diff options
| author | Adam Malczewski <[email protected]> | 2026-05-23 19:07:21 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-23 19:07:21 +0900 |
| commit | 997b00034435440d412f955e05e53f09bae83f9e (patch) | |
| tree | 22d1f530e9e1a97bd19286456d2f06793bd030f7 | |
| parent | ff00dec6ae2971bee38c74cb00fe034de9a839ee (diff) | |
| download | dispatch-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.lock | 28 | ||||
| -rw-r--r-- | dispatch.toml | 5 | ||||
| -rw-r--r-- | packages/api/src/agent-manager.ts | 2 | ||||
| -rw-r--r-- | packages/api/src/routes/models.ts | 32 | ||||
| -rw-r--r-- | packages/core/package.json | 2 | ||||
| -rw-r--r-- | packages/core/src/agent/agent.ts | 44 | ||||
| -rw-r--r-- | packages/core/src/credentials/api-keys.ts | 10 | ||||
| -rw-r--r-- | packages/core/src/credentials/google.ts | 178 | ||||
| -rw-r--r-- | packages/core/src/credentials/index.ts | 4 | ||||
| -rw-r--r-- | packages/frontend/src/App.svelte | 2 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/KeyUsage.svelte | 53 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ModelSelector.svelte | 46 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 21 |
13 files changed, 384 insertions, 43 deletions
@@ -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; |
