summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src
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 /packages/core/src
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
Diffstat (limited to 'packages/core/src')
-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
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,