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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ That's it. Toasts appear automatically after main agent responses.
| GitHub Copilot | `copilot` | Uses OpenCode auth\* |
| OpenAI (Plus/Pro) | `openai` | Uses OpenCode auth |
| Firmware AI | `firmware` | Uses OpenCode auth or API key |
| Chutes AI | `chutes` | Uses OpenCode auth or API key |
| Google Antigravity | `google-antigravity` | Multi-account via `opencode-antigravity-auth` |

### Firmware AI Setup
Expand All @@ -83,6 +84,27 @@ Firmware works automatically if OpenCode has Firmware configured. Alternatively,

The `apiKey` field supports the `{env:VAR_NAME}` syntax to reference environment variables, or you can provide the key directly.

### Chutes AI Setup

Chutes works automatically if OpenCode has Chutes configured. Alternatively, you can provide an API key in your `opencode.json`:

```jsonc
{
"provider": {
"chutes": {
"options": {
"apiKey": "{env:CHUTES_API_KEY}",
},
},
},
"experimental": {
"quotaToast": {
"enabledProviders": ["chutes"],
},
},
}
```

### GitHub Copilot Setup (optional)

Copilot works with no extra setup as long as OpenCode already has Copilot configured and logged in.
Expand Down
222 changes: 222 additions & 0 deletions src/lib/chutes-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/**
* Chutes API key configuration resolver
*
* Resolution priority (first wins):
* 1. Environment variable: CHUTES_API_KEY
* 2. opencode.json/opencode.jsonc: provider.chutes.options.apiKey
* - Supports {env:VAR_NAME} syntax for environment variable references
* 3. auth.json: chutes.key (legacy/fallback)
*/

import { existsSync } from "fs";
import { readFile } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
import { readAuthFile } from "./opencode-auth.js";

/** Result of Chutes API key resolution */
export interface ChutesApiKeyResult {
key: string;
source: ChutesKeySource;
}

/** Source of the resolved API key */
export type ChutesKeySource =
| "env:CHUTES_API_KEY"
| "opencode.json"
| "opencode.jsonc"
| "auth.json";

/**
* Strip JSONC comments (// and /* ... *​/) from a string.
*/
function stripJsonComments(content: string): string {
let result = "";
let i = 0;
let inString = false;
let stringChar = "";

while (i < content.length) {
const char = content[i];
const nextChar = content[i + 1];

if ((char === '"' || char === "'") && (i === 0 || content[i - 1] !== "\\")) {
if (!inString) {
inString = true;
stringChar = char;
} else if (char === stringChar) {
inString = false;
}
result += char;
i++;
continue;
}

if (!inString) {
if (char === "/" && nextChar === "/") {
while (i < content.length && content[i] !== "\n") {
i++;
}
continue;
}

if (char === "/" && nextChar === "*") {
i += 2;
while (i < content.length - 1 && !(content[i] === "*" && content[i + 1] === "/")) {
i++;
}
i += 2;
continue;
}
}

result += char;
i++;
}

return result;
}

/**
* Parse JSON or JSONC content
*/
function parseJsonOrJsonc(content: string, isJsonc: boolean): unknown {
const toParse = isJsonc ? stripJsonComments(content) : content;
return JSON.parse(toParse);
}

/**
* Resolve {env:VAR_NAME} syntax in a string value
*/
function resolveEnvTemplate(value: string): string | null {
const match = value.match(/^\{env:([^}]+)\}$/);
if (!match) return value;

const envVar = match[1];
const envValue = process.env[envVar];
return envValue && envValue.trim().length > 0 ? envValue.trim() : null;
}

/**
* Extract Chutes API key from opencode config object
*/
function extractChutesKeyFromConfig(config: unknown): string | null {
if (!config || typeof config !== "object") return null;

const root = config as Record<string, unknown>;
const provider = root.provider;
if (!provider || typeof provider !== "object") return null;

const chutes = (provider as Record<string, unknown>).chutes;
if (!chutes || typeof chutes !== "object") return null;

const options = (chutes as Record<string, unknown>).options;
if (!options || typeof options !== "object") return null;

const apiKey = (options as Record<string, unknown>).apiKey;
if (typeof apiKey !== "string" || apiKey.trim().length === 0) return null;

return resolveEnvTemplate(apiKey.trim());
}

/**
* Get candidate paths for opencode.json/opencode.jsonc files
*/
export function getOpencodeConfigCandidatePaths(): Array<{ path: string; isJsonc: boolean }> {
const cwd = process.cwd();
const configBaseDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");

return [
{ path: join(cwd, "opencode.jsonc"), isJsonc: true },
{ path: join(cwd, "opencode.json"), isJsonc: false },
{ path: join(configBaseDir, "opencode", "opencode.jsonc"), isJsonc: true },
{ path: join(configBaseDir, "opencode", "opencode.json"), isJsonc: false },
];
}

/**
* Read and parse opencode config file
*/
async function readOpencodeConfig(
filePath: string,
isJsonc: boolean,
): Promise<{ config: unknown; path: string; isJsonc: boolean } | null> {
try {
if (!existsSync(filePath)) return null;
const content = await readFile(filePath, "utf-8");
const config = parseJsonOrJsonc(content, isJsonc);
return { config, path: filePath, isJsonc };
} catch {
return null;
}
}

