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 /packages/core/src | |
| 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
Diffstat (limited to 'packages/core/src')
| -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 |
4 files changed, 217 insertions, 19 deletions
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, |
