diff --git a/README.md b/README.md index f6a531d..4efc406 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,9 @@ Add to your `opencode.json`: } ``` -Then enable the providers you use: +That's it. Providers are auto-detected based on your OpenCode configuration. Toasts appear automatically after assistant responses. + +**Optional:** To restrict which providers are queried, set `enabledProviders` explicitly: ```jsonc { @@ -34,8 +36,6 @@ Then enable the providers you use: } ``` -That's it. Toasts appear automatically after assistant responses. - > **LLM Agents:** Ask your agent to install `https://github.com/slkiser/opencode-quota` with the providers you have enabled. See [LLM Agent Installation Instructions](#llm-agent-installation-instructions) for details. ## Commands @@ -171,7 +171,7 @@ All options go under `experimental.quotaToast` in `opencode.json`: | ------------------- | ------------ | ----------------------------------------------- | | `enabled` | `true` | Enable/disable plugin | | `enableToast` | `true` | Show popup toasts | -| `enabledProviders` | `[]` | Provider IDs to query (see table above) | +| `enabledProviders` | `"auto"` | Provider IDs to query, or `"auto"` to detect | | `minIntervalMs` | `300000` | Minimum ms between API fetches (default: 5 min) | | `toastDurationMs` | `9000` | How long toasts display (ms) | | `onlyCurrentModel` | `false` | Only show quota for the current model | @@ -212,9 +212,9 @@ Add the plugin to the user's `opencode.json`: If the user already has plugins, append to the existing array. -#### Step 3: Configure Providers +#### Step 3: Configure Providers (Optional) -Based on the user's connected providers, add the appropriate `enabledProviders`: +By default, providers are auto-detected. If the user wants to restrict which providers are queried, add explicit `enabledProviders`: ```jsonc { diff --git a/package-lock.json b/package-lock.json index f2f09c2..c61880e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1352,9 +1352,9 @@ "license": "ISC" }, "node_modules/hono": { - "version": "4.11.4", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.4.tgz", - "integrity": "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==", + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", + "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "license": "MIT", "peer": true, "engines": { diff --git a/src/lib/config.ts b/src/lib/config.ts index 6bc85ad..2aeb134 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -30,6 +30,30 @@ function isValidGoogleModelId(id: unknown): id is GoogleModelId { return typeof id === "string" && ["G3PRO", "G3FLASH", "CLAUDE", "G3IMAGE"].includes(id); } +/** + * Normalize a provider ID for consistent matching. + * - Trims whitespace and lowercases + * - Maps known synonyms to canonical IDs + */ +function normalizeProviderId(id: string): string { + const s = id.trim().toLowerCase(); + switch (s) { + case "github-copilot": + case "copilot-chat": + case "github-copilot-chat": + return "copilot"; + default: + return s; + } +} + +/** + * Remove duplicates from an array while preserving order + */ +function dedupe(list: T[]): T[] { + return [...new Set(list)]; +} + /** * Load plugin configuration from OpenCode config * @@ -72,9 +96,17 @@ export async function loadConfig( debug: typeof quotaToastConfig.debug === "boolean" ? quotaToastConfig.debug : DEFAULT_CONFIG.debug, - enabledProviders: Array.isArray(quotaToastConfig.enabledProviders) - ? quotaToastConfig.enabledProviders.filter((p) => typeof p === "string") - : DEFAULT_CONFIG.enabledProviders, + enabledProviders: + quotaToastConfig.enabledProviders === "auto" + ? "auto" + : Array.isArray(quotaToastConfig.enabledProviders) + ? dedupe( + quotaToastConfig.enabledProviders + .filter((p): p is string => typeof p === "string") + .map(normalizeProviderId) + .filter(Boolean), + ) + : DEFAULT_CONFIG.enabledProviders, googleModels: Array.isArray(quotaToastConfig.googleModels) ? quotaToastConfig.googleModels.filter(isValidGoogleModelId) : DEFAULT_CONFIG.googleModels, @@ -124,7 +156,7 @@ export async function loadConfig( }, }; - // enabledProviders is intentionally allowed to be empty (providers OFF by default). + // enabledProviders: "auto" means auto-detect; explicit array means user-specified. // Ensure at least one Google model is configured if (config.googleModels.length === 0) { diff --git a/src/lib/copilot.ts b/src/lib/copilot.ts index c02057f..379ae70 100644 --- a/src/lib/copilot.ts +++ b/src/lib/copilot.ts @@ -77,10 +77,18 @@ function buildLegacyTokenHeaders(token: string): Record { /** * Read Copilot auth data from auth.json + * + * Tries multiple key names to handle different OpenCode versions/configs. */ async function readCopilotAuth(): Promise { const authData = await readAuthFile(); - const copilotAuth = authData?.["github-copilot"]; + if (!authData) return null; + + // Try known key names in priority order + const copilotAuth = + authData["github-copilot"] ?? + (authData as Record)["copilot"] ?? + (authData as Record)["copilot-chat"]; if (!copilotAuth || copilotAuth.type !== "oauth" || !copilotAuth.refresh) { return null; diff --git a/src/lib/opencode-auth.ts b/src/lib/opencode-auth.ts index 97b3fb5..1f1b1a7 100644 --- a/src/lib/opencode-auth.ts +++ b/src/lib/opencode-auth.ts @@ -17,7 +17,9 @@ export function getAuthPath(): string { const dataDir = process.platform === "win32" ? process.env.LOCALAPPDATA || join(home, "AppData", "Local") - : join(home, ".local", "share"); + : process.platform === "darwin" + ? join(home, "Library", "Application Support") + : join(home, ".local", "share"); return join(dataDir, "opencode", "auth.json"); } diff --git a/src/lib/quota-stats.ts b/src/lib/quota-stats.ts index f75b476..99f0eb8 100644 --- a/src/lib/quota-stats.ts +++ b/src/lib/quota-stats.ts @@ -110,12 +110,17 @@ function messageBuckets(msg: OpenCodeMessage): TokenBuckets { function normalizeModelId(raw: string): string { let s = raw.trim(); + + // Strip provider prefixes like "github-copilot/claude-opus-4.6" or "anthropic/claude-opus-4.6" + const lastSlash = s.lastIndexOf("/"); + if (lastSlash !== -1) s = s.slice(lastSlash + 1); + // routing prefixes if (s.toLowerCase().startsWith("antigravity-")) s = s.slice("antigravity-".length); // common subscription variants if (s.toLowerCase().endsWith("-thinking")) s = s.slice(0, -"-thinking".length); - // claude 4.5 -> 4-5 (models.dev uses dash) - s = s.replace(/claude-([a-z-]+)-4\.5\b/i, "claude-$1-4-5"); + // claude dotted versions -> hyphenated (models.dev uses dash): 4.5 -> 4-5, 4.6 -> 4-6, etc. + s = s.replace(/(claude-[a-z-]+)-(\d+)\.(\d+)(?=$|[^0-9])/gi, "$1-$2-$3"); // special: "glm-4.7-free" -> "glm-4.7" s = s.replace(/\bglm-(\d+)\.(\d+)-free\b/i, "glm-$1.$2"); // internal OpenCode alias (Zen) @@ -139,6 +144,26 @@ function inferOfficialProviderFromModelId(modelId: string): string | null { return null; } +/** + * Get pricing alias candidates for Anthropic models when the exact key is missing. + * Returns ordered candidates to try, including the original model. + */ +function anthropicPricingCandidates(model: string): string[] { + // model is expected normalized like "claude-opus-4-6" + if (model === "claude-opus-4-6") return [model, "claude-opus-4-5"]; + if (model === "claude-sonnet-4-6") return [model, "claude-sonnet-4-7", "claude-sonnet-4-5"]; + // Future-proof: try next lower version for any claude-*-N-M pattern + const match = model.match(/^(claude-[a-z]+-\d+)-(\d+)$/); + if (match) { + const [, prefix, minor] = match; + const minorNum = parseInt(minor, 10); + if (minorNum > 0) { + return [model, `${prefix}-${minorNum - 1}`]; + } + } + return [model]; +} + function mapToOfficialPricingKey(source: { providerID?: string; modelID?: string; @@ -188,6 +213,16 @@ function mapToOfficialPricingKey(source: { } } + // Anthropic alias fallback: try alternative version keys when exact key is missing + if (inferredProvider === "anthropic") { + const candidates = anthropicPricingCandidates(normalizedModel); + for (const candidate of candidates) { + if (lookupCost("anthropic", candidate)) { + return { ok: true, key: { provider: "anthropic", model: candidate } }; + } + } + } + return { ok: true, key: { provider: inferredProvider, model: normalizedModel } }; } diff --git a/src/lib/quota-status.ts b/src/lib/quota-status.ts index 050061a..475dd61 100644 --- a/src/lib/quota-status.ts +++ b/src/lib/quota-status.ts @@ -43,9 +43,11 @@ function tokensTotal(t: { export async function buildQuotaStatusReport(params: { configSource: string; configPaths: string[]; - enabledProviders: string[]; + enabledProviders: string[] | "auto"; onlyCurrentModel: boolean; currentModel?: string; + /** Whether a session was available for model lookup */ + sessionModelLookup?: "ok" | "not_found" | "no_session"; providerAvailability: Array<{ id: string; enabled: boolean; @@ -71,10 +73,17 @@ export async function buildQuotaStatusReport(params: { `- configSource: ${params.configSource}${params.configPaths.length ? ` (${params.configPaths.join(" | ")})` : ""}`, ); lines.push( - `- enabledProviders: ${params.enabledProviders.length ? params.enabledProviders.join(",") : "(none)"}`, + `- enabledProviders: ${params.enabledProviders === "auto" ? "(auto)" : params.enabledProviders.length ? params.enabledProviders.join(",") : "(none)"}`, ); lines.push(`- onlyCurrentModel: ${params.onlyCurrentModel ? "true" : "false"}`); - lines.push(`- currentModel: ${params.currentModel ?? "(unknown)"}`); + const modelDisplay = params.currentModel + ? params.currentModel + : params.sessionModelLookup === "not_found" + ? "(error: session.get returned no modelID)" + : params.sessionModelLookup === "no_session" + ? "(no session available)" + : "(unknown)"; + lines.push(`- currentModel: ${modelDisplay}`); lines.push("- providers:"); for (const p of params.providerAvailability) { const bits: string[] = []; diff --git a/src/lib/types.ts b/src/lib/types.ts index 0a0e449..4b4052f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -34,8 +34,11 @@ export interface QuotaToastConfig { * * Keep this list short and user-friendly; each provider advertises a stable id. * Example: ["copilot", "google-antigravity"]. + * + * When set to "auto" (or left unconfigured), the plugin will auto-enable + * all providers whose `isAvailable()` returns true at runtime. */ - enabledProviders: string[]; + enabledProviders: string[] | "auto"; googleModels: GoogleModelId[]; showOnIdle: boolean; @@ -72,8 +75,8 @@ export const DEFAULT_CONFIG: QuotaToastConfig = { debug: false, - // Providers are OFF by default; users opt-in via config. - enabledProviders: [], + // Providers are auto-detected by default; set to explicit list to opt-in manually. + enabledProviders: "auto" as const, // If Google Antigravity is enabled, default to Claude only. googleModels: ["CLAUDE"], diff --git a/src/plugin.ts b/src/plugin.ts index 6c454bc..7a038ce 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -48,6 +48,7 @@ interface OpencodeClient { get: (params: { path: { id: string } }) => Promise<{ data?: { parentID?: string; + modelID?: string; }; }>; prompt: (params: { @@ -491,11 +492,28 @@ export const QuotaToastPlugin: Plugin = async ({ client }) => { } } + /** + * Get the current model from the active session. + * + * Only uses session-scoped model lookup. Does NOT fall back to + * client.config.get() because that returns the global/default model + * which can be stale across sessions. + */ + async function getCurrentModel(sessionID?: string): Promise { + if (!sessionID) return undefined; + try { + const sessionResp = await typedClient.session.get({ path: { id: sessionID } }); + return sessionResp.data?.modelID; + } catch { + return undefined; + } + } + function formatDebugInfo(params: { trigger: string; reason: string; currentModel?: string; - enabledProviders: string[]; + enabledProviders: string[] | "auto"; availability?: Array<{ id: string; ok: boolean }>; }): string { const availability = params.availability @@ -503,7 +521,11 @@ export const QuotaToastPlugin: Plugin = async ({ client }) => { : "unknown"; const providers = - params.enabledProviders.length > 0 ? params.enabledProviders.join(",") : "(none)"; + params.enabledProviders === "auto" + ? "(auto)" + : params.enabledProviders.length > 0 + ? params.enabledProviders.join(",") + : "(none)"; const modelPart = params.currentModel ? ` model=${params.currentModel}` : ""; @@ -531,10 +553,18 @@ export const QuotaToastPlugin: Plugin = async ({ client }) => { : null; } - const providers = getProviders().filter((p) => config.enabledProviders.includes(p.id)); + const allProviders = getProviders(); + const isAutoMode = config.enabledProviders === "auto"; + const enabledProviderIds = isAutoMode ? [] : config.enabledProviders; + + // When enabledProviders is "auto", we'll filter by availability below. + // When explicit, filter to just the listed providers. + const providers = isAutoMode + ? allProviders + : allProviders.filter((p) => enabledProviderIds.includes(p.id)); - // Providers are opt-in; when none are enabled, do nothing. - if (providers.length === 0) { + // Only bail on empty if user explicitly configured an empty list. + if (!isAutoMode && providers.length === 0) { return config.debug ? formatDebugInfo({ trigger, reason: "enabledProviders empty", enabledProviders: [] }) : null; @@ -542,12 +572,7 @@ export const QuotaToastPlugin: Plugin = async ({ client }) => { let currentModel: string | undefined; if (config.onlyCurrentModel) { - try { - const configResponse = await typedClient.config.get(); - currentModel = configResponse.data?.model; - } catch { - currentModel = undefined; - } + currentModel = await getCurrentModel(sessionID); } const ctx = { @@ -633,7 +658,7 @@ export const QuotaToastPlugin: Plugin = async ({ client }) => { if (!config.debug) return formatted; - const debugFooter = `\n\n[debug] src=${configMeta.source} providers=${config.enabledProviders.join(",") || "(none)"} avail=${avail + const debugFooter = `\n\n[debug] src=${configMeta.source} providers=${config.enabledProviders === "auto" ? "(auto)" : config.enabledProviders.join(",") || "(none)"} avail=${avail .map((x) => `${x.p.id}:${x.ok ? "ok" : "no"}`) .join(" ")}`; @@ -727,8 +752,12 @@ export const QuotaToastPlugin: Plugin = async ({ client }) => { if (!configLoaded) await refreshConfig(); if (!config.enabled) return null; - const providers = getProviders().filter((p) => config.enabledProviders.includes(p.id)); - if (providers.length === 0) return null; + const allProviders = getProviders(); + const isAutoMode = config.enabledProviders === "auto"; + const providers = isAutoMode + ? allProviders + : allProviders.filter((p) => config.enabledProviders.includes(p.id)); + if (!isAutoMode && providers.length === 0) return null; const ctx = { client: typedClient, @@ -816,16 +845,18 @@ export const QuotaToastPlugin: Plugin = async ({ client }) => { refreshGoogleTokens?: boolean; skewMs?: number; force?: boolean; + sessionID?: string; }): Promise { await refreshConfig(); - let currentModel: string | undefined; - try { - const configResponse = await typedClient.config.get(); - currentModel = configResponse.data?.model; - } catch { - currentModel = undefined; - } + const currentModel = await getCurrentModel(params.sessionID); + const sessionModelLookup: "ok" | "not_found" | "no_session" = !params.sessionID + ? "no_session" + : currentModel + ? "ok" + : "not_found"; + + const isAutoMode = config.enabledProviders === "auto"; const providers = getProviders(); const availability = await Promise.all( @@ -841,7 +872,8 @@ export const QuotaToastPlugin: Plugin = async ({ client }) => { } return { id: p.id, - enabled: config.enabledProviders.includes(p.id), + // In auto mode, a provider is effectively "enabled" if it's available. + enabled: isAutoMode ? ok : config.enabledProviders.includes(p.id), available: ok, matchesCurrentModel: typeof p.matchesCurrentModel === "function" && currentModel @@ -861,6 +893,7 @@ export const QuotaToastPlugin: Plugin = async ({ client }) => { enabledProviders: config.enabledProviders, onlyCurrentModel: config.onlyCurrentModel, currentModel, + sessionModelLookup, providerAvailability: availability, googleRefresh: refresh ? { @@ -942,7 +975,41 @@ export const QuotaToastPlugin: Plugin = async ({ client }) => { } if (!msg) { - await injectRawOutput(sessionID, "Quota unavailable"); + // Provide an actionable message instead of a generic "unavailable". + if (!configLoaded) { + await injectRawOutput(sessionID, "Quota unavailable (config not loaded, try again)"); + } else if (!config.enabled) { + await injectRawOutput(sessionID, "Quota disabled in config (enabled: false)"); + } else { + // Check what providers are available for a more specific hint. + const allProvs = getProviders(); + const ctx = { + client: typedClient, + config: { googleModels: config.googleModels }, + }; + const avail = await Promise.all( + allProvs.map(async (p) => { + try { + return { id: p.id, ok: await p.isAvailable(ctx) }; + } catch { + return { id: p.id, ok: false }; + } + }), + ); + const availableIds = avail.filter((x) => x.ok).map((x) => x.id); + + if (availableIds.length === 0) { + await injectRawOutput( + sessionID, + "Quota unavailable\n\nNo quota providers detected. Make sure you are logged in to a supported provider (Copilot, OpenAI, etc.).\n\nRun /quota_status for diagnostics.", + ); + } else { + await injectRawOutput( + sessionID, + `Quota unavailable\n\nProviders detected (${availableIds.join(", ")}) but returned no data. This may be a temporary API error.\n\nRun /quota_status for diagnostics.`, + ); + } + } throw new Error("__QUOTA_COMMAND_HANDLED__"); } @@ -1037,6 +1104,7 @@ export const QuotaToastPlugin: Plugin = async ({ client }) => { ? (parsed.value["skewMs"] as number) : undefined, force: parsed.value["force"] === true, + sessionID, }); await injectRawOutput(sessionID, out); throw new Error("__QUOTA_COMMAND_HANDLED__"); @@ -1128,6 +1196,7 @@ export const QuotaToastPlugin: Plugin = async ({ client }) => { refreshGoogleTokens: args.refreshGoogleTokens, skewMs: args.skewMs, force: args.force, + sessionID: context.sessionID, }); context.metadata({ title: "Quota Status" }); await injectRawOutput(context.sessionID, out); diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index 9e56fea..afe0ee6 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -14,16 +14,22 @@ export const copilotProvider: QuotaProvider = { try { const resp = await ctx.client.config.providers(); const ids = new Set((resp.data?.providers ?? []).map((p) => p.id)); - return ids.has("github-copilot"); + return ids.has("github-copilot") || ids.has("copilot") || ids.has("copilot-chat"); } catch { return false; } }, matchesCurrentModel(model: string): boolean { - const provider = model.split("/")[0]?.toLowerCase(); - if (!provider) return false; - return provider.includes("copilot") || provider.includes("github"); + const lower = model.toLowerCase(); + // Check provider prefix (part before "/") + const provider = lower.split("/")[0]; + if (provider && (provider.includes("copilot") || provider.includes("github"))) { + return true; + } + // Also match if the full model string contains "copilot" or "github-copilot" + // to handle models like "github-copilot/claude-sonnet-4.5" + return lower.includes("copilot") || lower.includes("github-copilot"); }, async fetch(_ctx: QuotaProviderContext): Promise {