/**
* Resolve Chutes API key from all available sources.
*/
export async function resolveChutesApiKey(): Promise<ChutesApiKeyResult | null> {
const envKey = process.env.CHUTES_API_KEY?.trim();
if (envKey && envKey.length > 0) {
return { key: envKey, source: "env:CHUTES_API_KEY" };
}

const candidates = getOpencodeConfigCandidatePaths();
for (const candidate of candidates) {
const result = await readOpencodeConfig(candidate.path, candidate.isJsonc);
if (!result) continue;

const key = extractChutesKeyFromConfig(result.config);
if (key) {
return {
key,
source: result.isJsonc ? "opencode.jsonc" : "opencode.json",
};
}
}

const auth = await readAuthFile();
const chutes = auth?.chutes;
if (chutes && chutes.type === "api" && chutes.key && chutes.key.trim().length > 0) {
return { key: chutes.key.trim(), source: "auth.json" };
}

return null;
}

/**
* Check if a Chutes API key is configured
*/
export async function hasChutesApiKey(): Promise<boolean> {
const result = await resolveChutesApiKey();
return result !== null;
}

/**
* Get diagnostic info about Chutes API key configuration
*/
export async function getChutesKeyDiagnostics(): Promise<{
configured: boolean;
source: ChutesKeySource | null;
checkedPaths: string[];
}> {
const checkedPaths: string[] = [];

if (process.env.CHUTES_API_KEY !== undefined) {
checkedPaths.push("env:CHUTES_API_KEY");
}

const candidates = getOpencodeConfigCandidatePaths();
for (const candidate of candidates) {
if (existsSync(candidate.path)) {
checkedPaths.push(candidate.path);
}
}

const result = await resolveChutesApiKey();

return {
configured: result !== null,
source: result?.source ?? null,
checkedPaths,
};
}
111 changes: 111 additions & 0 deletions src/lib/chutes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* Chutes AI quota fetcher
*
* Resolves API key from multiple sources and queries:
* https://api.chutes.ai/users/me/quota_usage/me
*/

import type { ChutesResult } from "./types.js";
import { REQUEST_TIMEOUT_MS } from "./types.js";
import {
resolveChutesApiKey,
hasChutesApiKey,
getChutesKeyDiagnostics,
type ChutesKeySource,
} from "./chutes-config.js";

interface ChutesQuotaResponse {
quota: number;
used: number;
}

function clampPercent(n: number): number {
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, Math.round(n)));
}

function getNextDailyResetUtc(): string {
const now = new Date();
const reset = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1, 0, 0, 0, 0),
);
return reset.toISOString();
}

async function fetchWithTimeout(url: string, options: RequestInit): Promise<Response> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);

try {
return await fetch(url, { ...options, signal: controller.signal });
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw new Error(`Request timeout after ${Math.round(REQUEST_TIMEOUT_MS / 1000)}s`);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}

type ChutesApiAuth = {
type: "api";
key: string;
source: ChutesKeySource;
};

async function readChutesAuth(): Promise<ChutesApiAuth | null> {
const result = await resolveChutesApiKey();
if (!result) return null;
return { type: "api", key: result.key, source: result.source };
}

const CHUTES_QUOTA_URL = "https://api.chutes.ai/users/me/quota_usage/me";

export async function hasChutesApiKeyConfigured(): Promise<boolean> {
return await hasChutesApiKey();
}

export { getChutesKeyDiagnostics, type ChutesKeySource } from "./chutes-config.js";

export async function queryChutesQuota(): Promise<ChutesResult> {
const auth = await readChutesAuth();
if (!auth) return null;

try {
const resp = await fetchWithTimeout(CHUTES_QUOTA_URL, {
method: "GET",
headers: {
Authorization: `Bearer ${auth.key}`,
"User-Agent": "OpenCode-Quota-Toast/1.0",
},
});

if (!resp.ok) {
const text = await resp.text();
return {
success: false,
error: `Chutes API error ${resp.status}: ${text.slice(0, 120)}`,
};
}

const data = (await resp.json()) as ChutesQuotaResponse;

// Chutes returns used and quota.
const used = typeof data.used === "number" ? data.used : 0;
const quota = typeof data.quota === "number" ? data.quota : 0;

const percentRemaining = quota > 0 ? clampPercent(((quota - used) / quota) * 100) : 0;

return {
success: true,
percentRemaining,
resetTimeIso: getNextDailyResetUtc(),
};
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err),
};
}
}
6 changes: 3 additions & 3 deletions src/lib/firmware-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,13 +136,13 @@ export function getOpencodeConfigCandidatePaths(): Array<{ path: string; isJsonc
const cwd = process.cwd();
const configBaseDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");

// Order: global config first, then local overrides
// Order: local overrides first, then global fallback
// Check both .json and .jsonc variants
return [
{ path: join(configBaseDir, "opencode", "opencode.jsonc"), isJsonc: true },
{ path: join(configBaseDir, "opencode", "opencode.json"), isJsonc: false },
{ path: join(cwd, "opencode.jsonc"), isJsonc: true },
{ path: join(cwd, "opencode.json"), isJsonc: false },
{ path: join(configBaseDir, "opencode", "opencode.jsonc"), isJsonc: true },
{ path: join(configBaseDir, "opencode", "opencode.json"), isJsonc: false },
];
}

Expand Down
Loading