summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-23 19:07:21 +0900
committerAdam Malczewski <[email protected]>2026-05-23 19:07:21 +0900
commit997b00034435440d412f955e05e53f09bae83f9e (patch)
tree22d1f530e9e1a97bd19286456d2f06793bd030f7
parentff00dec6ae2971bee38c74cb00fe034de9a839ee (diff)
downloaddispatch-997b00034435440d412f955e05e53f09bae83f9e.tar.gz
dispatch-997b00034435440d412f955e05e53f09bae83f9e.zip
feat: google gemini provider, adaptive thinking for opus 4.7, model search filter
- Added Google (Gemini) as a provider: add-key UI, env var resolution via resolveApiKey, usage tracking via native models endpoint + gemini.google.com cookie scraping - @ai-sdk/anthropic upgraded to v3 (adaptive thinking support) with LanguageModelV1 cast for ai v4 compat - Claude Opus 4.7 uses adaptive thinking (type: adaptive); all other models keep explicit budget tokens - Model selector modal: search filter with space matching dash/underscore - Copy button: all tool results truncated at 300 chars - Sidebar layout fix: Claude Reset panel removed from flex-1 fill to prevent overlap
-rw-r--r--bun.lock28
-rw-r--r--dispatch.toml5
-rw-r--r--packages/api/src/agent-manager.ts2
-rw-r--r--packages/api/src/routes/models.ts32
-rw-r--r--packages/core/package.json2
-rw-r--r--packages/core/src/agent/agent.ts44
-rw-r--r--packages/core/src/credentials/api-keys.ts10
-rw-r--r--packages/core/src/credentials/google.ts178
-rw-r--r--packages/core/src/credentials/index.ts4
-rw-r--r--packages/frontend/src/App.svelte2
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte53
-rw-r--r--packages/frontend/src/lib/components/ModelSelector.svelte46
-rw-r--r--packages/frontend/src/lib/types.ts21
13 files changed, 384 insertions, 43 deletions
diff --git a/bun.lock b/bun.lock
index eefe2a0..3bcf51b 100644
--- a/bun.lock
+++ b/bun.lock
@@ -25,7 +25,7 @@
"name": "@dispatch/core",
"version": "0.0.1",
"dependencies": {
- "@ai-sdk/anthropic": "1.2.12",
+ "@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/openai-compatible": "^0.2.0",
"ai": "^4.0.0",
"chokidar": "^5.0.0",
@@ -64,13 +64,13 @@
"packages": {
"7zip-bin": ["[email protected]", "", {}, "sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A=="],
- "@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ=="],
+ "@ai-sdk/anthropic": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-saEX+h5JDOkT9P/+REKDyikbnJiToFuLipgNcsmu4Zr3GW5kW1m9HhvrPK+vj63itIOsoZU6tmVIjkrePOlIUA=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "@ai-sdk/provider-utils": "2.2.8" }, "peerDependencies": { "zod": "^3.0.0" } }, "sha512-LkvfcM8slJedRyJa/MiMiaOzcMjV1zNDwzTHEGz7aAsgsQV0maLfmJRi/nuSwf5jmp0EouC+JXXDUj2l94HgQw=="],
- "@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
+ "@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
- "@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
+ "@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/react": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider-utils": "2.2.8", "@ai-sdk/ui-utils": "1.2.11", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "zod": "^3.23.8" }, "optionalPeers": ["zod"] }, "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g=="],
@@ -240,6 +240,8 @@
"@sindresorhus/is": ["@sindresorhus/[email protected]", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
+ "@standard-schema/spec": ["@standard-schema/[email protected]", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
"@sveltejs/acorn-typescript": ["@sveltejs/[email protected]", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA=="],
"@sveltejs/vite-plugin-svelte": ["@sveltejs/[email protected]", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", "magic-string": "^0.30.17", "vitefu": "^1.0.6" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.0.0" } }, "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ=="],
@@ -528,6 +530,8 @@
"estree-walker": ["[email protected]", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
+ "eventsource-parser": ["[email protected]", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
+
"expect-type": ["[email protected]", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"exponential-backoff": ["[email protected]", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="],
@@ -946,6 +950,16 @@
"zod-to-json-schema": ["[email protected]", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
+ "@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
+
+ "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
+
+ "@ai-sdk/react/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
+
+ "@ai-sdk/ui-utils/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
+
+ "@ai-sdk/ui-utils/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
+
"@electron/asar/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"@electron/fuses/fs-extra": ["[email protected]", "", { "dependencies": { "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ=="],
@@ -976,6 +990,10 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["[email protected]", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "ai/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
+
+ "ai/@ai-sdk/provider-utils": ["@ai-sdk/[email protected]", "", { "dependencies": { "@ai-sdk/provider": "1.1.3", "nanoid": "^3.3.8", "secure-json-parse": "^2.7.0" }, "peerDependencies": { "zod": "^3.23.8" } }, "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA=="],
+
"app-builder-lib/@electron/get": ["@electron/[email protected]", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ=="],
"app-builder-lib/ci-info": ["[email protected]", "", {}, "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA=="],
@@ -1020,6 +1038,8 @@
"tiny-async-pool/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
+ "@ai-sdk/react/@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/[email protected]", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg=="],
+
"@electron/asar/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="],
"@electron/get/fs-extra/jsonfile": ["[email protected]", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="],
diff --git a/dispatch.toml b/dispatch.toml
index b35f27a..ebcd3c7 100644
--- a/dispatch.toml
+++ b/dispatch.toml
@@ -25,6 +25,11 @@ base_url = "https://opencode.ai/zen/go/v1"
id = "opencode-2"
provider = "opencode-go"
base_url = "https://opencode.ai/zen/go/v1"
+[[keys]]
+id = "newyorkiedog"
+provider = "google"
+base_url = "https://generativelanguage.googleapis.com/v1beta/openai"
+
# ─── Permissions ─────────────────────────────────────────────────
[permissions]
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 5b19eb3..1a28371 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -548,7 +548,7 @@ export class AgentManager {
}
} else {
// Standard key: resolve from env var
- const envKey = resolveApiKey(key.id);
+ const envKey = resolveApiKey(key.id, key.env);
if (envKey) {
apiKey = envKey;
baseURL = key.base_url;
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
index b250263..1a54abb 100644
--- a/packages/api/src/routes/models.ts
+++ b/packages/api/src/routes/models.ts
@@ -6,6 +6,7 @@ import {
type ClaudeAccount,
fetchAnthropicModels,
fetchCopilotUsage,
+ fetchGoogleUsage,
fetchOpencodeUsage,
getAccountUsage,
getAnthropicHeaders,
@@ -112,7 +113,7 @@ modelsRoutes.get("/available", async (c) => {
});
}
- const apiKeyValue = resolveApiKey(keyId);
+ const apiKeyValue = resolveApiKey(keyId, key.definition.env);
if (!apiKeyValue) {
return c.json({ error: `no API key found for ${keyId}` }, 500);
}
@@ -148,7 +149,7 @@ modelsRoutes.get("/available", async (c) => {
return c.json({ error: "failed to parse provider response", details: String(err) }, 502);
}
- const models = data.data.map((m) => m.id);
+ const models = data.data.map((m) => m.id.replace(/^models\//, ""));
return c.json({ models });
});
@@ -291,7 +292,7 @@ modelsRoutes.get("/key-usage", async (c) => {
},
});
} else if (provider === "github-copilot") {
- const token = resolveApiKey(keyId);
+ const token = resolveApiKey(keyId, key.definition.env);
if (!token) {
return c.json({ error: `no API key found for ${keyId}` }, 502);
}
@@ -307,6 +308,21 @@ modelsRoutes.get("/key-usage", async (c) => {
resetAt: report.resetAt,
plan: report.plan,
});
+ } else if (provider === "google") {
+ const token = resolveApiKey(keyId, key.definition.env);
+ if (!token) {
+ return c.json({ error: `no API key found for ${keyId}. Set GOOGLE_API_KEY env var.` }, 502);
+ }
+ const report = await fetchGoogleUsage(token, key.definition.base_url);
+ if (!report) {
+ return c.json({ error: "failed to fetch Google usage data" }, 502);
+ }
+ return c.json({
+ provider: "google",
+ models: report.models,
+ currentUsage: report.currentUsage,
+ weeklyUsage: report.weeklyUsage,
+ });
} else {
return c.json({ error: "usage tracking not supported for this provider" }, 400);
}
@@ -400,13 +416,13 @@ modelsRoutes.get("/credentials-status", (c) => {
// ─── Add key to dispatch.toml ─────────────────────────────────
-const VALID_PROVIDERS = ["anthropic", "opencode-go", "github-copilot"] as const;
+const VALID_PROVIDERS = ["anthropic", "opencode-go", "google"] as const;
type SupportedProvider = (typeof VALID_PROVIDERS)[number];
const PROVIDER_BASE_URLS: Record<SupportedProvider, string> = {
anthropic: "https://api.anthropic.com/v1",
"opencode-go": "https://opencode.ai/zen/go/v1",
- "github-copilot": "https://api.githubcopilot.com",
+ google: "https://generativelanguage.googleapis.com/v1beta/openai",
};
modelsRoutes.post("/add-key", async (c) => {
@@ -445,6 +461,12 @@ modelsRoutes.post("/add-key", async (c) => {
if (provider === "anthropic") {
const credPath = `${homedir()}/.claude/.credentials-${id}.json`;
newBlock += `\ncredentials_file = "${credPath}"`;
+ } else {
+ const envVar =
+ provider === "google"
+ ? "GOOGLE_API_KEY"
+ : `DISPATCH_${id.toUpperCase().replace(/-/g, "_")}_KEY`;
+ newBlock += `\nenv = "${envVar}"`;
}
newBlock += "\n";
diff --git a/packages/core/package.json b/packages/core/package.json
index a9bf6e9..6f88398 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -11,7 +11,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
- "@ai-sdk/anthropic": "1.2.12",
+ "@ai-sdk/anthropic": "^3.0.0",
"@ai-sdk/openai-compatible": "^0.2.0",
"ai": "^4.0.0",
"chokidar": "^5.0.0",
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index bf9b22f..24de59d 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
@@ -1,6 +1,6 @@
import { realpathSync } from "node:fs";
import { dirname, isAbsolute, relative, resolve } from "node:path";
-import type { CoreMessage } from "ai";
+import type { CoreMessage, LanguageModelV1 } from "ai";
import { streamText } from "ai";
import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js";
import { createProvider, prefixToolName, unprefixToolName } from "../llm/provider.js";
@@ -260,28 +260,40 @@ export class Agent {
const effort = options?.reasoningEffort ?? this.config.reasoningEffort ?? "max";
// Build stream text options
+ const rawModel = providerFactory(this.config.model);
+ const model = rawModel as unknown as LanguageModelV1;
const streamOptions: Parameters<typeof streamText>[0] = {
- model: providerFactory(this.config.model),
+ model,
system: systemPrompt,
messages: toCoreMessages(stepMessages, isAnthropic),
tools,
};
if (isAnthropic && effort !== "none") {
- const budgetTokens =
- effort === "max"
- ? 16000
- : effort === "high"
- ? 10000
- : effort === "medium"
- ? 5000
- : effort === "low"
- ? 2000
- : 0;
- streamOptions.providerOptions = {
- anthropic: { thinking: { type: "enabled" as const, budgetTokens } },
- };
- streamOptions.maxTokens = budgetTokens + 8000;
+ const modelId = this.config.model;
+ const isOpus47 = modelId === "claude-opus-4-7";
+
+ if (isOpus47) {
+ // Opus 4.7 only supports adaptive thinking
+ streamOptions.providerOptions = {
+ anthropic: { thinking: { type: "adaptive" as const } },
+ };
+ } else {
+ const budgetTokens =
+ effort === "max"
+ ? 16000
+ : effort === "high"
+ ? 10000
+ : effort === "medium"
+ ? 5000
+ : effort === "low"
+ ? 2000
+ : 0;
+ streamOptions.providerOptions = {
+ anthropic: { thinking: { type: "enabled" as const, budgetTokens } },
+ };
+ streamOptions.maxTokens = budgetTokens + 8000;
+ }
} else if (!isAnthropic && effort !== "none") {
streamOptions.providerOptions = { openaiCompatible: { reasoningEffort: effort } };
}
diff --git a/packages/core/src/credentials/api-keys.ts b/packages/core/src/credentials/api-keys.ts
index af5aa0e..ef30400 100644
--- a/packages/core/src/credentials/api-keys.ts
+++ b/packages/core/src/credentials/api-keys.ts
@@ -40,10 +40,14 @@ export function getApiKey(keyId: string): string | null {
}
/**
- * Resolve an API key from the database. Returns null if not found.
+ * Resolve an API key from the database, with env var fallback.
+ * Pass the env var name (e.g. "GOOGLE_API_KEY") to check process.env as well.
*/
-export function resolveApiKey(keyId: string): string | null {
- return getApiKey(keyId);
+export function resolveApiKey(keyId: string, envVar?: string): string | null {
+ const dbKey = getApiKey(keyId);
+ if (dbKey) return dbKey;
+ if (envVar) return process.env[envVar] ?? null;
+ return null;
}
/**
diff --git a/packages/core/src/credentials/google.ts b/packages/core/src/credentials/google.ts
new file mode 100644
index 0000000..17bc930
--- /dev/null
+++ b/packages/core/src/credentials/google.ts
@@ -0,0 +1,178 @@
+// ─── Google Gemini Usage Tracking ───────────────────────────
+// Two modes:
+// 1. API key → queries native Gemini models endpoint for rate limits
+// 2. Cookie → scrapes gemini.google.com for current usage % (like OpenCode)
+//
+// Set GEMINI_COOKIE env var with the value of your __Secure-1PSID cookie
+// from gemini.google.com to see current usage percentages.
+
+export interface GoogleUsageBucket {
+ percentUsed: number; // 0-100
+ resetsAt?: string; // e.g. "22:40" or "30 May at 17:40"
+}
+
+export interface GoogleUsageReport {
+ // Mode 1: API key rate limits
+ models?: Array<{
+ name: string;
+ inputTokenLimit: number;
+ outputTokenLimit: number;
+ rpm: number;
+ requestsPerDay: number;
+ }>;
+ // Mode 2: Cookie-scraped usage from gemini.google.com
+ currentUsage?: GoogleUsageBucket;
+ weeklyUsage?: GoogleUsageBucket;
+}
+
+// Helpers to extract HTML text between markers
+function extractBetween(html: string, after: string, before: string): string | null {
+ const start = html.indexOf(after);
+ if (start === -1) return null;
+ const s = start + after.length;
+ const end = html.indexOf(before, s);
+ if (end === -1) return null;
+ return html.slice(s, end).trim();
+}
+
+function extractPercent(html: string, afterMarker: string): number | null {
+ const chunk = extractBetween(html, afterMarker, "%");
+ if (!chunk) return null;
+ const digits = chunk.replace(/\D/g, "");
+ const n = parseInt(digits, 10);
+ return Number.isNaN(n) ? null : n;
+}
+
+function extractResetTime(html: string, afterMarker: string): string | null {
+ // Look for "Resets at HH:MM" or "Resets on DD Mon at HH:MM"
+ const start = html.indexOf(afterMarker);
+ if (start === -1) return null;
+ const chunk = html.slice(start + afterMarker.length);
+ // Match time patterns
+ const m = chunk.match(/Resets?\s+(at|on)\s+([^<]+)/i);
+ if (!m?.[1] || !m[2]) return null;
+ return `${m[1]} ${m[2].trim()}`;
+}
+
+async function scrapeGeminiWeb(cookie: string): Promise<{
+ currentUsage?: GoogleUsageBucket;
+ weeklyUsage?: GoogleUsageBucket;
+} | null> {
+ try {
+ const response = await fetch("https://gemini.google.com/app", {
+ headers: {
+ cookie: `__Secure-1PSID=${cookie}`,
+ "accept-language": "en-US,en;q=0.9",
+ },
+ redirect: "follow",
+ });
+
+ if (!response.ok) return null;
+
+ const html = await response.text();
+
+ // Detect auth redirect
+ if (html.includes("ServiceLogin") || html.includes("sign in")) {
+ return null;
+ }
+
+ // Look for usage data in the page
+ // Pattern: "Current usage" followed by a percentage and reset time
+ const currentPct = extractPercent(html, "Current usage");
+ const currentReset = extractResetTime(html, "Current usage");
+
+ // Weekly limit section
+ const weeklyPct = extractPercent(html, "Weekly limit");
+ const weeklyReset = extractResetTime(html, "Weekly limit");
+
+ const result: {
+ currentUsage?: GoogleUsageBucket;
+ weeklyUsage?: GoogleUsageBucket;
+ } = {};
+
+ if (currentPct !== null) {
+ result.currentUsage = {
+ percentUsed: currentPct,
+ resetsAt: currentReset ?? undefined,
+ };
+ }
+
+ if (weeklyPct !== null) {
+ result.weeklyUsage = {
+ percentUsed: weeklyPct,
+ resetsAt: weeklyReset ?? undefined,
+ };
+ }
+
+ if (result.currentUsage || result.weeklyUsage) return result;
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+async function fetchModelsViaApiKey(apiKey: string): Promise<Array<{
+ name: string;
+ inputTokenLimit: number;
+ outputTokenLimit: number;
+ rpm: number;
+ requestsPerDay: number;
+}> | null> {
+ const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`;
+
+ try {
+ const response = await fetch(url);
+ if (!response.ok) return null;
+
+ const data = (await response.json()) as {
+ models?: Array<{
+ name: string;
+ inputTokenLimit?: number;
+ outputTokenLimit?: number;
+ rateLimit?: { requestsPerMinute?: number };
+ limits?: { requestsPerDay?: number };
+ }>;
+ };
+
+ return (data.models ?? [])
+ .filter((m) => {
+ const name = m.name?.replace(/^models\//, "") ?? "";
+ return name.startsWith("gemini-");
+ })
+ .map((m) => ({
+ name: m.name?.replace(/^models\//, "") ?? "",
+ inputTokenLimit: m.inputTokenLimit ?? 0,
+ outputTokenLimit: m.outputTokenLimit ?? 0,
+ rpm: m.rateLimit?.requestsPerMinute ?? 0,
+ requestsPerDay: m.limits?.requestsPerDay ?? 0,
+ }));
+ } catch {
+ return null;
+ }
+}
+
+export async function fetchGoogleUsage(
+ apiKey: string,
+ _baseUrl: string,
+): Promise<GoogleUsageReport | null> {
+ const results: GoogleUsageReport = {};
+
+ // Try API key mode: get model rate limits
+ const models = await fetchModelsViaApiKey(apiKey);
+ if (models) {
+ results.models = models;
+ }
+
+ // Try cookie mode: scrape gemini.google.com usage
+ const cookie = process.env.GEMINI_COOKIE;
+ if (cookie) {
+ const scraped = await scrapeGeminiWeb(cookie);
+ if (scraped) {
+ if (scraped.currentUsage) results.currentUsage = scraped.currentUsage;
+ if (scraped.weeklyUsage) results.weeklyUsage = scraped.weeklyUsage;
+ }
+ }
+
+ if (results.models || results.currentUsage || results.weeklyUsage) return results;
+ return null;
+}
diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts
index e7f8a12..ff7392b 100644
--- a/packages/core/src/credentials/index.ts
+++ b/packages/core/src/credentials/index.ts
@@ -30,6 +30,10 @@ export {
fetchCopilotUsage,
} from "./copilot.js";
export {
+ fetchGoogleUsage,
+ type GoogleUsageReport,
+} from "./google.js";
+export {
fetchOpencodeUsage,
type OpencodeUsageBucket,
type OpencodeUsageReport,
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
index a2f7327..591266c 100644
--- a/packages/frontend/src/App.svelte
+++ b/packages/frontend/src/App.svelte
@@ -190,7 +190,7 @@ onMount(() => {
<select id="add-key-provider" class="select select-bordered select-sm w-full" bind:value={addKeyProvider}>
<option value="anthropic">Anthropic</option>
<option value="opencode-go">OpenCode</option>
- <option value="github-copilot">GitHub Copilot</option>
+ <option value="google">Google (Gemini)</option>
</select>
</div>
<div class="flex flex-col gap-1">
diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte
index 49c3cf6..96a4b08 100644
--- a/packages/frontend/src/lib/components/KeyUsage.svelte
+++ b/packages/frontend/src/lib/components/KeyUsage.svelte
@@ -404,6 +404,59 @@ function hasBucketData(bucket: UsageBucket | undefined): boolean {
<span class="text-xs text-base-content/40">Resets: {formatDate(entry.data.resetAt)}</span>
{/if}
</div>
+ {:else if entry.data.provider === "google"}
+ <div class="flex flex-col gap-0.5 pl-1">
+ <!-- Cookie-scraped usage from gemini.google.com -->
+ {#if entry.data.currentUsage}
+ {@const u = entry.data.currentUsage}
+ <div class="flex flex-col gap-0.5">
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">Current</span>
+ <span class="text-xs font-mono">{u.percentUsed}%</span>
+ </div>
+ <progress class="progress w-full h-2 {progressClass(u.percentUsed / 100)}" value={u.percentUsed} max="100"></progress>
+ {#if u.resetsAt}
+ <span class="text-xs text-base-content/40">Resets: {u.resetsAt}</span>
+ {/if}
+ </div>
+ {/if}
+ {#if entry.data.weeklyUsage}
+ {@const w = entry.data.weeklyUsage}
+ <div class="flex flex-col gap-0.5">
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">Weekly</span>
+ <span class="text-xs font-mono">{w.percentUsed}%</span>
+ </div>
+ <progress class="progress w-full h-2 {progressClass(w.percentUsed / 100)}" value={w.percentUsed} max="100"></progress>
+ {#if w.resetsAt}
+ <span class="text-xs text-base-content/40">Resets: {w.resetsAt}</span>
+ {/if}
+ </div>
+ {/if}
+ <!-- API key rate limits -->
+ {#if !entry.data.currentUsage && entry.data.models && entry.data.models.length > 0}
+ {@const m = entry.data.models[0]}
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">Models</span>
+ <span class="text-xs font-mono">{entry.data.models.length} available</span>
+ </div>
+ {#if m.rpm > 0}
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">RPM</span>
+ <span class="text-xs font-mono">{m.rpm}</span>
+ </div>
+ {/if}
+ {#if m.requestsPerDay > 0}
+ <div class="flex items-center justify-between">
+ <span class="text-xs text-base-content/50">RPD</span>
+ <span class="text-xs font-mono">{m.requestsPerDay.toLocaleString()}</span>
+ </div>
+ {/if}
+ {#if !entry.data.currentUsage}
+ <p class="text-xs text-base-content/40 mt-0.5">Set GEMINI_COOKIE (__Secure-1PSID) for usage %</p>
+ {/if}
+ {/if}
+ </div>
{/if}
{/if}
</div>
diff --git a/packages/frontend/src/lib/components/ModelSelector.svelte b/packages/frontend/src/lib/components/ModelSelector.svelte
index bbbf036..235cf3b 100644
--- a/packages/frontend/src/lib/components/ModelSelector.svelte
+++ b/packages/frontend/src/lib/components/ModelSelector.svelte
@@ -62,6 +62,7 @@ const modelCache = new Map<string, string[]>();
let loadingModels = $state(false);
let modelError = $state<string | null>(null);
let sliderDragging = $state<number | null>(null);
+ let modelSearch = $state("");
let cwdExists = $state<boolean | null>(null);
let cwdCheckTimer: ReturnType<typeof setTimeout> | null = null;
@@ -126,6 +127,7 @@ const modelCache = new Map<string, string[]>();
if (!keyId) return;
showModelModal = true;
modelError = null;
+ modelSearch = "";
// Check session cache
if (modelCache.has(keyId)) {
@@ -393,17 +395,39 @@ const modelCache = new Map<string, string[]>();
<span>{modelError}</span>
</div>
{:else}
- <div class="mt-4 flex flex-col gap-1 max-h-96 overflow-y-auto">
- {#each availableModels as model}
- <button
- class="btn {model === activeModelId
- ? 'btn-primary'
- : 'btn-ghost'} justify-start font-mono text-base"
- onclick={() => selectModel(model)}
- >
- {model}
- </button>
- {/each}
+ {@const search = modelSearch.toLowerCase().trim()}
+ {@const searchRegex = search
+ ? new RegExp(
+ search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/ /g, "[ _-]"),
+ )
+ : null}
+ {@const filteredModels = searchRegex
+ ? availableModels.filter((m) => searchRegex.test(m.toLowerCase()))
+ : availableModels}
+ <div class="mt-4 flex flex-col gap-1">
+ <input
+ type="text"
+ class="input input-bordered input-sm w-full"
+ placeholder="Filter models..."
+ bind:value={modelSearch}
+ />
+ <div class="mt-2 max-h-96 overflow-y-auto flex flex-col gap-1">
+ {#each filteredModels as model}
+ <button
+ class="btn {model === activeModelId
+ ? 'btn-primary'
+ : 'btn-ghost'} justify-start font-mono text-base"
+ onclick={() => selectModel(model)}
+ >
+ {model}
+ </button>
+ {/each}
+ {#if filteredModels.length === 0}
+ <p class="text-xs text-base-content/50 py-2 text-center">
+ {search ? 'No models match your search.' : 'No models available.'}
+ </p>
+ {/if}
+ </div>
</div>
{/if}
<div class="modal-action">
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts
index 532c948..2e6b219 100644
--- a/packages/frontend/src/lib/types.ts
+++ b/packages/frontend/src/lib/types.ts
@@ -163,4 +163,23 @@ export interface CopilotUsageData {
plan?: string;
}
-export type KeyUsageData = ClaudeUsageData | OpencodeUsageData | CopilotUsageData;
+export interface GoogleUsageData {
+ provider: "google";
+ models?: Array<{
+ name: string;
+ inputTokenLimit: number;
+ outputTokenLimit: number;
+ rpm: number;
+ requestsPerDay: number;
+ }>;
+ currentUsage?: {
+ percentUsed: number;
+ resetsAt?: string;
+ };
+ weeklyUsage?: {
+ percentUsed: number;
+ resetsAt?: string;
+ };
+}
+
+export type KeyUsageData = ClaudeUsageData | OpencodeUsageData | CopilotUsageData | GoogleUsageData;