Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
{
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 36 additions & 4 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(list: T[]): T[] {
return [...new Set(list)];
}

/**
* Load plugin configuration from OpenCode config
*
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
10 changes: 9 additions & 1 deletion src/lib/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,18 @@ function buildLegacyTokenHeaders(token: string): Record<string, string> {

/**
* Read Copilot auth data from auth.json
*
* Tries multiple key names to handle different OpenCode versions/configs.
*/
async function readCopilotAuth(): Promise<CopilotAuthData | null> {
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<string, CopilotAuthData | undefined>)["copilot"] ??
(authData as Record<string, CopilotAuthData | undefined>)["copilot-chat"];

if (!copilotAuth || copilotAuth.type !== "oauth" || !copilotAuth.refresh) {
return null;
Expand Down
4 changes: 3 additions & 1 deletion src/lib/opencode-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}

Expand Down
39 changes: 37 additions & 2 deletions src/lib/quota-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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;
Expand Down Expand Up @@ -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 } };
}

Expand Down
15 changes: 12 additions & 3 deletions src/lib/quota-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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[] = [];
Expand Down
9 changes: 6 additions & 3 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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"],
Expand Down
Loading