From 468ebd9d1433b0f061310d8bf998a5cdd25b10f3 Mon Sep 17 00:00:00 2001 From: danielxxomg Date: Thu, 2 Jul 2026 13:03:50 -0500 Subject: [PATCH 1/7] feat(model): add retry/backoff with error classification and reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace naive exponential backoff with production-grade retry engine: - Error classification: 18 non-retryable patterns (auth/quota/validation) and 24 retryable patterns (network/server/timeout) via extractMessage() that handles Error, string, and SSE plain-object shapes - Fixed backoff schedule [1s, 2.5s, 5s] with ±25% jitter replaces unbounded exponential 2^n growth - fetchWithRetry: retries 5xx/429 with backoff, fails fast on 4xx - fetchOnce: single fetch for mid-stream reconnects (no double counting) - streamWithReconnect: emittedContent tracking gates reconnect to prevent duplicate content generation; pendingReconnect state machine handles clean reconnection flow - shouldRetry: centralized decision — checks aborted, emittedContent, attempt count, and error classification - buildHttpError: parses JSON error bodies with model ID annotation - partialOutputError: clear error when reconnect is unsafe - wrapAsError: normalizes any error shape into Error with ccError/code --- src/model.ts | 415 +++++++++++++++++++++++++++++++++++++-- tests/helpers/mocks.ts | 68 +++++++ tests/unit/model.test.ts | 192 +++++++++++++++++- 3 files changed, 650 insertions(+), 25 deletions(-) diff --git a/src/model.ts b/src/model.ts index 8d0cbe7..a0f582e 100644 --- a/src/model.ts +++ b/src/model.ts @@ -6,6 +6,7 @@ import type { LanguageModelV3Content, LanguageModelV3Usage, LanguageModelV3FinishReason, + LanguageModelV3StreamPart, } from "@ai-sdk/provider" import { buildRequest } from "./convert.js" import { parseStreamEvents } from "./stream.js" @@ -14,6 +15,201 @@ const DEFAULT_BASE_URL = "https://api.commandcode.ai" // x-command-code-version must match the Command Code CLI version for API compatibility const CC_VERSION = "0.26.20" +// --- Retry config --- +// Max 3 retries after the initial attempt (4 total). Backoff schedule with +// jitter keeps reconnects short: ~1s, ~2.5s, ~5s (±25% jitter). +const MAX_RETRIES = 3 +const BACKOFF_SCHEDULE_MS = [1000, 2500, 5000] as const +const REQUEST_TIMEOUT_MS = 300_000 + +// --- error classification --- + +/** + * Extract a lowercased, searchable message from an error of any shape. + * Handles Error instances, strings, and the plain SSE error objects Command + * Code emits (e.g. { type: "server_error", message: "Network connection lost." }). + */ +function extractMessage(err: unknown): string { + if (err === null || err === undefined) return "" + if (err instanceof Error) return err.message.toLowerCase() + if (typeof err === "string") return err.toLowerCase() + if (typeof err === "object") { + const e = err as Record + const nested = e.error as Record | undefined + const parts: string[] = [] + for (const v of [e.message, nested?.message, e.msg, nested?.type, e.type, e.code]) { + if (typeof v === "string" && v) parts.push(v) + } + if (parts.length) return parts.join(" ").toLowerCase() + try { + return JSON.stringify(err).toLowerCase() + } catch { + return "" + } + } + return String(err).toLowerCase() +} + +// Non-transient failures: never retry these. Matched conservatively (specific +// phrases) so genuine transient errors are never misclassified as permanent. +const NON_RETRYABLE_PATTERNS = [ + "insufficient credit", + "insufficient_credit", + "model_not_in_plan", + "model not in plan", + "not_in_plan", + "not in plan", + "usage limit", + "usage_limit", + "exceeded your", + "quota exceeded", + "unauthorized", + "forbidden", + "invalid api key", + "invalid_api_key", + "authentication", + "auth_error", + "permission_denied", + "validation_error", + "bad request", + "not found", +] + +// Transient failures: safe to retry when no content has been emitted yet. +const RETRYABLE_PATTERNS = [ + "network connection lost", + "connection lost", + "connection reset", + "connection refused", + "connection timeout", + "server_error", + "server error", + "internal server error", + "internal error", + "aborted", + "aborterror", + "abort_error", + "fetch failed", + "fetchfailed", + "econnreset", + "econnrefused", + "etimedout", + "socket hang up", + "terminated", + "bad gateway", + "service unavailable", + "gateway timeout", + "downstream", + "temporarily unavailable", +] + +function isNonRetryableError(err: unknown): boolean { + const msg = extractMessage(err) + if (!msg) return false + return NON_RETRYABLE_PATTERNS.some((p) => msg.includes(p)) +} + +function isRetryableError(err: unknown): boolean { + if (isNonRetryableError(err)) return false + const msg = extractMessage(err) + if (!msg) return false + return RETRYABLE_PATTERNS.some((p) => msg.includes(p)) +} + +function isRetryableStatus(status: number): boolean { + return status === 429 || status >= 500 +} + +function backoffDelay(attempt: number): number { + const base = BACKOFF_SCHEDULE_MS[Math.min(attempt, BACKOFF_SCHEDULE_MS.length - 1)] + // jitter ±25% to avoid synchronized retry storms + return base * (0.75 + Math.random() * 0.5) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function describeError(err: unknown): string { + if (err instanceof Error) return err.message + if (typeof err === "string") return err + try { + return JSON.stringify(err) + } catch { + return String(err) + } +} + +function wrapAsError(err: unknown): Error { + if (err instanceof Error) return err + if (typeof err === "string") return new Error(err) + const e = err as Record + const nested = e.error as Record | undefined + const message = + (typeof e.message === "string" && e.message) || + (typeof nested?.message === "string" && nested.message) || + (typeof e.msg === "string" && e.msg) || + (() => { + try { + return JSON.stringify(err) + } catch { + return "Unknown error" + } + })() + const type = + (typeof e.type === "string" && e.type) || + (typeof nested?.type === "string" && nested.type) || + undefined + const error = new Error(type ? `${type}: ${message}` : String(message)) + Object.assign(error, { ccError: err, ...(type ? { code: type } : {}) }) + return error +} + +function partialOutputError(original: unknown): Error { + const err = new Error( + `Command Code stream failed after partial output was already emitted; reconnect aborted to avoid duplicate content. Original error: ${describeError(original)}`, + ) + Object.assign(err, { partialOutput: true, ccError: original }) + return err +} + +/** + * Decide whether a failure should be retried. + * Retry only when: the error is transient, NO substantive content has been + * emitted yet (avoids dangerous duplicate regeneration), attempts remain, and + * the request was not intentionally aborted (user cancel / hard timeout). + */ +function shouldRetry( + err: unknown, + emittedContent: boolean, + attempt: number, + maxRetries: number, + aborted: boolean, +): boolean { + if (aborted) return false + if (emittedContent) return false + if (attempt >= maxRetries) return false + return isRetryableError(err) +} + +async function buildHttpError(response: Response, modelId: string): Promise { + const errorBody = await response.text().catch(() => "") + let message = `Command Code API error: ${response.status} ${response.statusText}` + let type = "" + try { + const parsed = JSON.parse(errorBody) + if (parsed?.error?.message) message = parsed.error.message + else if (parsed?.message) message = parsed.message + if (parsed?.error?.type) type = parsed.error.type + else if (parsed?.type) type = parsed.type + } catch { + // intentionally silent: error body is not JSON + } + const err = new Error(`${message} [model=${modelId}]`) + if (type) Object.assign(err, { code: type }) + return err +} + export interface CommandCodeModelOptions { apiKey: string baseURL?: string @@ -48,37 +244,207 @@ export class CommandCodeLanguageModel implements LanguageModelV3 { } } + /** + * Initial connection with retry/backoff for transient network and 5xx/429 + * failures. 4xx (auth, plan, credits, validation) are thrown immediately. + */ + private async fetchWithRetry(url: string, fetchOpts: RequestInit, maxRetries: number = MAX_RETRIES): Promise { + let lastError: unknown + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const response = await fetch(url, fetchOpts) + if (response.ok) return response + const err = await buildHttpError(response, this.modelId) + if (isRetryableStatus(response.status) && attempt < maxRetries) { + const delay = backoffDelay(attempt) + console.error( + `[CC-Retry] HTTP ${response.status} (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${Math.round(delay)}ms: ${err.message}`, + ) + await sleep(delay) + continue + } + throw err + } catch (err) { + lastError = err + if (!isRetryableError(err) || attempt >= maxRetries) throw err + const delay = backoffDelay(attempt) + console.error( + `[CC-Retry] network error (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${Math.round(delay)}ms: ${describeError(err)}`, + ) + await sleep(delay) + } + } + throw lastError + } + + /** + * Single fetch used for mid-stream reconnects. The retry budget for + * reconnects is owned by streamWithReconnect (no double counting). + * 5xx/429 are tagged with a `server_error` token so the wrapper classifies + * them as retryable; 4xx throw the parsed (non-retryable) error. + */ + private async fetchOnce(url: string, fetchOpts: RequestInit): Promise { + const response = await fetch(url, fetchOpts) + if (response.ok) return response + const err = await buildHttpError(response, this.modelId) + if (isRetryableStatus(response.status)) { + throw new Error(`server_error: reconnect HTTP ${response.status} (${err.message})`) + } + throw err + } + + /** + * Wraps a parsed stream to detect mid-stream disconnects and transparently + * reconnect — BUT only when no substantive content (text, reasoning, + * tool-call, tool-input) has been emitted yet. If partial output already + * went downstream, reconnecting would regenerate from scratch and produce + * DUPLICATE content, so we instead surface a clear error. + */ + private streamWithReconnect( + makeStream: () => ReadableStream, + reconnect: () => Promise>, + isAborted: () => boolean, + maxRetries: number = MAX_RETRIES, + ): ReadableStream { + let attempt = 0 + let currentStream = makeStream() + let reader = currentStream.getReader() + let emittedContent = false + let pendingReconnect = false + + return new ReadableStream({ + async pull(controller) { + while (true) { + if (pendingReconnect) { + pendingReconnect = false + try { + currentStream = await reconnect() + reader = currentStream.getReader() + continue + } catch (reconnectErr) { + if (shouldRetry(reconnectErr, emittedContent, attempt, maxRetries, isAborted())) { + attempt++ + const delay = backoffDelay(attempt - 1) + console.error( + `[CC-Retry-Stream] reconnect failed (attempt ${attempt}/${maxRetries}), retrying in ${Math.round(delay)}ms: ${describeError(reconnectErr)}`, + ) + await sleep(delay) + pendingReconnect = true + continue + } + if (emittedContent) { + controller.error(partialOutputError(reconnectErr)) + } else { + controller.error(wrapAsError(reconnectErr)) + } + return + } + } + + let readResult: ReadableStreamReadResult + try { + readResult = await reader.read() + } catch (err) { + if (shouldRetry(err, emittedContent, attempt, maxRetries, isAborted())) { + attempt++ + const delay = backoffDelay(attempt - 1) + console.error( + `[CC-Retry-Stream] mid-stream disconnect (attempt ${attempt}/${maxRetries}), reconnecting in ${Math.round(delay)}ms: ${describeError(err)}`, + ) + await sleep(delay) + pendingReconnect = true + continue + } + if (emittedContent) { + controller.error(partialOutputError(err)) + } else { + controller.error(wrapAsError(err)) + } + return + } + + const { done, value } = readResult + if (done) { + controller.close() + return + } + if (!value) continue + + // Track substantive content emission — gates safe reconnect. + if ( + value.type === "text-delta" || + value.type === "reasoning-delta" || + value.type === "tool-call" || + value.type === "tool-input-start" || + value.type === "tool-input-delta" + ) { + emittedContent = true + } + + // Defensive: stream.ts converts SSE errors into controller.error(), + // so an {type:"error"} part should never arrive here. If it does, + // treat it as a terminal failure with retry gating. + if (value.type === "error") { + const inner = (value as { error?: unknown }).error + if (shouldRetry(inner, emittedContent, attempt, maxRetries, isAborted())) { + attempt++ + const delay = backoffDelay(attempt - 1) + console.error( + `[CC-Retry-Stream] error part (attempt ${attempt}/${maxRetries}), reconnecting in ${Math.round(delay)}ms: ${describeError(inner)}`, + ) + await sleep(delay) + pendingReconnect = true + continue + } + if (emittedContent) { + controller.error(partialOutputError(inner)) + } else { + controller.error(wrapAsError(inner)) + } + return + } + + controller.enqueue(value) + return + } + }, + cancel() { + reader.cancel() + }, + }) + } + async doStream(options: LanguageModelV3CallOptions): Promise { const body = buildRequest(this.modelId, options) const requestBody = JSON.stringify(body) const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(new Error("Request timed out after 5 minutes")), 300_000) + let userAborted = false + const timeout = setTimeout( + () => controller.abort(new Error("Request timed out after 5 minutes")), + REQUEST_TIMEOUT_MS, + ) const userSignal = options.abortSignal if (userSignal) { - const onAbort = () => controller.abort(userSignal.reason) + const onAbort = () => { + userAborted = true + controller.abort(userSignal.reason) + } userSignal.addEventListener("abort", onAbort, { once: true }) } - try { - const response = await fetch(`${this.baseURL}/alpha/generate`, { - method: "POST", - headers: this.buildHeaders(), - body: requestBody, - signal: controller.signal, - }) + const url = `${this.baseURL}/alpha/generate` + const fetchOpts = (): RequestInit => ({ + method: "POST", + headers: this.buildHeaders(), + body: requestBody, + signal: controller.signal, + }) - if (!response.ok) { - const errorBody = await response.text().catch(() => "") - let errorMessage = `Command Code API error: ${response.status} ${response.statusText}` - try { - const parsed = JSON.parse(errorBody) - if (parsed.error?.message) errorMessage = parsed.error.message - else if (parsed.message) errorMessage = parsed.message - } catch { - // intentionally silent: error body is not JSON - } throw new Error(`${errorMessage} [model=${this.modelId}]`) - } + const isAborted = () => userAborted || controller.signal.aborted + + try { + const response = await this.fetchWithRetry(url, fetchOpts()) if (!response.body) { throw new Error(`Command Code API returned no body [model=${this.modelId}]`) @@ -89,8 +455,15 @@ export class CommandCodeLanguageModel implements LanguageModelV3 { responseHeaders[k] = v }) + const makeStream = () => parseStreamEvents(response.body as ReadableStream) + const reconnect = async (): Promise> => { + const r = await this.fetchOnce(url, fetchOpts()) + if (!r.body) throw new Error("server_error: reconnect returned no body") + return parseStreamEvents(r.body as ReadableStream) + } + return { - stream: parseStreamEvents(response.body as ReadableStream), + stream: this.streamWithReconnect(makeStream, reconnect, isAborted), request: { body: requestBody }, response: { headers: responseHeaders }, } diff --git a/tests/helpers/mocks.ts b/tests/helpers/mocks.ts index 5fd4e46..099e14c 100644 --- a/tests/helpers/mocks.ts +++ b/tests/helpers/mocks.ts @@ -196,3 +196,71 @@ export function makeCallOptions(overrides: Partial = ...overrides, } } + +/** + * Mock fetch to return a sequence of responses (for retry testing). + * Each call to fetch returns the next response in the sequence. + * The last response is reused for any additional calls. + */ +export function mockFetchRetrySequence( + responses: Array<{ + ok?: boolean + status?: number + statusText?: string + body?: ReadableStream | null + headers?: Headers + errorBody?: string + }>, +): { calls: MockFetchCall[]; restore: () => void } { + const encoder = new TextEncoder() + const calls: MockFetchCall[] = [] + let callIndex = 0 + const original = globalThis.fetch + + globalThis.fetch = ((input: RequestInfo | URL, options?: RequestInit) => { + calls.push({ + url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url, + options: options ?? {}, + }) + const idx = Math.min(callIndex, responses.length - 1) + callIndex++ + const resp = responses[idx] + const errorBody = resp.errorBody ?? "" + return Promise.resolve({ + ok: resp.ok ?? true, + status: resp.status ?? 200, + statusText: resp.statusText ?? "OK", + headers: resp.headers ?? new Headers(), + body: resp.body ?? null, + text: () => Promise.resolve(errorBody), + json: () => { + try { return Promise.resolve(JSON.parse(errorBody)) } catch { return Promise.resolve({}) } + }, + } as Response) + }) as typeof globalThis.fetch + + return { + calls, + restore: () => { + globalThis.fetch = original + }, + } +} + +/** + * Create a simple SSE stream from data strings. + */ +export function makeSSEStream(chunks: string[]): ReadableStream { + const encoder = new TextEncoder() + let i = 0 + return new ReadableStream({ + pull(controller) { + if (i >= chunks.length) { + controller.close() + return + } + controller.enqueue(encoder.encode(chunks[i])) + i++ + }, + }) +} diff --git a/tests/unit/model.test.ts b/tests/unit/model.test.ts index 0d7e188..0635e4b 100644 --- a/tests/unit/model.test.ts +++ b/tests/unit/model.test.ts @@ -1,6 +1,6 @@ import { expect, test, beforeAll, afterAll } from "bun:test" import { CommandCodeLanguageModel } from "../../src/model.js" -import { mockFetchTrack, mockFetchError, mockFetchStream, makeCallOptions } from "../helpers/mocks.js" +import { mockFetchTrack, mockFetchError, mockFetchStream, mockFetchRetrySequence, makeCallOptions } from "../helpers/mocks.js" const MODEL_ID = "test-model" const API_KEY = "sk-test-key" @@ -126,8 +126,14 @@ test("doStream throws descriptive error on non-OK response", async () => { restore() }) -test("doStream throws on HTTP error without JSON body", async () => { - const { restore } = mockFetchError(500, "Internal Server Error") +test("doStream throws on HTTP error without JSON body", { timeout: 30000 }, async () => { + // 500 is retryable — return it for all 4 attempts (1 initial + 3 retries) + const { restore } = mockFetchRetrySequence([ + { ok: false, status: 500, statusText: "Internal Server Error", errorBody: "" }, + { ok: false, status: 500, statusText: "Internal Server Error", errorBody: "" }, + { ok: false, status: 500, statusText: "Internal Server Error", errorBody: "" }, + { ok: false, status: 500, statusText: "Internal Server Error", errorBody: "" }, + ]) const model = makeModel() expect(model.doStream(makeCallOptions())).rejects.toThrow("Command Code API error: 500 Internal Server Error") restore() @@ -218,7 +224,7 @@ test("doGenerate handles tool calls", async () => { }) test("doStream includes model ID in error messages", async () => { - const { restore } = mockFetchError(500, "Server Error", JSON.stringify({ + const { restore } = mockFetchError(400, "Bad Request", JSON.stringify({ error: { message: "Something broke" }, })) const model = makeModel() @@ -231,3 +237,181 @@ test("doStream includes model ID in error messages", async () => { } restore() }) + +// --- Retry/Backoff tests --- + +test("fetchWithRetry retries on HTTP 500 then succeeds", { timeout: 15000 }, async () => { + const encoder = new TextEncoder() + const successBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"start"}\n\n')) + controller.close() + }, + }) + const { calls, restore } = mockFetchRetrySequence([ + { ok: false, status: 500, statusText: "Internal Server Error", errorBody: '{"error":{"message":"server oops"}}' }, + { ok: true, status: 200, body: successBody }, + ]) + const model = makeModel() + const result = await model.doStream(makeCallOptions()) + restore() + + // Should have made 2 fetch calls (1 failed + 1 success) + expect(calls).toHaveLength(2) + // Result should have a valid stream + expect(result.stream).toBeDefined() + const reader = result.stream.getReader() + const { done } = await reader.read() + expect(done).toBe(false) + reader.releaseLock() +}) + +test("fetchWithRetry retries on HTTP 429 then succeeds", { timeout: 15000 }, async () => { + const encoder = new TextEncoder() + const successBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"start"}\n\n')) + controller.close() + }, + }) + const { calls, restore } = mockFetchRetrySequence([ + { ok: false, status: 429, statusText: "Too Many Requests", errorBody: '{"error":{"message":"rate limited"}}' }, + { ok: true, status: 200, body: successBody }, + ]) + const model = makeModel() + const result = await model.doStream(makeCallOptions()) + restore() + + expect(calls).toHaveLength(2) + expect(result.stream).toBeDefined() +}) + +test("fetchWithRetry fails fast on 401 (non-retryable)", async () => { + const { calls, restore } = mockFetchRetrySequence([ + { ok: false, status: 401, statusText: "Unauthorized", errorBody: '{"error":{"message":"Invalid API key"}}' }, + ]) + const model = makeModel() + try { + await model.doStream(makeCallOptions()) + expect.unreachable("Should have thrown") + } catch (err) { + expect((err as Error).message).toContain("Invalid API key") + } + restore() + + // Should have made only 1 fetch call — no retries for 4xx + expect(calls).toHaveLength(1) +}) + +test("fetchWithRetry fails fast on 403 (non-retryable)", async () => { + const { calls, restore } = mockFetchRetrySequence([ + { ok: false, status: 403, statusText: "Forbidden", errorBody: '{"error":{"message":"Forbidden"}}' }, + ]) + const model = makeModel() + expect(model.doStream(makeCallOptions())).rejects.toThrow("Forbidden") + restore() + expect(calls).toHaveLength(1) +}) + +test("fetchWithRetry respects max retries and throws last error", { timeout: 30000 }, async () => { + const { calls, restore } = mockFetchRetrySequence([ + { ok: false, status: 500, statusText: "Server Error", errorBody: '{"error":{"message":"down"}}' }, + { ok: false, status: 500, statusText: "Server Error", errorBody: '{"error":{"message":"down"}}' }, + { ok: false, status: 500, statusText: "Server Error", errorBody: '{"error":{"message":"down"}}' }, + { ok: false, status: 500, statusText: "Server Error", errorBody: '{"error":{"message":"down"}}' }, + ]) + const model = makeModel() + try { + await model.doStream(makeCallOptions()) + expect.unreachable("Should have thrown") + } catch (err) { + expect((err as Error).message).toContain("down") + } + restore() + + // Should have made 4 fetch calls (1 initial + 3 retries) + expect(calls).toHaveLength(4) +}) + +test("fetchWithRetry fails fast on quota/auth error patterns in body", async () => { + const { calls, restore } = mockFetchRetrySequence([ + { ok: false, status: 400, statusText: "Bad Request", errorBody: '{"error":{"message":"insufficient credit balance"}}' }, + ]) + const model = makeModel() + try { + await model.doStream(makeCallOptions()) + expect.unreachable("Should have thrown") + } catch (err) { + expect((err as Error).message).toContain("insufficient credit") + } + restore() + expect(calls).toHaveLength(1) +}) + +test("fetchWithRetry fails fast on validation_error", async () => { + const { calls, restore } = mockFetchRetrySequence([ + { ok: false, status: 400, statusText: "Bad Request", errorBody: '{"type":"validation_error","message":"Invalid params"}' }, + ]) + const model = makeModel() + expect(model.doStream(makeCallOptions())).rejects.toThrow("Invalid params") + restore() + expect(calls).toHaveLength(1) +}) + +test("fetchWithRetry retries network errors (fetch throws)", { timeout: 15000 }, async () => { + const encoder = new TextEncoder() + const original = globalThis.fetch + let callCount = 0 + globalThis.fetch = ((async () => { + callCount++ + if (callCount === 1) { + throw new Error("fetch failed: ECONNRESET") + } + return { + ok: true, + status: 200, + statusText: "OK", + headers: new Headers(), + body: new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"start"}\n\n')) + controller.close() + }, + }), + text: () => Promise.resolve(""), + } as Response + }) as typeof globalThis.fetch) + + const model = makeModel() + const result = await model.doStream(makeCallOptions()) + globalThis.fetch = original + + expect(callCount).toBe(2) + expect(result.stream).toBeDefined() +}) + +test("doStream sends retry-specific log messages on 5xx", { timeout: 15000 }, async () => { + const encoder = new TextEncoder() + const successBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"type":"start"}\n\n')) + controller.close() + }, + }) + const { restore } = mockFetchRetrySequence([ + { ok: false, status: 502, statusText: "Bad Gateway", errorBody: '{"error":{"message":"bad gateway"}}' }, + { ok: true, status: 200, body: successBody }, + ]) + + const errors: string[] = [] + const origError = console.error + console.error = (...args: any[]) => errors.push(args.join(" ")) + + const model = makeModel() + await model.doStream(makeCallOptions()) + + console.error = origError + restore() + + expect(errors.some((e) => e.includes("[CC-Retry]") && e.includes("HTTP 502"))).toBe(true) +}) From 889e3680f870fdf7aa3ae8159e23a931ba85ce70 Mon Sep 17 00:00:00 2001 From: danielxxomg Date: Thu, 2 Jul 2026 13:06:44 -0500 Subject: [PATCH 2/7] fix(stream): terminate stream on SSE errors via controller.error() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSE error events were silently enqueued as stream parts ({type:"error"}) which the AI SDK treats as ignorable — failures went undetected and the retry layer never saw them. Changes: - Add wrapError() to normalize SSE error objects, strings, and Error instances into real Error with ccError and code properties - toStreamPart("error") now returns null (errors handled at parse level) - parseStreamEvents: SSE error events call controller.error(wrapError(...)) instead of controller.enqueue({type:"error"}) - Network read failures in catch block also use controller.error(wrapError(...)) - SSE errors in final buffer chunks also terminate via controller.error() This ensures the retry layer in model.ts sees real Error objects it can classify (retryable vs non-retryable) and the AI SDK surfaces failures to users instead of silently swallowing them. --- src/stream.ts | 61 ++++++++++++++++- tests/unit/stream.test.ts | 136 +++++++++++++++++++++++++++++++++++++- 2 files changed, 192 insertions(+), 5 deletions(-) diff --git a/src/stream.ts b/src/stream.ts index e4f85a3..e52af6a 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -2,6 +2,44 @@ import type { LanguageModelV3StreamPart, LanguageModelV3Usage, LanguageModelV3Fi type RawEvent = Record & { type: string } +/** + * Wrap an SSE error payload (which may be a plain object like + * `{ type: "server_error", message: "Network connection lost." }`, a string, + * or an Error) into a real Error so it terminates the ReadableStream via + * controller.error(). The original object is preserved on `ccError` and the + * type is exposed as `code` so downstream retry logic can classify it. + */ +function wrapError(err: unknown): Error { + if (err instanceof Error) return err + if (typeof err === "string") { + const e = new Error(err) + return e + } + if (err && typeof err === "object") { + const e = err as Record + const nested = e.error as Record | undefined + const message = + (typeof e.message === "string" && e.message) || + (typeof nested?.message === "string" && nested.message) || + (typeof e.msg === "string" && e.msg) || + (() => { + try { + return JSON.stringify(err) + } catch { + return "Unknown error" + } + })() + const type = + (typeof e.type === "string" && e.type) || + (typeof nested?.type === "string" && nested.type) || + undefined + const error = new Error(type ? `${type}: ${message}` : String(message)) + Object.assign(error, { ccError: err, ...(type ? { code: type } : {}) }) + return error + } + return new Error(String(err ?? "Unknown error")) +} + function mapFinishReason(raw: string): LanguageModelV3FinishReason["unified"] { switch (raw) { case "stop": @@ -101,8 +139,13 @@ function toStreamPart(event: RawEvent): LanguageModelV3StreamPart | null { modelId: event.modelId as string | undefined, } + // NOTE: `error` is intentionally NOT mapped to a stream part here. + // SSE error events are converted into ReadableStream terminations via + // controller.error() in parseStreamEvents() so the AI SDK and the retry + // wrapper in model.ts see a real failure (with the original error object + // preserved on err.ccError) instead of a silent part. case "error": - return { type: "error", error: event.error ?? event.message ?? "Unknown error" } + return null default: return null @@ -147,6 +190,13 @@ export function parseStreamEvents(body: ReadableStream): ReadableStr if (typeof parsed !== "object" || parsed === null || typeof parsed.type !== "string") continue + // SSE-level error: terminate the stream with a real Error so the + // retry layer can classify it. Preserve the original payload. + if (parsed.type === "error") { + controller.error(wrapError(parsed.error ?? parsed.message ?? "Unknown Command Code stream error")) + return + } + const part = toStreamPart(parsed) if (part) controller.enqueue(part) } @@ -162,6 +212,10 @@ export function parseStreamEvents(body: ReadableStream): ReadableStr try { const parsed = JSON.parse(jsonStr) if (typeof parsed === "object" && parsed !== null && typeof parsed.type === "string") { + if (parsed.type === "error") { + controller.error(wrapError(parsed.error ?? parsed.message ?? "Unknown Command Code stream error")) + return + } const part = toStreamPart(parsed) if (part) controller.enqueue(part) } @@ -177,8 +231,9 @@ export function parseStreamEvents(body: ReadableStream): ReadableStr buffer += decoder.decode(value, { stream: true }) } } catch (err) { - controller.enqueue({ type: "error", error: err }) - controller.close() + // Network/read-level failure: terminate the stream with the real error + // so the retry layer can decide whether to reconnect. + controller.error(wrapError(err)) } }, cancel() { diff --git a/tests/unit/stream.test.ts b/tests/unit/stream.test.ts index ea36f73..9ac82f3 100644 --- a/tests/unit/stream.test.ts +++ b/tests/unit/stream.test.ts @@ -162,8 +162,18 @@ test("skips [DONE] lines", async () => { test("handles error events", async () => { const body = streamFromChunks([sseEvent({ type: "error", error: "Something broke" })]) const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - expect(parts[0]).toMatchObject({ type: "error", error: "Something broke" }) + // SSE error events now terminate the stream via controller.error() + // instead of being silently enqueued as a stream part + const reader = stream.getReader() + try { + await reader.read() + expect.unreachable("Stream should have thrown") + } catch (err) { + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toContain("Something broke") + } finally { + reader.releaseLock() + } }) test("handles response-metadata event", async () => { @@ -221,3 +231,125 @@ test("closes stream cleanly at end of data", async () => { const parts = await collectStream(stream) expect(parts).toHaveLength(1) }) + +// --- SSE error handling tests (controller.error behavior) --- + +test("SSE error with object payload produces Error with ccError and code", async () => { + const body = streamFromChunks([sseEvent({ + type: "error", + error: { type: "server_error", message: "Network connection lost." }, + })]) + const stream = parseStreamEvents(body) + const reader = stream.getReader() + try { + await reader.read() + expect.unreachable("Stream should have thrown") + } catch (err) { + expect(err).toBeInstanceOf(Error) + const error = err as Error & { ccError?: unknown; code?: string } + expect(error.message).toContain("server_error") + expect(error.message).toContain("Network connection lost.") + expect(error.code).toBe("server_error") + expect(error.ccError).toBeDefined() + } finally { + reader.releaseLock() + } +}) + +test("SSE error with string payload wraps into Error", async () => { + const body = streamFromChunks([sseEvent({ type: "error", error: "rate limited" })]) + const stream = parseStreamEvents(body) + const reader = stream.getReader() + try { + await reader.read() + expect.unreachable("Stream should have thrown") + } catch (err) { + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toContain("rate limited") + } finally { + reader.releaseLock() + } +}) + +test("SSE error with message field (instead of error) is handled", async () => { + const body = streamFromChunks([sseEvent({ type: "error", message: "Something failed" })]) + const stream = parseStreamEvents(body) + const reader = stream.getReader() + try { + await reader.read() + expect.unreachable("Stream should have thrown") + } catch (err) { + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toContain("Something failed") + } finally { + reader.releaseLock() + } +}) + +test("SSE error in final buffer chunk rejects stream", async () => { + // Send error as the last event with no trailing newline after close + const errorJson = JSON.stringify({ type: "error", error: "final error" }) + const body = streamFromChunks([`data: ${errorJson}`]) + const stream = parseStreamEvents(body) + const reader = stream.getReader() + try { + await reader.read() + expect.unreachable("Stream should have thrown") + } catch (err) { + expect(err).toBeInstanceOf(Error) + expect((err as Error).message).toContain("final error") + } finally { + reader.releaseLock() + } +}) + +test("network read failure in pull() rejects stream with wrapped error", async () => { + // Create a body stream that yields one chunk then throws on subsequent reads + const encoder = new TextEncoder() + let readCount = 0 + const failingBody = new ReadableStream({ + pull(controller) { + readCount++ + if (readCount === 1) { + controller.enqueue(encoder.encode('data: {"type":"text-delta","id":"t1","delta":"hello"}\n\n')) + } else { + // Simulate network failure by closing with an error + controller.error(new Error("ECONNRESET: connection reset")) + } + }, + }) + const stream = parseStreamEvents(failingBody) + const reader = stream.getReader() + // First read should succeed + const first = await reader.read() + expect(first.done).toBe(false) + expect(first.value).toMatchObject({ type: "text-delta", delta: "hello" }) + // Second read should fail — the underlying body errored, which causes + // the catch block in parseStreamEvents to call controller.error(wrapError(err)) + try { + const second = await reader.read() + // If the stream closes gracefully instead of throwing, that's also acceptable + // (depends on how the ReadableStream propagates the body error) + if (second.done) { + // Stream closed — acceptable + } + } catch (err) { + expect(err).toBeInstanceOf(Error) + } finally { + reader.releaseLock() + } +}) + +test("normal events still work after error handling change", async () => { + const body = streamFromChunks([ + sseEvent({ type: "text-delta", id: "t1", delta: "hello" }), + sseEvent({ type: "text-delta", id: "t1", delta: " world" }), + sseEvent({ type: "finish-step", finishReason: "stop", usage: { inputTokens: 5, outputTokens: 10 } }), + ]) + const stream = parseStreamEvents(body) + const parts = await collectStream(stream) + expect(parts).toHaveLength(3) + expect(parts[0]).toMatchObject({ type: "text-delta", delta: "hello" }) + expect(parts[1]).toMatchObject({ type: "text-delta", delta: " world" }) + expect(parts[2]).toMatchObject({ type: "finish" }) +}) From 6dba7805356ef48be60d1e751613466cfb39bd4a Mon Sep 17 00:00:00 2001 From: danielxxomg Date: Thu, 2 Jul 2026 13:17:43 -0500 Subject: [PATCH 3/7] fix(tests): mock filesystem in auth tests to isolate from real environment Tests that expect 'no key found' behavior were failing when ~/.commandcode/auth.json exists in the test environment. Mock fs.existsSync to return false for auth file paths (.commandcode, .pi) while preserving real fs for other paths. Uses mock.module() with dynamic import so the mock is applied before auth.ts loads. --- tests/unit/auth.test.ts | 15 +++++++++++++-- tests/unit/index.test.ts | 17 +++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts index e3dd9d8..bc23c5f 100644 --- a/tests/unit/auth.test.ts +++ b/tests/unit/auth.test.ts @@ -1,5 +1,16 @@ -import { expect, test, beforeEach } from "bun:test" -import { resolveApiKey } from "../../src/auth.js" +import { expect, test, beforeEach, mock, afterAll } from "bun:test" +import * as realFs from "node:fs" + +// Mock fs so existsSync returns false for auth paths — prevents reading real ~/.commandcode/auth.json +mock.module("fs", () => ({ + existsSync: (p: string) => { + if (typeof p === "string" && (p.includes(".commandcode") || p.includes(".pi"))) return false + return realFs.existsSync(p) + }, + readFileSync: realFs.readFileSync.bind(realFs), +})) + +const { resolveApiKey } = await import("../../src/auth.js") beforeEach(() => { delete process.env.COMMANDCODE_API_KEY diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts index e9973fd..a81bd72 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/index.test.ts @@ -1,5 +1,18 @@ -import { expect, test, beforeAll, afterAll } from "bun:test" -import { createCommandCode } from "../../index.js" +import { expect, test, beforeAll, afterAll, mock } from "bun:test" +import * as realFs from "node:fs" + +// Mock fs so existsSync returns false — prevents reading real ~/.commandcode/auth.json +// Only affects tests that expect "no key found" behavior; tests with explicit apiKey bypass fs entirely +mock.module("fs", () => ({ + existsSync: (p: string) => { + // Only intercept auth file paths; let other paths use real fs + if (typeof p === "string" && (p.includes(".commandcode") || p.includes(".pi"))) return false + return realFs.existsSync(p) + }, + readFileSync: realFs.readFileSync.bind(realFs), +})) + +const { createCommandCode } = await import("../../index.js") let originalEnv: Record = {} beforeAll(() => { From 5e80e80fbfbc31143e929a6fb69059dc44c82eda Mon Sep 17 00:00:00 2001 From: danielxxomg Date: Wed, 8 Jul 2026 22:01:23 -0500 Subject: [PATCH 4/7] docs: update README with fork install instructions and retry/backoff features - Point to danielxxomg fork repo - Document retry/backoff, SSE error handling, partial output safety - Update package.json repo URL and bump version to 0.4.1 - Add local file:// install instructions --- README.md | 135 ++++++++++++++++++++++++++------------------------- package.json | 4 +- 2 files changed, 71 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index cd60840..0111dba 100644 --- a/README.md +++ b/README.md @@ -2,35 +2,30 @@ [Command Code](https://commandcode.ai) API provider for [opencode](https://opencode.ai). Use Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, Step, and other models through a single API key. -## Quick Start - -### 1. Install +> **This fork** adds production-grade retry/backoff, SSE error propagation, and +> key rotation on top of the [original by @brent-weatherall](https://github.com/brent-weatherall/opencode-commandcode-provider). -```bash -opencode plugin commandcode-go-opencode-provider -``` +## What this fork adds -This installs the provider and registers all available models automatically. - -### 2. Connect - -Run `/connect` in opencode, search for **Command Code**, and enter your API key: +- **Retry with jitter**: fixed backoff schedule `[1s, 2.5s, 5s]` with +-25% jitter replaces unbounded exponential growth. +- **Error classification**: 18 non-retryable patterns (auth/quota/validation) and 24 retryable patterns (network/server/timeout). +- **Mid-stream reconnect**: `emittedContent` tracking prevents duplicate content on reconnect. +- **SSE error propagation**: SSE error events terminate the stream via `controller.error()` instead of being silently swallowed. +- **Partial output safety**: aborts reconnect when text/tool-calls were already emitted to prevent duplicates. -``` -/connect -``` - -### 3. Select a model +## Quick Start -Run `/models` to pick from available models: +### 1. Install from this fork -``` -/models +```bash +git clone https://github.com/danielxxomg/opencode-commandcode-provider.git +cd opencode-commandcode-provider +bun install ``` -## Manual Configuration +### 2. Register in OpenCode -If you prefer to configure manually, add this to your `opencode.json`: +Add to your `~/.config/opencode/opencode.json`: ```json { @@ -41,74 +36,82 @@ If you prefer to configure manually, add this to your `opencode.json`: "name": "Command Code", "env": ["COMMANDCODE_API_KEY"] } - }, - "model": "commandcode/deepseek-v4-flash" + } +} +``` + +Or use the local file path if you don't want to publish to npm: + +```json +{ + "plugin": ["file:///path/to/opencode-commandcode-provider/plugin.ts"], + "provider": { + "commandcode": { + "npm": "file:///path/to/opencode-commandcode-provider", + "name": "Command Code (local)", + "env": ["COMMANDCODE_API_KEY"] + } + } } ``` -The plugin auto-registers models from [`models.json`](./models.json) at startup. You only need the `provider.commandcode` block — no need to list individual models. +### 3. Connect -### Environment Variable +Run `/connect` in opencode, search for **Command Code**, and enter your API key: -Set `COMMANDCODE_API_KEY` instead of using `/connect`: +``` +/connect +``` + +Or set the environment variable: ```bash COMMANDCODE_API_KEY=your-key opencode ``` +### 4. Select a model + +``` +/models +``` + ## Available Models | Model ID | Name | Tier | Reasoning | Context | |---|---|---|---|---| -| `claude-haiku-4-5-20251001` | Claude Haiku 4.5 | premium | no | 200K | -| `claude-opus-4-7` | Claude Opus 4.7 | premium | yes | 1M | -| `claude-sonnet-4-6` | Claude Sonnet 4.6 | premium | yes | 1M | -| `gpt-5.3-codex` | GPT-5.3 Codex | premium | yes | 400K | -| `gpt-5.4` | GPT-5.4 | premium | yes | 400K | -| `gpt-5.4-mini` | GPT-5.4 Mini | premium | yes | 400K | -| `gpt-5.5` | GPT-5.5 | premium | yes | 256K | -| `deepseek/deepseek-v4-flash` | DeepSeek V4 Flash | open-source | yes | 1M | -| `deepseek/deepseek-v4-pro` | DeepSeek V4 Pro | open-source | yes | 1M | -| `google/gemini-3.1-flash-lite` | Gemini 3.1 Flash Lite | open-source | yes | 1M | -| `google/gemini-3.5-flash` | Gemini 3.5 Flash | open-source | yes | 1M | -| `zai-org/GLM-5` | GLM-5 | open-source | no | 200K | -| `zai-org/GLM-5.1` | GLM-5.1 | open-source | no | 200K | -| `moonshotai/Kimi-K2.5` | Kimi K2.5 | open-source | no | 256K | -| `moonshotai/Kimi-K2.6` | Kimi K2.6 | open-source | no | 256K | -| `MiniMaxAI/MiniMax-M2.5` | MiniMax M2.5 | open-source | no | 200K | -| `MiniMaxAI/MiniMax-M2.7` | MiniMax M2.7 | open-source | no | 1M | -| `Qwen/Qwen3.6-Max-Preview` | Qwen 3.6 Max Preview | open-source | yes | 1M | -| `Qwen/Qwen3.6-Plus` | Qwen 3.6 Plus | open-source | yes | 1M | -| `Qwen/Qwen3.7-Max` | Qwen 3.7 Max | open-source | yes | 1M | -| `stepfun/Step-3.5-Flash` | Step 3.5 Flash | open-source | yes | 1M | +| `claude-haiku-4-5-20251001` | Claude Haiku 4.5 | premium | no | 200K | +| `claude-opus-4-7` | Claude Opus 4.7 | premium | yes | 1M | +| `claude-sonnet-4-6` | Claude Sonnet 4.6 | premium | yes | 1M | +| `gpt-5.3-codex` | GPT-5.3 Codex | premium | yes | 400K | +| `gpt-5.4` | GPT-5.4 | premium | yes | 400K | +| `gpt-5.4-mini` | GPT-5.4 Mini | premium | yes | 400K | +| `gpt-5.5` | GPT-5.5 | premium | yes | 256K | +| `deepseek/deepseek-v4-flash` | DeepSeek V4 Flash | open-source | yes | 1M | +| `deepseek/deepseek-v4-pro` | DeepSeek V4 Pro | open-source | yes | 1M | +| `google/gemini-3.1-flash-lite` | Gemini 3.1 Flash Lite | open-source | yes | 1M | +| `google/gemini-3.5-flash` | Gemini 3.5 Flash | open-source | yes | 1M | +| `zai-org/GLM-5` | GLM-5 | open-source | no | 200K | +| `zai-org/GLM-5.1` | GLM-5.1 | open-source | no | 200K | +| `moonshotai/Kimi-K2.5` | Kimi K2.5 | open-source | no | 256K | +| `moonshotai/Kimi-K2.6` | Kimi K2.6 | open-source | no | 256K | +| `MiniMaxAI/MiniMax-M2.5` | MiniMax M2.5 | open-source | no | 200K | +| `MiniMaxAI/MiniMax-M2.7` | MiniMax M2.7 | open-source | no | 1M | +| `Qwen/Qwen3.6-Max-Preview` | Qwen 3.6 Max Preview | open-source | yes | 1M | +| `Qwen/Qwen3.6-Plus` | Qwen 3.6 Plus | open-source | yes | 1M | +| `Qwen/Qwen3.7-Max` | Qwen 3.7 Max | open-source | yes | 1M | +| `stepfun/Step-3.5-Flash` | Step 3.5 Flash | open-source | yes | 1M | Full model list is maintained in [`models.json`](./models.json). Run `bun run sync` to refresh from the latest Command Code CLI release on npm. ## Development ```bash -git clone https://github.com/brent-weatherall/commandcode-go-opencode-provider.git -cd commandcode-go-opencode-provider +git clone https://github.com/danielxxomg/opencode-commandcode-provider.git +cd opencode-commandcode-provider bun install +bun test ``` -For local testing, create `opencode.local.json` (gitignored) with `file://` paths: - -```json -{ - "plugin": ["file:///path/to/commandcode-go-opencode-provider/server"], - "provider": { - "commandcode": { - "npm": "file:///path/to/commandcode-go-opencode-provider", - "name": "Command Code (local)", - "env": ["COMMANDCODE_API_KEY"] - } - } -} -``` - -Run `opencode --config opencode.local.json` to test with your local build. - ### Sync Models ```bash diff --git a/package.json b/package.json index a96ec87..757ca85 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "commandcode-go-opencode-provider", - "version": "0.4.0", + "version": "0.4.1", "author": "Brent Weatherall", "repository": { "type": "git", - "url": "git+https://github.com/brent-weatherall/opencode-commandcode-provider.git" + "url": "git+https://github.com/danielxxomg/opencode-commandcode-provider.git" }, "main": "index.ts", "devDependencies": { From 0bcd596100668ef9286473d216fb7b23649b15ed Mon Sep 17 00:00:00 2001 From: danielxxomg Date: Tue, 28 Jul 2026 02:17:28 +0200 Subject: [PATCH 5/7] feat: add full OpenCode model schema (capabilities, vision, PDF, reasoning variants) for all 50 Command Code models --- models.json | 2491 ++++++++++++++++++++++++++++++++-- plugin.ts | 28 +- scripts/update-all-models.ts | 210 +++ 3 files changed, 2588 insertions(+), 141 deletions(-) create mode 100644 scripts/update-all-models.ts diff --git a/models.json b/models.json index db50960..2ffbad8 100644 --- a/models.json +++ b/models.json @@ -1,177 +1,2027 @@ [ + { + "id": "xiaomi/mimo-v2.5-pro", + "name": "Xiaomi Mimo V2.5 Pro", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.2, + "output": 0.6, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "xiaomi/mimo-v2.5", + "name": "Xiaomi Mimo V2.5", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.1, + "output": 0.3, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "claude-sonnet-5", + "name": "Claude Sonnet 5", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 3, + "output": 15, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "claude-sonnet-4-6", + "name": "Claude Sonnet 4.6", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 3, + "output": 15, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 32000 + } + }, + { + "id": "claude-fable-5", + "name": "Claude Fable 5", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 3, + "output": 15, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "claude-opus-5", + "name": "Claude Opus 5", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 15, + "output": 75, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 64000 + } + }, + { + "id": "claude-opus-4-8", + "name": "Claude Opus 4.8", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 15, + "output": 75, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 32000 + } + }, + { + "id": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 15, + "output": 75, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 32000 + } + }, { "id": "claude-haiku-4-5-20251001", "name": "Claude Haiku 4.5", "tier": "premium", "reasoning": false, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": false + }, + "cost": { + "input": 0.8, + "output": 4, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 8192 + } + }, + { + "id": "gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 2.5, + "output": 10, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 128000 + } + }, + { + "id": "gpt-5.6-terra", + "name": "GPT-5.6 Terra", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 1.5, + "output": 6, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 500000, + "output": 128000 + } + }, + { + "id": "gpt-5.6-luna", + "name": "GPT-5.6 Luna", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.5, + "output": 2, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 256000, + "output": 64000 + } + }, + { + "id": "gpt-5.5", + "name": "GPT-5.5", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 2.5, + "output": 10, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 256000, + "output": 128000 + } + }, + { + "id": "gpt-5.4", + "name": "GPT-5.4", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 2.5, + "output": 10, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 256000, + "output": 128000 + } + }, + { + "id": "gpt-5.3-codex", + "name": "GPT-5.3 Codex", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 2.5, + "output": 10, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 400000, + "output": 128000 + } + }, + { + "id": "gpt-5.4-mini", + "name": "GPT-5.4 Mini", + "tier": "premium", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.15, + "output": 0.6, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 400000, + "output": 64000 + } + }, + { + "id": "deepseek/deepseek-v4-pro", + "name": "DeepSeek V4 Pro", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.435, + "output": 0.87, + "cache": { + "read": 0.003625, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "deepseek/deepseek-v4-flash", + "name": "DeepSeek V4 Flash", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.14, + "output": 0.28, + "cache": { + "read": 0.01, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 384000 + } + }, + { + "id": "moonshotai/Kimi-K3", + "name": "Kimi K3", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.6, + "output": 2.4, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "moonshotai/Kimi-K2.7-Code", + "name": "Kimi K2.7 Code", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.5, + "output": 2, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 500000, + "output": 131072 + } + }, + { + "id": "moonshotai/Kimi-K2.7-Code-Highspeed", + "name": "Kimi K2.7 Code Highspeed", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.5, + "output": 2, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 500000, + "output": 131072 + } + }, + { + "id": "moonshotai/Kimi-K2.6", + "name": "Kimi K2.6", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": false + }, + "cost": { + "input": 0.4, + "output": 1.6, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 262144, + "output": 131072 + } + }, + { + "id": "moonshotai/Kimi-K2.5", + "name": "Kimi K2.5", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": false + }, + "cost": { + "input": 0.3, + "output": 1.2, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 262144, + "output": 131072 + } + }, + { + "id": "zai-org/GLM-5.2", + "name": "GLM-5.2", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 1, + "output": 3, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "zai-org/GLM-5.2-Fast", + "name": "GLM-5.2 Fast", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.5, + "output": 1.5, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "zai-org/GLM-5.1", + "name": "GLM-5.1", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": false + }, + "cost": { + "input": 1.4, + "output": 4.4, + "cache": { + "read": 0.26, + "write": 0 + } + }, + "limit": { + "context": 200000, + "output": 131072 + } + }, + { + "id": "zai-org/GLM-5", + "name": "GLM-5", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": false + }, "cost": { "input": 1, - "output": 5, - "cache_read": 0.1, - "cache_write": 1.25 + "output": 3, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { "context": 200000, - "output": 8192 + "output": 131072 } }, { - "id": "claude-opus-4-7", - "name": "Claude Opus 4.7", - "tier": "premium", + "id": "MiniMaxAI/MiniMax-M3-Free", + "name": "MiniMax M3 (Free)", + "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 5, - "output": 25, - "cache_read": 0.5, - "cache_write": 6.25 + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { "context": 1000000, - "output": 32000 + "output": 131072 } }, { - "id": "claude-sonnet-4-6", - "name": "Claude Sonnet 4.6", - "tier": "premium", + "id": "MiniMaxAI/MiniMax-M3", + "name": "MiniMax M3", + "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 3, - "output": 15, - "cache_read": 0.3, - "cache_write": 3.75 + "input": 0.5, + "output": 2, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { "context": 1000000, - "output": 16000 + "output": 131072 } }, { - "id": "gpt-5.3-codex", - "name": "GPT-5.3 Codex", - "tier": "premium", + "id": "MiniMaxAI/MiniMax-M2.7", + "name": "MiniMax M2.7", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": false + }, + "cost": { + "input": 0.3, + "output": 1.2, + "cache": { + "read": 0.06, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "MiniMaxAI/MiniMax-M2.5", + "name": "MiniMax M2.5", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": false, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": false + }, + "cost": { + "input": 0.2, + "output": 0.8, + "cache": { + "read": 0, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "Qwen/Qwen3.6-Max-Preview", + "name": "Qwen 3.6 Max Preview", + "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 2, - "output": 8, - "cache_read": 0.5 + "input": 1.3, + "output": 7.8, + "cache": { + "read": 0.26, + "write": 1.63 + } }, "limit": { - "context": 400000, - "output": 128000 + "context": 1000000, + "output": 131072 } }, { - "id": "gpt-5.4", - "name": "GPT-5.4", - "tier": "premium", + "id": "Qwen/Qwen3.6-Plus", + "name": "Qwen 3.6 Plus", + "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 2.5, - "output": 15, - "cache_read": 0.25 + "input": 0.5, + "output": 3, + "cache": { + "read": 0.1, + "write": 0 + } }, "limit": { - "context": 400000, - "output": 128000 + "context": 1000000, + "output": 131072 } }, { - "id": "gpt-5.4-mini", - "name": "GPT-5.4 Mini", - "tier": "premium", + "id": "Qwen/Qwen3.7-Max", + "name": "Qwen 3.7 Max", + "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 0.75, - "output": 4.5, - "cache_read": 0.075 + "input": 1.25, + "output": 3.75, + "cache": { + "read": 0.25, + "write": 1.56 + } }, "limit": { - "context": 400000, - "output": 128000 + "context": 1000000, + "output": 131072 } }, { - "id": "gpt-5.5", - "name": "GPT-5.5", - "tier": "premium", + "id": "Qwen/Qwen3.7-Plus", + "name": "Qwen 3.7 Plus", + "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 5, - "output": 30, - "cache_read": 0.5 + "input": 0.4, + "output": 1.2, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { - "context": 256000, - "output": 128000 + "context": 1000000, + "output": 131072 } }, { - "id": "deepseek/deepseek-v4-flash", - "name": "DeepSeek V4 Flash", + "id": "stepfun/Step-3.7-Flash", + "name": "Step 3.7 Flash", "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 0.14, - "output": 0.28, - "cache_read": 0.01 + "input": 0.15, + "output": 0.45, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { "context": 1000000, - "output": 384000 + "output": 131072 } }, { - "id": "deepseek/deepseek-v4-pro", - "name": "DeepSeek V4 Pro", + "id": "stepfun/Step-3.5-Flash", + "name": "Step 3.5 Flash", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, + "cost": { + "input": 0.1, + "output": 0.3, + "cache": { + "read": 0.02, + "write": 0 + } + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "tencent/hy3-paid", + "name": "Tencent Hy3 Paid", "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 0.435, - "output": 0.87, - "cache_read": 0.003625 + "input": 0.3, + "output": 0.9, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { "context": 1000000, - "output": 384000 + "output": 131072 } }, { - "id": "google/gemini-3.1-flash-lite", - "name": "Gemini 3.1 Flash Lite", + "id": "tencent/Hy3", + "name": "Tencent Hy3", "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 0.25, - "output": 1.5, - "cache_read": 0.03 + "input": 0.2, + "output": 0.6, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { "context": 1000000, - "output": 65536 + "output": 131072 } }, { - "id": "google/gemini-3.5-flash", - "name": "Gemini 3.5 Flash", + "id": "google/gemini-3.6-flash", + "name": "Gemini 3.6 Flash", "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { "input": 1.5, "output": 9, - "cache_read": 0.15 + "cache": { + "read": 0.15, + "write": 0 + } }, "limit": { "context": 1000000, @@ -179,92 +2029,317 @@ } }, { - "id": "zai-org/GLM-5", - "name": "GLM-5", + "id": "google/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", "tier": "open-source", - "reasoning": false, + "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 0.95, - "output": 3.15 + "input": 1.5, + "output": 9, + "cache": { + "read": 0.15, + "write": 0 + } }, "limit": { - "context": 200000, - "output": 131072 + "context": 1000000, + "output": 65536 } }, { - "id": "zai-org/GLM-5.1", - "name": "GLM-5.1", + "id": "google/gemini-3.5-flash-lite", + "name": "Gemini 3.5 Flash Lite", "tier": "open-source", - "reasoning": false, + "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 1.4, - "output": 4.4, - "cache_read": 0.26 + "input": 0.25, + "output": 1.5, + "cache": { + "read": 0.03, + "write": 0 + } }, "limit": { - "context": 200000, - "output": 131072 + "context": 1000000, + "output": 65536 } }, { - "id": "moonshotai/Kimi-K2.5", - "name": "Kimi K2.5", + "id": "google/gemini-3.1-flash-lite", + "name": "Gemini 3.1 Flash Lite", "tier": "open-source", - "reasoning": false, + "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": true, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 0.6, - "output": 3 + "input": 0.25, + "output": 1.5, + "cache": { + "read": 0.03, + "write": 0 + } }, "limit": { - "context": 256000, - "output": 131072 + "context": 1000000, + "output": 65536 } }, { - "id": "moonshotai/Kimi-K2.6", - "name": "Kimi K2.6", + "id": "thinkingmachines/inkling", + "name": "Inkling", "tier": "open-source", - "reasoning": false, + "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 0.95, - "output": 4, - "cache_read": 0.16 + "input": 0.2, + "output": 0.8, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { - "context": 256000, + "context": 1000000, "output": 131072 } }, { - "id": "MiniMaxAI/MiniMax-M2.5", - "name": "MiniMax M2.5", + "id": "sakana/fugu-ultra", + "name": "Sakana Fugu Ultra", "tier": "open-source", - "reasoning": false, + "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 0.5, - "output": 2 + "input": 1, + "output": 3, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { - "context": 200000, + "context": 1000000, "output": 131072 } }, { - "id": "MiniMaxAI/MiniMax-M2.7", - "name": "MiniMax M2.7", + "id": "xai/grok-4.5", + "name": "xAI Grok 4.5", "tier": "open-source", - "reasoning": false, + "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 0.3, - "output": 1.2, - "cache_read": 0.06 + "input": 2, + "output": 6, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { "context": 1000000, @@ -272,16 +2347,52 @@ } }, { - "id": "Qwen/Qwen3.6-Max-Preview", - "name": "Qwen 3.6 Max Preview", + "id": "meta/muse-spark-1.1", + "name": "Meta Muse Spark 1.1", "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": true, + "toolcall": true, + "input": { + "text": true, + "image": true, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 1.3, - "output": 7.8, - "cache_read": 0.26, - "cache_write": 1.63 + "input": 0.2, + "output": 0.6, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { "context": 1000000, @@ -289,15 +2400,52 @@ } }, { - "id": "Qwen/Qwen3.6-Plus", - "name": "Qwen 3.6 Plus", + "id": "nvidia/nemotron-3-ultra-550b-a55b", + "name": "Nvidia Nemotron 3 Ultra", "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { "input": 0.5, - "output": 3, - "cache_read": 0.1 + "output": 1.5, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { "context": 1000000, @@ -305,16 +2453,52 @@ } }, { - "id": "Qwen/Qwen3.7-Max", - "name": "Qwen 3.7 Max", + "id": "poolside/laguna-s-2.1-free", + "name": "Poolside Laguna S 2.1 (Free)", "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 1.25, - "output": 3.75, - "cache_read": 0.25, - "cache_write": 1.56 + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { "context": 1000000, @@ -322,15 +2506,52 @@ } }, { - "id": "stepfun/Step-3.5-Flash", - "name": "Step 3.5 Flash", + "id": "inclusionai/ling-3.0-flash-free", + "name": "InclusionAI Ling 3.0 Flash (Free)", "tier": "open-source", "reasoning": true, "tool_call": true, + "capabilities": { + "temperature": true, + "reasoning": true, + "attachment": false, + "toolcall": true, + "input": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "output": { + "text": true, + "image": false, + "pdf": false, + "audio": false, + "video": false + }, + "interleaved": { + "field": "reasoning_content" + } + }, + "variants": { + "low": { + "reasoningEffort": "low" + }, + "medium": { + "reasoningEffort": "medium" + }, + "high": { + "reasoningEffort": "high" + } + }, "cost": { - "input": 0.1, - "output": 0.3, - "cache_read": 0.02 + "input": 0, + "output": 0, + "cache": { + "read": 0, + "write": 0 + } }, "limit": { "context": 1000000, diff --git a/plugin.ts b/plugin.ts index 655e127..fab1dbd 100644 --- a/plugin.ts +++ b/plugin.ts @@ -10,7 +10,9 @@ interface ModelEntry { tier: "premium" | "open-source" reasoning: boolean tool_call: boolean - cost: { input: number; output: number; cache_read?: number; cache_write?: number } + capabilities?: Record + variants?: Record + cost: { input: number; output: number; cache_read?: number; cache_write?: number; cache?: { read: number; write: number } } limit: { context: number; output: number } } @@ -43,19 +45,33 @@ export default async function commandcodePlugin() { const models = loadModels() const modelsObj: Record = {} for (const entry of models) { - const key = toConfigKey(entry.id) - const costObj: Record = { input: entry.cost.input, output: entry.cost.output } - if (entry.cost.cache_read !== undefined) costObj.cache_read = entry.cost.cache_read - if (entry.cost.cache_write !== undefined) costObj.cache_write = entry.cost.cache_write + const shortKey = toConfigKey(entry.id) + const fullKey = entry.id.toLowerCase() + const hyphenKey = entry.id.toLowerCase().replace(/\//g, "-") - modelsObj[key] = { + const costObj: Record = { + input: entry.cost.input, + output: entry.cost.output, + cache: entry.cost.cache ?? { + read: entry.cost.cache_read ?? 0, + write: entry.cost.cache_write ?? 0, + }, + } + + const modelDef: Record = { id: entry.id, name: entry.name, reasoning: entry.reasoning, tool_call: entry.tool_call, + capabilities: entry.capabilities, + variants: entry.variants, cost: costObj, limit: entry.limit, } + + modelsObj[shortKey] = modelDef + modelsObj[fullKey] = modelDef + modelsObj[hyphenKey] = modelDef } cc.models = modelsObj } diff --git a/scripts/update-all-models.ts b/scripts/update-all-models.ts new file mode 100644 index 0000000..4528f0e --- /dev/null +++ b/scripts/update-all-models.ts @@ -0,0 +1,210 @@ +import { writeFileSync } from "fs" +import { join } from "path" + +export interface ModelVariant { + [key: string]: unknown +} + +export interface ModelCapabilities { + temperature: boolean + reasoning: boolean + attachment: boolean + toolcall: boolean + input: { + text: boolean + image: boolean + pdf: boolean + audio: boolean + video: boolean + } + output: { + text: boolean + image: boolean + pdf: boolean + audio: boolean + video: boolean + } + interleaved?: boolean | { field: string } +} + +export interface ModelCost { + input: number + output: number + cache: { + read: number + write: number + } +} + +export interface ModelEntry { + id: string + name: string + tier: "premium" | "open-source" + reasoning: boolean + tool_call: boolean + capabilities: ModelCapabilities + variants?: Record + cost: ModelCost + limit: { context: number; output: number } +} + +const DEFAULT_REASONING_VARIANTS: Record = { + low: { reasoningEffort: "low" }, + medium: { reasoningEffort: "medium" }, + high: { reasoningEffort: "high" }, +} + +interface RawModelConfig { + name: string + tier?: "premium" | "open-source" + reasoning?: boolean + hasVision?: boolean + hasPdf?: boolean + costInput?: number + costOutput?: number + cacheRead?: number + cacheWrite?: number + contextLimit?: number + outputLimit?: number + variants?: Record +} + +const MODEL_CATALOG: Record = { + // Xiaomi Mimo + "xiaomi/mimo-v2.5-pro": { name: "Xiaomi Mimo V2.5 Pro", tier: "open-source", reasoning: true, hasVision: true, costInput: 0.2, costOutput: 0.6, contextLimit: 1000000, outputLimit: 131072 }, + "xiaomi/mimo-v2.5": { name: "Xiaomi Mimo V2.5", tier: "open-source", reasoning: true, hasVision: true, costInput: 0.1, costOutput: 0.3, contextLimit: 1000000, outputLimit: 131072 }, + + // Anthropic + "claude-sonnet-5": { name: "Claude Sonnet 5", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 3.0, costOutput: 15.0, contextLimit: 1000000, outputLimit: 64000 }, + "claude-sonnet-4-6": { name: "Claude Sonnet 4.6", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 3.0, costOutput: 15.0, contextLimit: 1000000, outputLimit: 32000 }, + "claude-fable-5": { name: "Claude Fable 5", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 3.0, costOutput: 15.0, contextLimit: 1000000, outputLimit: 64000 }, + "claude-opus-5": { name: "Claude Opus 5", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 15.0, costOutput: 75.0, contextLimit: 1000000, outputLimit: 64000 }, + "claude-opus-4-8": { name: "Claude Opus 4.8", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 15.0, costOutput: 75.0, contextLimit: 1000000, outputLimit: 32000 }, + "claude-opus-4-7": { name: "Claude Opus 4.7", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 15.0, costOutput: 75.0, contextLimit: 1000000, outputLimit: 32000 }, + "claude-haiku-4-5-20251001": { name: "Claude Haiku 4.5", tier: "premium", reasoning: false, hasVision: true, costInput: 0.8, costOutput: 4.0, contextLimit: 200000, outputLimit: 8192 }, + + // OpenAI + "gpt-5.6-sol": { name: "GPT-5.6 Sol", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 2.5, costOutput: 10.0, contextLimit: 1000000, outputLimit: 128000 }, + "gpt-5.6-terra": { name: "GPT-5.6 Terra", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 1.5, costOutput: 6.0, contextLimit: 500000, outputLimit: 128000 }, + "gpt-5.6-luna": { name: "GPT-5.6 Luna", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 0.5, costOutput: 2.0, contextLimit: 256000, outputLimit: 64000 }, + "gpt-5.5": { name: "GPT-5.5", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 2.5, costOutput: 10.0, contextLimit: 256000, outputLimit: 128000 }, + "gpt-5.4": { name: "GPT-5.4", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 2.5, costOutput: 10.0, contextLimit: 256000, outputLimit: 128000 }, + "gpt-5.3-codex": { name: "GPT-5.3 Codex", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 2.5, costOutput: 10.0, contextLimit: 400000, outputLimit: 128000 }, + "gpt-5.4-mini": { name: "GPT-5.4 Mini", tier: "premium", reasoning: true, hasVision: true, hasPdf: true, costInput: 0.15, costOutput: 0.6, contextLimit: 400000, outputLimit: 64000 }, + + // DeepSeek + "deepseek/deepseek-v4-pro": { name: "DeepSeek V4 Pro", tier: "open-source", reasoning: true, costInput: 0.435, costOutput: 0.87, cacheRead: 0.003625, contextLimit: 1000000, outputLimit: 384000 }, + "deepseek/deepseek-v4-flash": { name: "DeepSeek V4 Flash", tier: "open-source", reasoning: true, costInput: 0.14, costOutput: 0.28, cacheRead: 0.01, contextLimit: 1000000, outputLimit: 384000 }, + + // Moonshot Kimi + "moonshotai/Kimi-K3": { name: "Kimi K3", tier: "open-source", reasoning: true, hasVision: true, costInput: 0.6, costOutput: 2.4, contextLimit: 1000000, outputLimit: 131072 }, + "moonshotai/Kimi-K2.7-Code": { name: "Kimi K2.7 Code", tier: "open-source", reasoning: true, hasVision: true, costInput: 0.5, costOutput: 2.0, contextLimit: 500000, outputLimit: 131072 }, + "moonshotai/Kimi-K2.7-Code-Highspeed": { name: "Kimi K2.7 Code Highspeed", tier: "open-source", reasoning: true, hasVision: true, costInput: 0.5, costOutput: 2.0, contextLimit: 500000, outputLimit: 131072 }, + "moonshotai/Kimi-K2.6": { name: "Kimi K2.6", tier: "open-source", reasoning: false, hasVision: true, costInput: 0.4, costOutput: 1.6, contextLimit: 262144, outputLimit: 131072 }, + "moonshotai/Kimi-K2.5": { name: "Kimi K2.5", tier: "open-source", reasoning: false, hasVision: true, costInput: 0.3, costOutput: 1.2, contextLimit: 262144, outputLimit: 131072 }, + + // Zhipu GLM + "zai-org/GLM-5.2": { name: "GLM-5.2", tier: "open-source", reasoning: true, costInput: 1.0, costOutput: 3.0, contextLimit: 1000000, outputLimit: 131072 }, + "zai-org/GLM-5.2-Fast": { name: "GLM-5.2 Fast", tier: "open-source", reasoning: true, costInput: 0.5, costOutput: 1.5, contextLimit: 1000000, outputLimit: 131072 }, + "zai-org/GLM-5.1": { name: "GLM-5.1", tier: "open-source", reasoning: false, costInput: 1.4, costOutput: 4.4, cacheRead: 0.26, contextLimit: 200000, outputLimit: 131072 }, + "zai-org/GLM-5": { name: "GLM-5", tier: "open-source", reasoning: false, costInput: 1.0, costOutput: 3.0, contextLimit: 200000, outputLimit: 131072 }, + + // MiniMax + "MiniMaxAI/MiniMax-M3-Free": { name: "MiniMax M3 (Free)", tier: "open-source", reasoning: true, costInput: 0, costOutput: 0, contextLimit: 1000000, outputLimit: 131072 }, + "MiniMaxAI/MiniMax-M3": { name: "MiniMax M3", tier: "open-source", reasoning: true, costInput: 0.5, costOutput: 2.0, contextLimit: 1000000, outputLimit: 131072 }, + "MiniMaxAI/MiniMax-M2.7": { name: "MiniMax M2.7", tier: "open-source", reasoning: false, costInput: 0.3, costOutput: 1.2, cacheRead: 0.06, contextLimit: 1000000, outputLimit: 131072 }, + "MiniMaxAI/MiniMax-M2.5": { name: "MiniMax M2.5", tier: "open-source", reasoning: false, costInput: 0.2, costOutput: 0.8, contextLimit: 1000000, outputLimit: 131072 }, + + // Qwen + "Qwen/Qwen3.6-Max-Preview": { name: "Qwen 3.6 Max Preview", tier: "open-source", reasoning: true, costInput: 1.3, costOutput: 7.8, cacheRead: 0.26, cacheWrite: 1.63, contextLimit: 1000000, outputLimit: 131072 }, + "Qwen/Qwen3.6-Plus": { name: "Qwen 3.6 Plus", tier: "open-source", reasoning: true, costInput: 0.5, costOutput: 3.0, cacheRead: 0.1, contextLimit: 1000000, outputLimit: 131072 }, + "Qwen/Qwen3.7-Max": { name: "Qwen 3.7 Max", tier: "open-source", reasoning: true, costInput: 1.25, costOutput: 3.75, cacheRead: 0.25, cacheWrite: 1.56, contextLimit: 1000000, outputLimit: 131072 }, + "Qwen/Qwen3.7-Plus": { name: "Qwen 3.7 Plus", tier: "open-source", reasoning: true, costInput: 0.4, costOutput: 1.2, contextLimit: 1000000, outputLimit: 131072 }, + + // StepFun + "stepfun/Step-3.7-Flash": { name: "Step 3.7 Flash", tier: "open-source", reasoning: true, hasVision: true, costInput: 0.15, costOutput: 0.45, contextLimit: 1000000, outputLimit: 131072 }, + "stepfun/Step-3.5-Flash": { name: "Step 3.5 Flash", tier: "open-source", reasoning: true, costInput: 0.1, costOutput: 0.3, cacheRead: 0.02, contextLimit: 1000000, outputLimit: 131072 }, + + // Tencent + "tencent/hy3-paid": { name: "Tencent Hy3 Paid", tier: "open-source", reasoning: true, costInput: 0.3, costOutput: 0.9, contextLimit: 1000000, outputLimit: 131072 }, + "tencent/Hy3": { name: "Tencent Hy3", tier: "open-source", reasoning: true, costInput: 0.2, costOutput: 0.6, contextLimit: 1000000, outputLimit: 131072 }, + + // Google + "google/gemini-3.6-flash": { name: "Gemini 3.6 Flash", tier: "open-source", reasoning: true, hasVision: true, hasPdf: true, costInput: 1.5, costOutput: 9.0, cacheRead: 0.15, contextLimit: 1000000, outputLimit: 65536 }, + "google/gemini-3.5-flash": { name: "Gemini 3.5 Flash", tier: "open-source", reasoning: true, hasVision: true, hasPdf: true, costInput: 1.5, costOutput: 9.0, cacheRead: 0.15, contextLimit: 1000000, outputLimit: 65536 }, + "google/gemini-3.5-flash-lite": { name: "Gemini 3.5 Flash Lite", tier: "open-source", reasoning: true, hasVision: true, hasPdf: true, costInput: 0.25, costOutput: 1.5, cacheRead: 0.03, contextLimit: 1000000, outputLimit: 65536 }, + "google/gemini-3.1-flash-lite": { name: "Gemini 3.1 Flash Lite", tier: "open-source", reasoning: true, hasVision: true, hasPdf: true, costInput: 0.25, costOutput: 1.5, cacheRead: 0.03, contextLimit: 1000000, outputLimit: 65536 }, + + // Miscellaneous + "thinkingmachines/inkling": { name: "Inkling", tier: "open-source", reasoning: true, costInput: 0.2, costOutput: 0.8, contextLimit: 1000000, outputLimit: 131072 }, + "sakana/fugu-ultra": { name: "Sakana Fugu Ultra", tier: "open-source", reasoning: true, costInput: 1.0, costOutput: 3.0, contextLimit: 1000000, outputLimit: 131072 }, + "xai/grok-4.5": { name: "xAI Grok 4.5", tier: "open-source", reasoning: true, costInput: 2.0, costOutput: 6.0, contextLimit: 1000000, outputLimit: 131072 }, + "meta/muse-spark-1.1": { name: "Meta Muse Spark 1.1", tier: "open-source", reasoning: true, hasVision: true, costInput: 0.2, costOutput: 0.6, contextLimit: 1000000, outputLimit: 131072 }, + "nvidia/nemotron-3-ultra-550b-a55b": { name: "Nvidia Nemotron 3 Ultra", tier: "open-source", reasoning: true, costInput: 0.5, costOutput: 1.5, contextLimit: 1000000, outputLimit: 131072 }, + "poolside/laguna-s-2.1-free": { name: "Poolside Laguna S 2.1 (Free)", tier: "open-source", reasoning: true, costInput: 0, costOutput: 0, contextLimit: 1000000, outputLimit: 131072 }, + "inclusionai/ling-3.0-flash-free": { name: "InclusionAI Ling 3.0 Flash (Free)", tier: "open-source", reasoning: true, costInput: 0, costOutput: 0, contextLimit: 1000000, outputLimit: 131072 } +} + +function main() { + const modelEntries: ModelEntry[] = [] + + for (const [id, raw] of Object.entries(MODEL_CATALOG)) { + const isReasoning = raw.reasoning ?? true + const hasVision = raw.hasVision ?? false + const hasPdf = raw.hasPdf ?? false + + const capabilities: ModelCapabilities = { + temperature: true, + reasoning: isReasoning, + attachment: hasVision || hasPdf, + toolcall: true, + input: { + text: true, + image: hasVision, + pdf: hasPdf, + audio: false, + video: false, + }, + output: { + text: true, + image: false, + pdf: false, + audio: false, + video: false, + }, + interleaved: isReasoning ? { field: "reasoning_content" } : false, + } + + const entry: ModelEntry = { + id, + name: raw.name || id, + tier: raw.tier || (id.includes("/") ? "open-source" : "premium"), + reasoning: isReasoning, + tool_call: true, + capabilities, + variants: isReasoning ? (raw.variants || DEFAULT_REASONING_VARIANTS) : undefined, + cost: { + input: raw.costInput ?? 0.5, + output: raw.costOutput ?? 1.5, + cache: { + read: raw.cacheRead ?? 0, + write: raw.cacheWrite ?? 0, + }, + }, + limit: { + context: raw.contextLimit ?? 1000000, + output: raw.outputLimit ?? 131072, + }, + } + + modelEntries.push(entry) + } + + const projectRoot = join(import.meta.dir, "..") + const outputPath = join(projectRoot, "models.json") + + writeFileSync(outputPath, JSON.stringify(modelEntries, null, 2) + "\n", "utf-8") + console.log(`Successfully generated full-schema models.json for ${modelEntries.length} models`) +} + +main() From eaca63cca0625b0a01e39625358ebc3d466ea6fd Mon Sep 17 00:00:00 2001 From: danielxxomg <150471793+danielxxomg@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:06:10 -0500 Subject: [PATCH 6/7] feat: add dynamic model discovery, OpenCode 2 support, and project context --- README.md | 112 +++++++--- models.json | 405 ++++++++++++++++++++++++++++++++++- package.json | 15 +- plugin.ts | 73 ++----- src/context.ts | 171 +++++++++++++++ src/convert.ts | 29 ++- src/model.ts | 6 +- src/models.ts | 216 +++++++++++++++++++ tests/unit/context.test.ts | 49 +++++ tests/unit/convert.test.ts | 33 +++ tests/unit/discovery.test.ts | 94 ++++++++ tests/unit/models.test.ts | 75 +++++++ tests/unit/v2.test.ts | 50 +++++ tsconfig.json | 2 +- v2.ts | 82 +++++++ 15 files changed, 1310 insertions(+), 102 deletions(-) create mode 100644 src/context.ts create mode 100644 src/models.ts create mode 100644 tests/unit/context.test.ts create mode 100644 tests/unit/discovery.test.ts create mode 100644 tests/unit/models.test.ts create mode 100644 tests/unit/v2.test.ts create mode 100644 v2.ts diff --git a/README.md b/README.md index 0111dba..8b8d6a4 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,15 @@ > **This fork** adds production-grade retry/backoff, SSE error propagation, and > key rotation on top of the [original by @brent-weatherall](https://github.com/brent-weatherall/opencode-commandcode-provider). -## What this fork adds +## What this fork adds (v0.5.0) -- **Retry with jitter**: fixed backoff schedule `[1s, 2.5s, 5s]` with +-25% jitter replaces unbounded exponential growth. -- **Error classification**: 18 non-retryable patterns (auth/quota/validation) and 24 retryable patterns (network/server/timeout). -- **Mid-stream reconnect**: `emittedContent` tracking prevents duplicate content on reconnect. -- **SSE error propagation**: SSE error events terminate the stream via `controller.error()` instead of being silently swallowed. -- **Partial output safety**: aborts reconnect when text/tool-calls were already emitted to prevent duplicates. +- **Automatic Model Discovery**: fetches current model roster and context limits from Command Code's API at startup with zero-latency fallback to the bundled 67-model offline catalog. +- **OpenCode 2 (V2) Support**: native `./v2` entrypoint (`plugins: ["commandcode-go-opencode-provider/v2"]`) with automatic provider catalog transformation. +- **Project & Git Context**: gathers project file tree (respecting `.gitignore`) and Git metadata (branch, status, recent commits) with in-memory caching to empower Command Code's Plan Mode. +- **Reasoning Effort / Thinking Configuration**: support for custom thinking levels (`low`, `medium`, `high`, `max`) on reasoning models. +- **Production-grade Retry with Jitter**: fixed backoff schedule `[1s, 2.5s, 5s]` with ±25% jitter replaces unbounded exponential growth. +- **Error Classification**: 18 non-retryable patterns (auth/quota/validation) and 24 retryable patterns (network/server/timeout). +- **Mid-stream Reconnect & SSE Error Propagation**: `emittedContent` tracking prevents duplicates; SSE error events terminate streams via `controller.error()`. ## Quick Start @@ -25,8 +27,16 @@ bun install ### 2. Register in OpenCode -Add to your `~/.config/opencode/opencode.json`: +#### For OpenCode 2 (V2): +Add to your `opencode.json`: +```json +{ + "plugins": ["commandcode-go-opencode-provider/v2"] +} +``` +#### For OpenCode 1 (V1): +Add to your `~/.config/opencode/opencode.json`: ```json { "plugin": ["commandcode-go-opencode-provider/server"], @@ -79,27 +89,73 @@ COMMANDCODE_API_KEY=your-key opencode | Model ID | Name | Tier | Reasoning | Context | |---|---|---|---|---| -| `claude-haiku-4-5-20251001` | Claude Haiku 4.5 | premium | no | 200K | -| `claude-opus-4-7` | Claude Opus 4.7 | premium | yes | 1M | -| `claude-sonnet-4-6` | Claude Sonnet 4.6 | premium | yes | 1M | -| `gpt-5.3-codex` | GPT-5.3 Codex | premium | yes | 400K | -| `gpt-5.4` | GPT-5.4 | premium | yes | 400K | -| `gpt-5.4-mini` | GPT-5.4 Mini | premium | yes | 400K | -| `gpt-5.5` | GPT-5.5 | premium | yes | 256K | -| `deepseek/deepseek-v4-flash` | DeepSeek V4 Flash | open-source | yes | 1M | -| `deepseek/deepseek-v4-pro` | DeepSeek V4 Pro | open-source | yes | 1M | -| `google/gemini-3.1-flash-lite` | Gemini 3.1 Flash Lite | open-source | yes | 1M | -| `google/gemini-3.5-flash` | Gemini 3.5 Flash | open-source | yes | 1M | -| `zai-org/GLM-5` | GLM-5 | open-source | no | 200K | -| `zai-org/GLM-5.1` | GLM-5.1 | open-source | no | 200K | -| `moonshotai/Kimi-K2.5` | Kimi K2.5 | open-source | no | 256K | -| `moonshotai/Kimi-K2.6` | Kimi K2.6 | open-source | no | 256K | -| `MiniMaxAI/MiniMax-M2.5` | MiniMax M2.5 | open-source | no | 200K | -| `MiniMaxAI/MiniMax-M2.7` | MiniMax M2.7 | open-source | no | 1M | -| `Qwen/Qwen3.6-Max-Preview` | Qwen 3.6 Max Preview | open-source | yes | 1M | -| `Qwen/Qwen3.6-Plus` | Qwen 3.6 Plus | open-source | yes | 1M | -| `Qwen/Qwen3.7-Max` | Qwen 3.7 Max | open-source | yes | 1M | -| `stepfun/Step-3.5-Flash` | Step 3.5 Flash | open-source | yes | 1M | +| `xiaomi/mimo-v2.5-pro` | Xiaomi Mimo V2.5 Pro | open-source | yes | 1M | +| `xiaomi/mimo-v2.5` | Xiaomi Mimo V2.5 | open-source | yes | 1M | +| `claude-sonnet-5` | Claude Sonnet 5 | premium | yes | 1M | +| `claude-sonnet-4-6` | Claude Sonnet 4.6 | premium | yes | 1M | +| `claude-fable-5` | Claude Fable 5 | premium | yes | 1M | +| `claude-opus-5` | Claude Opus 5 | premium | yes | 1M | +| `claude-opus-4-8` | Claude Opus 4.8 | premium | yes | 1M | +| `claude-opus-4-7` | Claude Opus 4.7 | premium | yes | 1M | +| `claude-haiku-4-5-20251001` | Claude Haiku 4.5 | premium | no | 200K | +| `gpt-5.6-sol` | GPT-5.6 Sol | premium | yes | 1M | +| `gpt-5.6-terra` | GPT-5.6 Terra | premium | yes | 1M | +| `gpt-5.6-luna` | GPT-5.6 Luna | premium | yes | 1M | +| `gpt-5.5` | GPT-5.5 | premium | yes | 400K | +| `gpt-5.4` | GPT-5.4 | premium | yes | 400K | +| `gpt-5.3-codex` | GPT-5.3 Codex | premium | yes | 400K | +| `gpt-5.4-mini` | GPT-5.4 Mini | premium | yes | 400K | +| `deepseek/deepseek-v4-pro` | DeepSeek V4 Pro | open-source | yes | 1M | +| `deepseek/deepseek-v4-flash` | DeepSeek V4 Flash | open-source | yes | 1M | +| `moonshotai/Kimi-K3` | Kimi K3 | open-source | yes | 1M | +| `moonshotai/Kimi-K2.7-Code` | Kimi K2.7 Code | open-source | yes | 500K | +| `moonshotai/Kimi-K2.7-Code-Highspeed` | Kimi K2.7 Code Highspeed | open-source | yes | 500K | +| `moonshotai/Kimi-K2.6` | Kimi K2.6 | open-source | no | 262K | +| `moonshotai/Kimi-K2.5` | Kimi K2.5 | open-source | no | 262K | +| `zai-org/GLM-5.2` | GLM-5.2 | open-source | yes | 1M | +| `zai-org/GLM-5.2-Fast` | GLM-5.2 Fast | open-source | yes | 1M | +| `zai-org/GLM-5.1` | GLM-5.1 | open-source | no | 200K | +| `zai-org/GLM-5` | GLM-5 | open-source | no | 200K | +| `MiniMaxAI/MiniMax-M3-Free` | MiniMax M3 (Free) | open-source | yes | 1M | +| `MiniMaxAI/MiniMax-M3` | MiniMax M3 | open-source | yes | 1M | +| `MiniMaxAI/MiniMax-M2.7` | MiniMax M2.7 | open-source | no | 1M | +| `MiniMaxAI/MiniMax-M2.5` | MiniMax M2.5 | open-source | no | 1M | +| `Qwen/Qwen3.6-Max-Preview` | Qwen 3.6 Max Preview | open-source | yes | 1M | +| `Qwen/Qwen3.6-Plus` | Qwen 3.6 Plus | open-source | yes | 1M | +| `Qwen/Qwen3.7-Max` | Qwen 3.7 Max | open-source | yes | 1M | +| `Qwen/Qwen3.7-Plus` | Qwen 3.7 Plus | open-source | yes | 1M | +| `stepfun/Step-3.7-Flash` | Step 3.7 Flash | open-source | yes | 1M | +| `stepfun/Step-3.5-Flash` | Step 3.5 Flash | open-source | yes | 1M | +| `tencent/hy3-paid` | Tencent Hy3 Paid | open-source | yes | 1M | +| `tencent/Hy3` | Tencent Hy3 | open-source | yes | 1M | +| `google/gemini-3.6-flash` | Gemini 3.6 Flash | open-source | yes | 1M | +| `google/gemini-3.5-flash` | Gemini 3.5 Flash | open-source | yes | 1M | +| `google/gemini-3.5-flash-lite` | Gemini 3.5 Flash Lite | open-source | yes | 1M | +| `google/gemini-3.1-flash-lite` | Gemini 3.1 Flash Lite | open-source | yes | 1M | +| `thinkingmachines/inkling` | Inkling | open-source | yes | 1M | +| `sakana/fugu-ultra` | Sakana Fugu Ultra | open-source | yes | 1M | +| `xai/grok-4.5` | xAI Grok 4.5 | open-source | yes | 1M | +| `meta/muse-spark-1.1` | Meta Muse Spark 1.1 | open-source | yes | 1M | +| `nvidia/nemotron-3-ultra-550b-a55b` | Nvidia Nemotron 3 Ultra | open-source | yes | 1M | +| `poolside/laguna-s-2.1-free` | Poolside Laguna S 2.1 (Free) | open-source | yes | 1M | +| `inclusionai/ling-3.0-flash-free` | InclusionAI Ling 3.0 Flash (Free) | open-source | yes | 1M | +| `claude-fable-5-1` | Claude Fable 5.1 | premium | no | 1M | +| `deepseek/deepseek-v4-flash-vision-exp` | DeepSeek V4 Flash Vision (exp) | open-source | no | 1M | +| `deepseek/deepseek-v4-flash-fast` | DeepSeek V4 Flash Fast | open-source | no | 1M | +| `z-ai/glm-5.3-flash` | GLM-5.3 Flash | open-source | yes | 1M | +| `zai-org/GLM-5.3` | GLM-5.3 | open-source | yes | 1M | +| `Qwen/Qwen3.8-Max-0902` | Qwen 3.8 Max 0902 | open-source | no | 1M | +| `Qwen/Qwen3.8-Max` | Qwen 3.8 Max | open-source | no | 1M | +| `Qwen/Qwen3.8-27B` | Qwen 3.8 27B | open-source | no | 262K | +| `Qwen/Qwen3.8-Flash` | Qwen 3.8 Flash | open-source | no | 1M | +| `Qwen/Qwen3.7-Flash` | Qwen 3.7 Flash | open-source | no | 1M | +| `meituan/LongCat-2.0:free` | LongCat 2.0 | open-source | no | 1M | +| `tencent/hy4-preview` | Tencent Hy4 Preview | open-source | yes | 1M | +| `google/gemini-3.7-flash` | Gemini 3.7 Flash | open-source | no | 1M | +| `thinkingmachines/inkling-small` | Inkling Small | open-source | yes | 1M | +| `meta/muse-spark-1.2` | Muse Spark 1.2 | open-source | no | 1M | +| `meta/muse-spark-1.2-contributor` | Muse Spark 1.2 Contributor | open-source | no | 1M | +| `xai/grok-4.6` | Grok 4.6 | open-source | no | 500K | Full model list is maintained in [`models.json`](./models.json). Run `bun run sync` to refresh from the latest Command Code CLI release on npm. diff --git a/models.json b/models.json index 2ffbad8..9018e35 100644 --- a/models.json +++ b/models.json @@ -512,7 +512,7 @@ } }, "limit": { - "context": 1000000, + "context": 1050000, "output": 128000 } }, @@ -565,7 +565,7 @@ } }, "limit": { - "context": 500000, + "context": 1050000, "output": 128000 } }, @@ -618,7 +618,7 @@ } }, "limit": { - "context": 256000, + "context": 1050000, "output": 64000 } }, @@ -671,7 +671,7 @@ } }, "limit": { - "context": 256000, + "context": 400000, "output": 128000 } }, @@ -724,7 +724,7 @@ } }, "limit": { - "context": 256000, + "context": 400000, "output": 128000 } }, @@ -2395,7 +2395,7 @@ } }, "limit": { - "context": 1000000, + "context": 1048576, "output": 131072 } }, @@ -2557,5 +2557,396 @@ "context": 1000000, "output": 131072 } + }, + { + "id": "claude-fable-5-1", + "name": "Claude Fable 5.1", + "tier": "premium", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": true, + "pdf": true, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "deepseek/deepseek-v4-flash-vision-exp", + "name": "DeepSeek V4 Flash Vision (exp)", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": true, + "pdf": false, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "deepseek/deepseek-v4-flash-fast", + "name": "DeepSeek V4 Flash Fast", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "z-ai/glm-5.3-flash", + "name": "GLM-5.3 Flash", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": true, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, + { + "id": "zai-org/GLM-5.3", + "name": "GLM-5.3", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": true, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "Qwen/Qwen3.8-Max-0902", + "name": "Qwen 3.8 Max 0902", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "Qwen/Qwen3.8-Max", + "name": "Qwen 3.8 Max", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "Qwen/Qwen3.8-27B", + "name": "Qwen 3.8 27B", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 262144, + "output": 131072 + } + }, + { + "id": "Qwen/Qwen3.8-Flash", + "name": "Qwen 3.8 Flash", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "Qwen/Qwen3.7-Flash", + "name": "Qwen 3.7 Flash", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "meituan/LongCat-2.0:free", + "name": "LongCat 2.0", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, + { + "id": "tencent/hy4-preview", + "name": "Tencent Hy4 Preview", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": true, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, + { + "id": "google/gemini-3.7-flash", + "name": "Gemini 3.7 Flash", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": true, + "pdf": true, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, + { + "id": "thinkingmachines/inkling-small", + "name": "Inkling Small", + "tier": "open-source", + "reasoning": true, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": true, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1000000, + "output": 131072 + } + }, + { + "id": "meta/muse-spark-1.2", + "name": "Muse Spark 1.2", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, + { + "id": "meta/muse-spark-1.2-contributor", + "name": "Muse Spark 1.2 Contributor", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": false, + "pdf": false, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 1048576, + "output": 131072 + } + }, + { + "id": "xai/grok-4.6", + "name": "Grok 4.6", + "tier": "open-source", + "reasoning": false, + "tool_call": true, + "capabilities": { + "vision": true, + "pdf": false, + "reasoning": false, + "tool_call": true + }, + "cost": { + "input": 0, + "output": 0, + "cache_read": 0, + "cache_write": 0 + }, + "limit": { + "context": 500000, + "output": 131072 + } } -] +] \ No newline at end of file diff --git a/package.json b/package.json index 757ca85..76deceb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "commandcode-go-opencode-provider", - "version": "0.4.1", - "author": "Brent Weatherall", + "version": "0.5.0", + "author": "Brent Weatherall, danielxxomg", "repository": { "type": "git", "url": "git+https://github.com/danielxxomg/opencode-commandcode-provider.git" @@ -18,15 +18,18 @@ }, "./server": { "import": "./plugin.ts" + }, + "./v2": { + "import": "./v2.ts" } }, "bugs": { - "url": "https://github.com/brent-weatherall/opencode-commandcode-provider/issues" + "url": "https://github.com/danielxxomg/opencode-commandcode-provider/issues" }, - "description": "Command Code API provider for opencode — use Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, and Step models via Command Code", + "description": "Command Code API provider for opencode — use Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, and Step models via Command Code with auto-discovery and OpenCode 2 support", "engines": { "bun": ">=1.0.0" }, - "files": ["index.ts", "plugin.ts", "models.json", "src/"], - "homepage": "https://github.com/brent-weatherall/opencode-commandcode-provider#readme", + "files": ["index.ts", "plugin.ts", "v2.ts", "models.json", "src/"], + "homepage": "https://github.com/danielxxomg/opencode-commandcode-provider#readme", "keywords": [ "opencode", "opencode-provider", diff --git a/plugin.ts b/plugin.ts index fab1dbd..1ffe561 100644 --- a/plugin.ts +++ b/plugin.ts @@ -1,40 +1,22 @@ -import { readFileSync } from "fs" -import { join, dirname } from "path" -import { fileURLToPath } from "url" - -const __dirname = dirname(fileURLToPath(import.meta.url)) - -interface ModelEntry { - id: string - name: string - tier: "premium" | "open-source" - reasoning: boolean - tool_call: boolean - capabilities?: Record - variants?: Record - cost: { input: number; output: number; cache_read?: number; cache_write?: number; cache?: { read: number; write: number } } - limit: { context: number; output: number } -} - -function loadModels(): ModelEntry[] { - const modelsPath = join(__dirname, "models.json") - return JSON.parse(readFileSync(modelsPath, "utf-8")) -} - -function toConfigKey(id: string): string { - const slashIdx = id.indexOf("/") - const short = slashIdx >= 0 ? id.slice(slashIdx + 1) : id - return short.toLowerCase() -} +import { + loadModels, + fetchModelsFromApi, + mergeModels, + toConfigKey, + toV1ModelConfig, + type ModelEntry, +} from "./src/models.js" export default async function commandcodePlugin() { return { config: async (config: Record) => { const providers = config.provider as Record> | undefined if (!providers) { - (config as Record).provider = { commandcode: {} } + ;(config as Record).provider = { commandcode: {} } } - const cc = ((config as Record).provider as Record>)?.commandcode as Record | undefined + const cc = ((config as Record).provider as Record>)?.commandcode as + | Record + | undefined if (!cc) return if (!cc.npm) cc.npm = "commandcode-go-opencode-provider" @@ -42,32 +24,23 @@ export default async function commandcodePlugin() { if (!cc.env) cc.env = ["COMMANDCODE_API_KEY"] if (!cc.models) { - const models = loadModels() + let models = loadModels() + try { + const apiModels = await fetchModelsFromApi() + if (apiModels && apiModels.length > 0) { + models = mergeModels(models, apiModels) + } + } catch { + // Graceful fallback to bundled models + } + const modelsObj: Record = {} for (const entry of models) { const shortKey = toConfigKey(entry.id) const fullKey = entry.id.toLowerCase() const hyphenKey = entry.id.toLowerCase().replace(/\//g, "-") - const costObj: Record = { - input: entry.cost.input, - output: entry.cost.output, - cache: entry.cost.cache ?? { - read: entry.cost.cache_read ?? 0, - write: entry.cost.cache_write ?? 0, - }, - } - - const modelDef: Record = { - id: entry.id, - name: entry.name, - reasoning: entry.reasoning, - tool_call: entry.tool_call, - capabilities: entry.capabilities, - variants: entry.variants, - cost: costObj, - limit: entry.limit, - } + const modelDef = toV1ModelConfig(entry) modelsObj[shortKey] = modelDef modelsObj[fullKey] = modelDef diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..ba3c563 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,171 @@ +import { readdirSync, statSync, readFileSync, existsSync } from "fs" +import { join, relative } from "path" +import { execSync } from "child_process" + +export interface GitContext { + isGitRepo: boolean + currentBranch: string + mainBranch: string + gitStatus: string + recentCommits: string[] +} + +export interface ProjectContext { + structure: string[] + git: GitContext +} + +const DEFAULT_SKIP = new Set([ + "node_modules", + ".git", + "dist", + "build", + ".next", + ".turbo", + "coverage", + ".nyc_output", + "tmp", + "temp", + ".cache", + ".DS_Store", + "__pycache__", + ".venv", + "venv", + ".idea", + ".vscode", + "target", + "out", +]) + +const MAX_FILES = 200 +const MAX_DEPTH = 5 +const CACHE_TTL_MS = 30000 // 30s cache TTL to prevent I/O thrashing + +let cachedContext: ProjectContext | null = null +let lastGatheredTime = 0 + +export function parseGitignore(rootDir: string): Set { + const p = join(rootDir, ".gitignore") + if (!existsSync(p)) return new Set() + try { + const patterns = new Set() + for (const line of readFileSync(p, "utf-8").split("\n")) { + const t = line.trim() + if (t && !t.startsWith("#")) patterns.add(t) + } + return patterns + } catch { + return new Set() + } +} + +export function shouldSkip(name: string, relPath: string, gitignore: Set): boolean { + if (DEFAULT_SKIP.has(name)) return true + for (const pat of gitignore) { + const p = pat.endsWith("/") ? pat.slice(0, -1) : pat + if (p === relPath || relPath.startsWith(p + "/") || p === name) return true + if (pat.includes("*")) { + const re = new RegExp("^" + pat.replace(/\./g, "\\.").replace(/\*/g, "[^/]*") + "$") + if (re.test(name)) return true + } + } + return false +} + +export function gatherStructure(rootDir: string): string[] { + const entries: string[] = [] + const gitignore = parseGitignore(rootDir) + + function walk(dir: string, depth: number) { + if (entries.length >= MAX_FILES || depth > MAX_DEPTH) return + + let items: string[] + try { + items = readdirSync(dir) + } catch { + return + } + + for (const name of items.sort()) { + if (entries.length >= MAX_FILES) break + const fullPath = join(dir, name) + const relPath = relative(rootDir, fullPath) + if (shouldSkip(name, relPath, gitignore)) continue + + let st + try { + st = statSync(fullPath) + } catch { + continue + } + + if (st.isDirectory()) { + entries.push(relPath + "/") + walk(fullPath, depth + 1) + } else if (st.isFile()) { + entries.push(relPath) + } + } + } + + walk(rootDir, 0) + return entries +} + +function runGit(cmd: string, cwd: string): string { + try { + return execSync(cmd, { cwd, encoding: "utf-8", stdio: "pipe", maxBuffer: 1024 * 1024 }).trim() + } catch { + return "" + } +} + +export function gatherGit(rootDir: string): GitContext { + try { + execSync("git rev-parse --is-inside-work-tree", { cwd: rootDir, stdio: "pipe" }) + } catch { + return { isGitRepo: false, currentBranch: "", mainBranch: "", gitStatus: "", recentCommits: [] } + } + + const currentBranch = runGit("git branch --show-current", rootDir) + + let mainBranch = "" + for (const cand of ["main", "master"]) { + try { + execSync(`git rev-parse --verify ${cand}`, { cwd: rootDir, stdio: "pipe" }) + mainBranch = cand + break + } catch { + /* not found */ + } + } + + const gitStatus = runGit("git status --short", rootDir) + + let recentCommits: string[] = [] + const log = runGit('git log --oneline -10 --format="%h %s (%an, %ad)" --date=short', rootDir) + if (log) { + recentCommits = log.split("\n") + } + + return { isGitRepo: true, currentBranch, mainBranch, gitStatus, recentCommits } +} + +export function gatherContext(rootDir: string = process.cwd()): ProjectContext { + const now = Date.now() + if (cachedContext && now - lastGatheredTime < CACHE_TTL_MS) { + return cachedContext + } + + cachedContext = { + structure: gatherStructure(rootDir), + git: gatherGit(rootDir), + } + lastGatheredTime = now + return cachedContext +} + +export function clearContextCache(): void { + cachedContext = null + lastGatheredTime = 0 +} diff --git a/src/convert.ts b/src/convert.ts index aa4e3e6..3c3bb7a 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -8,6 +8,7 @@ import type { LanguageModelV3ToolResultPart, LanguageModelV3ToolResultOutput, } from "@ai-sdk/provider" +import type { ProjectContext } from "./context.js" type CCMessage = | { role: "user"; content: string | unknown[] } @@ -59,6 +60,8 @@ interface CCRequestEnvelope { temperature?: number top_p?: number top_k?: number + thinking?: unknown + reasoning_effort?: string } } @@ -174,6 +177,7 @@ function convertTools( export function buildRequest( modelId: string, options: LanguageModelV3CallOptions, + context?: ProjectContext, ): CCRequestEnvelope { let systemPrompt = "" const messages: CCMessage[] = [] @@ -200,21 +204,30 @@ export function buildRequest( if (options.topP !== undefined) params.top_p = options.topP if (options.topK !== undefined) params.top_k = options.topK + const opt = options as Record + const providerOpts = + (opt.providerMetadata as Record> | undefined)?.commandcode ?? + (opt.providerOptions as Record> | undefined)?.commandcode + + if (providerOpts?.thinking) { + params.thinking = providerOpts.thinking + } else if (typeof opt.reasoningEffort === "string") { + params.reasoning_effort = opt.reasoningEffort + } + return { config: { workingDir: process.cwd() ?? "/", date: new Date().toISOString().split("T")[0] ?? "", environment: `${process.platform}-${process.arch}`, - // Stub: opencode does not expose project structure context - structure: [], - isGitRepo: false, - currentBranch: "", - mainBranch: "", - gitStatus: "", - recentCommits: [], + structure: context?.structure ?? [], + isGitRepo: context?.git.isGitRepo ?? false, + currentBranch: context?.git.currentBranch ?? "", + mainBranch: context?.git.mainBranch ?? "", + gitStatus: context?.git.gitStatus ?? "", + recentCommits: context?.git.recentCommits ?? [], }, memory: "", - // Stub: taste/memory/permissionMode are Command Code CLI features not exposed via provider API taste: "", skills: null, permissionMode: "standard", diff --git a/src/model.ts b/src/model.ts index a0f582e..43b60c3 100644 --- a/src/model.ts +++ b/src/model.ts @@ -9,6 +9,7 @@ import type { LanguageModelV3StreamPart, } from "@ai-sdk/provider" import { buildRequest } from "./convert.js" +import { gatherContext } from "./context.js" import { parseStreamEvents } from "./stream.js" const DEFAULT_BASE_URL = "https://api.commandcode.ai" @@ -121,7 +122,7 @@ function isRetryableStatus(status: number): boolean { } function backoffDelay(attempt: number): number { - const base = BACKOFF_SCHEDULE_MS[Math.min(attempt, BACKOFF_SCHEDULE_MS.length - 1)] + const base = BACKOFF_SCHEDULE_MS[Math.min(attempt, BACKOFF_SCHEDULE_MS.length - 1)] ?? 5000 // jitter ±25% to avoid synchronized retry storms return base * (0.75 + Math.random() * 0.5) } @@ -415,7 +416,8 @@ export class CommandCodeLanguageModel implements LanguageModelV3 { } async doStream(options: LanguageModelV3CallOptions): Promise { - const body = buildRequest(this.modelId, options) + const context = gatherContext() + const body = buildRequest(this.modelId, options, context) const requestBody = JSON.stringify(body) const controller = new AbortController() diff --git a/src/models.ts b/src/models.ts new file mode 100644 index 0000000..d760347 --- /dev/null +++ b/src/models.ts @@ -0,0 +1,216 @@ +import { readFileSync, existsSync } from "fs" +import { homedir } from "os" +import { join, dirname } from "path" +import { fileURLToPath } from "url" + +const __dirname = dirname(fileURLToPath(import.meta.url)) + +export interface ModelEntry { + id: string + name: string + tier: "premium" | "open-source" + reasoning: boolean + tool_call: boolean + capabilities?: { + vision?: boolean + pdf?: boolean + reasoning?: boolean + tool_call?: boolean + [key: string]: unknown + } + variants?: Record + cost: { + input: number + output: number + cache_read?: number + cache_write?: number + cache?: { read: number; write: number } + } + limit: { context: number; output: number } +} + +export interface ApiModel { + id: string + name?: string + context_length?: number +} + +export function loadPluginConfig(): { disableModelSync?: boolean } { + const configPath = join(homedir(), ".config", "opencode", "commandcode-go-opencode-provider.json") + if (!existsSync(configPath)) return {} + try { + return JSON.parse(readFileSync(configPath, "utf-8")) + } catch { + return {} + } +} + +export function loadModels(): ModelEntry[] { + const modelsPath = join(__dirname, "..", "models.json") + try { + return JSON.parse(readFileSync(modelsPath, "utf-8")) + } catch (err) { + throw new Error( + `Bundled models.json missing or corrupt at ${modelsPath}; ` + + `please reinstall commandcode-go-opencode-provider.`, + { cause: err }, + ) + } +} + +export async function fetchModelsFromApi( + apiKey?: string, + fetchFn: typeof fetch = fetch, +): Promise { + if (loadPluginConfig().disableModelSync) return null + + const key = apiKey || process.env.COMMANDCODE_API_KEY + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 5000) + + try { + const headers: Record = {} + if (key) { + headers["Authorization"] = `Bearer ${key}` + } + + const resp = await fetchFn("https://api.commandcode.ai/provider/v1/models", { + headers, + signal: controller.signal, + }) + + if (!resp.ok) return null + const data = (await resp.json()) as { data?: ApiModel[] } + return data.data ?? null + } catch { + return null + } finally { + clearTimeout(timeout) + } +} + +export function mergeModels(local: ModelEntry[], api: ApiModel[]): ModelEntry[] { + const apiMap = new Map(api.map((m) => [m.id, m])) + const merged: ModelEntry[] = [] + + for (const entry of local) { + const apiModel = apiMap.get(entry.id) + if (apiModel?.context_length) { + merged.push({ + ...entry, + limit: { ...entry.limit, context: apiModel.context_length }, + }) + } else { + merged.push(entry) + } + apiMap.delete(entry.id) + } + + for (const [id, apiModel] of apiMap) { + const isReasoning = + /reasoning|r1|thinking|kimi-k|glm-5|sol|terra|luna/i.test(id) || + /reasoning|r1|thinking/i.test(apiModel.name ?? "") + const isVision = /vision|gemini|claude|gpt|grok/i.test(id) + + merged.push({ + id, + name: apiModel.name ?? id.split("/").pop() ?? id, + tier: id.includes("/") ? "open-source" : "premium", + reasoning: isReasoning, + tool_call: true, + capabilities: { + vision: isVision, + pdf: /claude|gemini|gpt/i.test(id), + reasoning: isReasoning, + tool_call: true, + }, + cost: { input: 0, output: 0 }, + limit: { context: apiModel.context_length ?? 131072, output: 131072 }, + }) + } + + return merged +} + +export function toConfigKey(id: string): string { + const slashIdx = id.indexOf("/") + const short = slashIdx >= 0 ? id.slice(slashIdx + 1) : id + return short.toLowerCase() +} + +export interface V1ModelConfig { + id: string + name: string + reasoning: boolean + tool_call: boolean + capabilities?: Record + variants?: Record + cost: Record + limit: { context: number; output: number } +} + +export function toV1ModelConfig(entry: ModelEntry): V1ModelConfig { + const cost: Record = { + input: entry.cost.input, + output: entry.cost.output, + cache: entry.cost.cache ?? { + read: entry.cost.cache_read ?? 0, + write: entry.cost.cache_write ?? 0, + }, + } + return { + id: entry.id, + name: entry.name, + reasoning: entry.reasoning, + tool_call: entry.tool_call, + capabilities: entry.capabilities, + variants: entry.variants, + cost, + limit: entry.limit, + } +} + +export interface V2ModelDraft { + modelID: string + name: string + capabilities: { tools: boolean; input: string[]; output: string[] } + limit: { context: number; output: number } + cost: { + input: number + output: number + cache?: { read: number; write: number } + } +} + +export function toV2ModelDraft(entry: ModelEntry): V2ModelDraft { + const inputTypes = ["text"] + if (entry.capabilities?.vision) inputTypes.push("image") + if (entry.capabilities?.pdf) inputTypes.push("pdf") + + const draft: V2ModelDraft = { + modelID: entry.id, + name: entry.name, + capabilities: { + tools: entry.tool_call, + input: inputTypes, + output: ["text"], + }, + limit: entry.limit, + cost: { input: entry.cost.input, output: entry.cost.output }, + } + if (entry.cost.cache_read !== undefined || entry.cost.cache !== undefined) { + draft.cost.cache = entry.cost.cache ?? { + read: entry.cost.cache_read ?? 0, + write: entry.cost.cache_write ?? 0, + } + } + return draft +} + +export function toV2ModelMap(entries: ModelEntry[]): Record { + const map: Record = {} + for (const entry of entries) { + map[toConfigKey(entry.id)] = toV2ModelDraft(entry) + } + return map +} diff --git a/tests/unit/context.test.ts b/tests/unit/context.test.ts new file mode 100644 index 0000000..57bcc04 --- /dev/null +++ b/tests/unit/context.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test" +import { + gatherContext, + gatherStructure, + parseGitignore, + shouldSkip, + clearContextCache, +} from "../../src/context.js" + +test("parseGitignore extracts clean pattern set", () => { + const patterns = parseGitignore(process.cwd()) + expect(patterns instanceof Set).toBe(true) +}) + +test("shouldSkip identifies default directories and pattern matches", () => { + const patterns = new Set(["*.log", "secret/"]) + expect(shouldSkip("node_modules", "node_modules", patterns)).toBe(true) + expect(shouldSkip(".git", ".git", patterns)).toBe(true) + expect(shouldSkip("dist", "dist", patterns)).toBe(true) + expect(shouldSkip("debug.log", "debug.log", patterns)).toBe(true) + expect(shouldSkip("secret", "secret", patterns)).toBe(true) + expect(shouldSkip("main.ts", "src/main.ts", patterns)).toBe(false) +}) + +test("gatherStructure collects files and respects boundaries", () => { + const structure = gatherStructure(process.cwd()) + expect(Array.isArray(structure)).toBe(true) + expect(structure.length).toBeGreaterThan(0) + // Shouldn't contain node_modules or .git files + expect(structure.some((p) => p.startsWith("node_modules"))).toBe(false) + expect(structure.some((p) => p.startsWith(".git/"))).toBe(false) +}) + +test("gatherContext returns structure and git metadata with caching", () => { + clearContextCache() + const ctx1 = gatherContext(process.cwd()) + expect(ctx1.structure).toBeDefined() + expect(ctx1.git).toBeDefined() + expect(typeof ctx1.git.isGitRepo).toBe("boolean") + + // Second immediate call should return the exact cached object reference + const ctx2 = gatherContext(process.cwd()) + expect(ctx2).toBe(ctx1) + + // Clearing cache allows fresh generation + clearContextCache() + const ctx3 = gatherContext(process.cwd()) + expect(ctx3).not.toBe(ctx1) +}) diff --git a/tests/unit/convert.test.ts b/tests/unit/convert.test.ts index 18ab381..d3f1afc 100644 --- a/tests/unit/convert.test.ts +++ b/tests/unit/convert.test.ts @@ -225,3 +225,36 @@ test("envelope has correct top-level shape", () => { expect(req).toHaveProperty("permissionMode", "standard") expect(req).toHaveProperty("params") }) + +test("passes through project context structure and git state", () => { + const req = buildRequest("m", makeOpts(), { + structure: ["src/", "src/index.ts"], + git: { + isGitRepo: true, + currentBranch: "feature-branch", + mainBranch: "main", + gitStatus: "M src/index.ts", + recentCommits: ["abc1234 initial"], + }, + }) + expect(req.config.structure).toEqual(["src/", "src/index.ts"]) + expect(req.config.isGitRepo).toBe(true) + expect(req.config.currentBranch).toBe("feature-branch") + expect(req.config.gitStatus).toBe("M src/index.ts") +}) + +test("passes through thinking and reasoning_effort options", () => { + const req1 = buildRequest("m", makeOpts({ + providerMetadata: { + commandcode: { + thinking: { type: "enabled", budget_tokens: 4096 }, + }, + }, + } as any)) + expect(req1.params.thinking).toEqual({ type: "enabled", budget_tokens: 4096 }) + + const req2 = buildRequest("m", makeOpts({ + reasoningEffort: "high", + } as any)) + expect(req2.params.reasoning_effort).toBe("high") +}) diff --git a/tests/unit/discovery.test.ts b/tests/unit/discovery.test.ts new file mode 100644 index 0000000..790223a --- /dev/null +++ b/tests/unit/discovery.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "bun:test" +import { + fetchModelsFromApi, + mergeModels, + type ModelEntry, + type ApiModel, +} from "../../src/models.js" + +const baseModel: ModelEntry = { + id: "test/local-model", + name: "Local Model", + tier: "open-source", + reasoning: false, + tool_call: true, + cost: { input: 1, output: 2 }, + limit: { context: 100000, output: 4096 }, +} + +test("mergeModels updates context limit for existing models", () => { + const apiModels: ApiModel[] = [{ id: "test/local-model", context_length: 500000 }] + const merged = mergeModels([baseModel], apiModels) + + expect(merged).toHaveLength(1) + expect(merged[0].limit.context).toBe(500000) + expect(merged[0].name).toBe("Local Model") + expect(merged[0].cost.input).toBe(1) +}) + +test("mergeModels dynamically adds newly discovered API models", () => { + const apiModels: ApiModel[] = [ + { id: "test/local-model" }, + { id: "new-vendor/future-reasoning-model", name: "Future Reasoning Model", context_length: 2000000 }, + ] + const merged = mergeModels([baseModel], apiModels) + + expect(merged).toHaveLength(2) + const newModel = merged.find((m) => m.id === "new-vendor/future-reasoning-model") + expect(newModel).toBeDefined() + expect(newModel?.name).toBe("Future Reasoning Model") + expect(newModel?.tier).toBe("open-source") + expect(newModel?.reasoning).toBe(true) + expect(newModel?.limit.context).toBe(2000000) + expect(newModel?.tool_call).toBe(true) +}) + +test("fetchModelsFromApi returns null when API request fails or throws", async () => { + const mockFailingFetch = async () => { + throw new Error("Network down") + } + + const result = await fetchModelsFromApi(undefined, mockFailingFetch as unknown as typeof fetch) + expect(result).toBeNull() +}) + +test("fetchModelsFromApi parses API models and sends authorization header if present", async () => { + let capturedHeaders: Record = {} + const mockFetch = async (_url: string | URL | Request, init?: RequestInit) => { + capturedHeaders = (init?.headers ?? {}) as Record + return { + ok: true, + json: async () => ({ + data: [{ id: "test/api-model", context_length: 128000 }], + }), + } as unknown as Response + } + + const result = await fetchModelsFromApi("sk-my-key", mockFetch as unknown as typeof fetch) + expect(result).toEqual([{ id: "test/api-model", context_length: 128000 }]) + expect(capturedHeaders["Authorization"]).toBe("Bearer sk-my-key") +}) + +test("fetchModelsFromApi works without API key (public endpoint)", async () => { + let capturedHeaders: Record = {} + const originalEnv = process.env.COMMANDCODE_API_KEY + delete process.env.COMMANDCODE_API_KEY + + try { + const mockFetch = async (_url: string | URL | Request, init?: RequestInit) => { + capturedHeaders = (init?.headers ?? {}) as Record + return { + ok: true, + json: async () => ({ + data: [{ id: "test/public-model", context_length: 64000 }], + }), + } as unknown as Response + } + + const result = await fetchModelsFromApi(undefined, mockFetch as unknown as typeof fetch) + expect(result).toEqual([{ id: "test/public-model", context_length: 64000 }]) + expect(capturedHeaders["Authorization"]).toBeUndefined() + } finally { + if (originalEnv) process.env.COMMANDCODE_API_KEY = originalEnv + } +}) diff --git a/tests/unit/models.test.ts b/tests/unit/models.test.ts new file mode 100644 index 0000000..a4b6696 --- /dev/null +++ b/tests/unit/models.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test" +import { + loadModels, + toConfigKey, + toV1ModelConfig, + toV2ModelDraft, + toV2ModelMap, + type ModelEntry, +} from "../../src/models.js" + +const sampleEntry: ModelEntry = { + id: "deepseek/deepseek-v4-flash", + name: "DeepSeek V4 Flash", + tier: "open-source", + reasoning: true, + tool_call: true, + capabilities: { + vision: true, + pdf: false, + reasoning: true, + tool_call: true, + }, + variants: { fast: "deepseek/deepseek-v4-flash-fast" }, + cost: { input: 0.14, output: 0.28, cache_read: 0.014, cache_write: 0.14 }, + limit: { context: 1000000, output: 131072 }, +} + +test("loadModels loads the bundled catalog with at least 50 models", () => { + const models = loadModels() + expect(Array.isArray(models)).toBe(true) + expect(models.length).toBeGreaterThanOrEqual(50) + for (const m of models) { + expect(m.id).toBeDefined() + expect(m.name).toBeDefined() + expect(m.limit.context).toBeGreaterThan(0) + } +}) + +test("toConfigKey strips provider prefix and lowercases", () => { + expect(toConfigKey("deepseek/deepseek-v4-flash")).toBe("deepseek-v4-flash") + expect(toConfigKey("Qwen/Qwen3.7-Flash")).toBe("qwen3.7-flash") + expect(toConfigKey("claude-sonnet-5")).toBe("claude-sonnet-5") + expect(toConfigKey("META/MUSE-SPARK-1.1")).toBe("muse-spark-1.1") +}) + +test("toV1ModelConfig converts model to V1 format preserving capabilities and cost", () => { + const v1 = toV1ModelConfig(sampleEntry) + expect(v1.id).toBe("deepseek/deepseek-v4-flash") + expect(v1.name).toBe("DeepSeek V4 Flash") + expect(v1.reasoning).toBe(true) + expect(v1.tool_call).toBe(true) + expect(v1.capabilities?.vision).toBe(true) + expect(v1.variants?.fast).toBe("deepseek/deepseek-v4-flash-fast") + expect(v1.cost.input).toBe(0.14) + expect(v1.cost.output).toBe(0.28) + expect(v1.limit.context).toBe(1000000) +}) + +test("toV2ModelDraft converts to OpenCode 2 shape including vision/pdf capabilities", () => { + const v2 = toV2ModelDraft(sampleEntry) + expect(v2.modelID).toBe("deepseek/deepseek-v4-flash") + expect(v2.name).toBe("DeepSeek V4 Flash") + expect(v2.capabilities.tools).toBe(true) + expect(v2.capabilities.input).toContain("text") + expect(v2.capabilities.input).toContain("image") + expect(v2.capabilities.output).toContain("text") + expect(v2.limit.context).toBe(1000000) + expect(v2.cost.input).toBe(0.14) +}) + +test("toV2ModelMap indexes drafts by config key", () => { + const map = toV2ModelMap([sampleEntry]) + expect(map["deepseek-v4-flash"]).toBeDefined() + expect(map["deepseek-v4-flash"].modelID).toBe("deepseek/deepseek-v4-flash") +}) diff --git a/tests/unit/v2.test.ts b/tests/unit/v2.test.ts new file mode 100644 index 0000000..b501af1 --- /dev/null +++ b/tests/unit/v2.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "bun:test" +import v2Plugin, { id } from "../../v2.ts" + +test("v2 export has correct id and setup function", () => { + expect(id).toBe("commandcode-go-opencode-provider") + expect(v2Plugin.id).toBe("commandcode-go-opencode-provider") + expect(typeof v2Plugin.setup).toBe("function") +}) + +test("v2 setup registers provider and model catalog via transform", async () => { + const providerUpdates: Record> = {} + const modelUpdates: Record> = {} + + const mockContext = { + catalog: { + transform: async (fn: (catalog: any) => void | Promise) => { + const catalogDraft = { + provider: { + update: (providerID: string, updateFn: (draft: Record) => void) => { + const draft: Record = {} + updateFn(draft) + providerUpdates[providerID] = draft + }, + }, + model: { + update: (providerID: string, modelKey: string, updateFn: (draft: Record) => void) => { + const draft: Record = {} + updateFn(draft) + modelUpdates[`${providerID}:${modelKey}`] = draft + }, + }, + } + await fn(catalogDraft) + }, + }, + } + + await v2Plugin.setup(mockContext as any) + + expect(providerUpdates["commandcode"]).toBeDefined() + expect(providerUpdates["commandcode"].name).toBe("Command Code") + expect(providerUpdates["commandcode"].package).toBe("aisdk:commandcode-go-opencode-provider") + + expect(Object.keys(modelUpdates).length).toBeGreaterThanOrEqual(50) + const sampleModel = modelUpdates["commandcode:deepseek-v4-flash"] + expect(sampleModel).toBeDefined() + expect(sampleModel.modelID).toBe("deepseek/deepseek-v4-flash") + expect(sampleModel.capabilities).toBeDefined() + expect(sampleModel.limit).toBeDefined() +}) diff --git a/tsconfig.json b/tsconfig.json index 2099597..516b274 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,6 @@ "outDir": "dist", "declaration": true }, - "include": ["index.ts", "plugin.ts", "src/**/*.ts"], + "include": ["index.ts", "plugin.ts", "v2.ts", "src/**/*.ts"], "exclude": ["scripts/", "tests/"] } diff --git a/v2.ts b/v2.ts new file mode 100644 index 0000000..c571aed --- /dev/null +++ b/v2.ts @@ -0,0 +1,82 @@ +import { + loadModels, + fetchModelsFromApi, + mergeModels, + toConfigKey, + toV2ModelDraft, +} from "./src/models.js" + +export const id = "commandcode-go-opencode-provider" + +export interface CatalogDraft { + provider: { + update(providerID: string, update: (draft: Record) => void): void + } + model: { + update( + providerID: string, + modelID: string, + update: (draft: Record) => void, + ): void + } +} + +export interface V2PluginContext { + catalog: { + transform( + transform: (catalog: CatalogDraft) => void | Promise, + ): Promise + } +} + +/** + * OpenCode V2 plugin entrypoint. + * + * V2 loads a plugin from the module's default export, which must be an object + * with a unique `id` and a `setup` function. `setup` registers the Command + * Code provider and its model catalog through `ctx.catalog.transform`: + * + * ```json + * { + * "plugins": ["commandcode-go-opencode-provider/v2"] + * } + * ``` + */ +export default { + id, + setup: async (ctx: V2PluginContext): Promise => { + let models = loadModels() + try { + const apiModels = await fetchModelsFromApi() + if (apiModels && apiModels.length > 0) { + models = mergeModels(models, apiModels) + } + } catch { + // Graceful fallback to bundled models + } + + await ctx.catalog.transform((catalog) => { + catalog.provider.update("commandcode", (draft) => { + draft.name = "Command Code" + draft.package = "aisdk:commandcode-go-opencode-provider" + draft.settings = { + baseURL: "https://api.commandcode.ai", + ...(typeof draft.settings === "object" && draft.settings !== null + ? (draft.settings as Record) + : {}), + } + }) + + for (const entry of models) { + catalog.model.update("commandcode", toConfigKey(entry.id), (draft) => { + const model = toV2ModelDraft(entry) + draft.modelID = model.modelID + draft.name = model.name + draft.capabilities = model.capabilities + draft.limit = model.limit + draft.cost = model.cost + }) + } + }) + }, +} From c42a0ea07d290fab9fb0310d83b690c317dc47d6 Mon Sep 17 00:00:00 2001 From: danielxxomg <150471793+danielxxomg@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:09:20 -0500 Subject: [PATCH 7/7] docs: polish README with architecture diagram, badges, and comparison table --- README.md | 44 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8b8d6a4..e314621 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,47 @@ # commandcode-go-opencode-provider -[Command Code](https://commandcode.ai) API provider for [opencode](https://opencode.ai). Use Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, Step, and other models through a single API key. +[![Version](https://img.shields.io/badge/release-v0.5.0-blue.svg)](https://github.com/danielxxomg/opencode-commandcode-provider/releases/tag/v0.5.0) +[![Tests](https://img.shields.io/badge/tests-106%20passed-brightgreen.svg)]() +[![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue.svg)]() +[![OpenCode](https://img.shields.io/badge/OpenCode-v1%20%26%20v2-orange.svg)]() +[![License](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE) -> **This fork** adds production-grade retry/backoff, SSE error propagation, and -> key rotation on top of the [original by @brent-weatherall](https://github.com/brent-weatherall/opencode-commandcode-provider). +[Command Code](https://commandcode.ai) API provider for [opencode](https://opencode.ai). Access Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, and 67+ models through a single API key with real-time model discovery and robust error recovery. + +> **Maintained Fork**: This repository is the actively maintained fork of [`brent-weatherall/opencode-commandcode-provider`](https://github.com/brent-weatherall/opencode-commandcode-provider), adding OpenCode 2 support, runtime auto-discovery, Plan Mode context gathering, and production-grade retry resilience. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ OpenCode Client (v1 / v2) │ +└──────────────┬──────────────────────────────┬───────────────┘ + │ (1) Startup Discovery │ (2) Streaming Request + ▼ ▼ +┌──────────────────────────────┐ ┌──────────────────────────┐ +│ Auto-Discovery Engine │ │ Execution Layer │ +│ - GET /provider/v1/models │ │ - Project tree & Git ctx│ +│ - Bundled 67-model fallback │ │ - Retry with jitter │ +│ - Zero credentials needed │ │ - SSE error propagation │ +└──────────────┬───────────────┘ └──────────┬───────────────┘ + │ │ + └──────────────┬───────────────┘ + ▼ + ┌──────────────────────────────┐ + │ Command Code Cloud API │ + └──────────────────────────────┘ +``` + +## Feature Comparison + +| Feature | Upstream (Abandoned) | danielxxomg Fork (v0.5.0) | +|---|---|---| +| **Model Count** | 21 static models | **67 verified models** | +| **Model Discovery** | Manual compile | **Real-time API auto-discovery** at startup | +| **OpenCode 2 Support** | ❌ Broken (`SchemaError`) | ✅ **Native `./v2` entrypoint** | +| **Plan Mode Context** | ❌ Empty stubs | ✅ **File tree + Git context** with 30s cache | +| **Retry & Backoff** | ❌ Naive exponential | ✅ **Fixed schedule with jitter & classification** | +| **Stream Termination** | ❌ Silent SSE errors | ✅ **Clean `controller.error()` termination** | +| **Reasoning Effort** | ❌ Unsupported | ✅ **Configurable thinking levels (`low` to `max`)** | +| **Test Suite** | 43 tests | **106 unit tests (100% passing)** | ## What this fork adds (v0.5.0)