diff --git a/index.ts b/index.ts index 56ca606..ab67747 100644 --- a/index.ts +++ b/index.ts @@ -13,50 +13,42 @@ */ import { AssistantMessageEventStream } from "@earendil-works/pi-ai" -import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent" +import { + getAgentDir, + type ExtensionAPI, + type ExtensionCommandContext, + type ProviderConfig, +} from "@earendil-works/pi-coding-agent" import { join } from "node:path" import { getApiKey as getStoredApiKey } from "./src/converters.ts" import { COMMAND_CODE_CLI_VERSION, createStreamCommandCode, DEFAULT_API_BASE } from "./src/core.ts" import { calculateCommandCodeCost } from "./src/cost.ts" -import { DEFAULT_MODELS_URL, loadCommandCodeModels } from "./src/models.ts" +import { + DEFAULT_MODELS_URL, + getModelsTimeoutMs, + loadCommandCodeModels, + thinkingMetadataForModel, + type CommandCodeModel, +} from "./src/models.ts" import { getApiKey as getOAuthApiKey, login, refreshToken } from "./src/oauth.ts" import { MODEL_COSTS, ZERO_MODEL_COST } from "./src/pricing.ts" +import { createCommandCodeRuntime } from "./src/runtime.ts" -const API_BASE = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE -const MODELS_URL = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL -const MODELS_CACHE_PATH = - process.env.COMMANDCODE_MODELS_CACHE ?? join(getAgentDir(), "commandcode-models.json") - -const streamCommandCode = createStreamCommandCode({ - createStream: () => new AssistantMessageEventStream(), - calculateCost: calculateCommandCodeCost, - apiBase: API_BASE, -}) - -// --------------------------------------------------------------------------- -// Extension entry point -// --------------------------------------------------------------------------- - -export default async function (pi: ExtensionAPI) { - const storedApiKey = getStoredApiKey() - const { models, warning } = await loadCommandCodeModels({ - url: MODELS_URL, - cachePath: MODELS_CACHE_PATH, - }) - - if (warning) console.warn(`[commandcode] ${warning}`) - - pi.registerProvider("commandcode", { +function createProviderConfig( + models: readonly CommandCodeModel[], + apiBase: string, + streamCommandCode: ProviderConfig["streamSimple"], +): ProviderConfig { + return { name: "Command Code", - baseUrl: API_BASE, - apiKey: storedApiKey, + baseUrl: apiBase, + apiKey: "$COMMANDCODE_API_KEY", authHeader: true, api: "commandcode-custom", streamSimple: streamCommandCode, headers: { - "x-command-code-version": COMMAND_CODE_CLI_VERSION, - "x-cli-environment": "production", + "x-commandcode-version": COMMAND_CODE_CLI_VERSION, }, oauth: { name: "Command Code", @@ -68,10 +60,38 @@ export default async function (pi: ExtensionAPI) { id: model.id, name: model.name, reasoning: model.reasoning, + ...(thinkingMetadataForModel(model.id) ?? {}), input: ["text"] as const, cost: MODEL_COSTS[model.id] ?? ZERO_MODEL_COST, contextWindow: model.contextWindow, maxTokens: model.maxTokens, })), + } +} + +export default async function (pi: ExtensionAPI) { + const apiBase = process.env.COMMANDCODE_API_BASE ?? DEFAULT_API_BASE + const modelsUrl = process.env.COMMANDCODE_MODELS_URL ?? DEFAULT_MODELS_URL + const modelsTimeoutMs = getModelsTimeoutMs() + const modelsCachePath = + process.env.COMMANDCODE_MODELS_CACHE ?? join(getAgentDir(), "commandcode-models.json") + const streamCommandCode = createStreamCommandCode({ + createStream: () => new AssistantMessageEventStream(), + calculateCost: calculateCommandCodeCost, + apiBase, }) + + const runtime = createCommandCodeRuntime(pi, { + endpoint: modelsUrl, + cachePath: modelsCachePath, + loadModels: () => + loadCommandCodeModels({ + url: modelsUrl, + cachePath: modelsCachePath, + timeoutMs: modelsTimeoutMs, + }), + createProviderConfig: (models) => createProviderConfig(models, apiBase, streamCommandCode), + }) + + await runtime.initialize() } diff --git a/src/core.ts b/src/core.ts index e696c86..43c0eaf 100644 --- a/src/core.ts +++ b/src/core.ts @@ -134,6 +134,15 @@ function generateMaxTokens(model: ModelLike, options?: StreamOptions): number { ) } +function mappedReasoningEffort(model: ModelLike, options?: StreamOptions): string | undefined { + const level = options?.reasoning + if (!level || level === "off" || !model.reasoning) return undefined + + const effortMap = model.thinking?.effortMap ?? model.thinkingLevelMap + const mapped = effortMap?.[level] + return typeof mapped === "string" && mapped !== "off" ? mapped : undefined +} + export function projectSlugFromPath(pathName: string): string { const slug = pathName .toLowerCase() @@ -424,6 +433,7 @@ export function createStreamCommandCode(deps: CoreDependencies) { const workingDir = cwd() const threadId = uuid() + const reasoningEffort = mappedReasoningEffort(model, options) let body: unknown = { config: { @@ -448,6 +458,7 @@ export function createStreamCommandCode(deps: CoreDependencies) { max_tokens: generateMaxTokens(model, options), temperature: 0.3, stream: true, + ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}), }, threadId, } diff --git a/src/models.ts b/src/models.ts index 604cf7c..9f4c375 100644 --- a/src/models.ts +++ b/src/models.ts @@ -2,10 +2,97 @@ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises" import { dirname } from "node:path" export const DEFAULT_MODELS_URL = "https://api.commandcode.ai/provider/v1/models" +export const DEFAULT_MODELS_TIMEOUT_MS = 10_000 const DEFAULT_MAX_OUTPUT_TOKENS = 65_536 const MODEL_CACHE_VERSION = 1 +export type PiThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" + +type CommandCodeReasoningEffort = Exclude + +/** + * Per-model reasoning efforts supported by Command Code's generate endpoint. + * + * The Provider API does not expose reasoning metadata. This is an exact + * snapshot of `reasoningEfforts` from the command-code@1.14.1 model catalog + * (`packages/shared/src/model-catalog.ts`, also published in the generated + * `dist/bundled/command-code-knowledge/reference/models.md`). Models omitted + * here let Command Code choose their reasoning depth, matching the CLI. + */ +export const MODEL_EFFORTS: Readonly> = { + "Qwen/Qwen3.8-Max": ["low", "medium", "xhigh"], + "claude-fable-5": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-5": ["low", "medium", "high", "xhigh", "max"], + "claude-sonnet-4-6": ["low", "medium", "high", "xhigh", "max"], + "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"], + "deepseek/deepseek-v4-flash": ["high", "max"], + "deepseek/deepseek-v4-pro": ["high", "max"], + "gpt-5.3-codex": ["low", "medium", "high", "xhigh"], + "gpt-5.4": ["low", "medium", "high", "xhigh"], + "gpt-5.4-mini": ["low", "medium", "high"], + "gpt-5.5": ["low", "medium", "high", "xhigh"], + "gpt-5.6-luna": ["low", "medium", "high", "xhigh", "max"], + "gpt-5.6-sol": ["low", "medium", "high", "xhigh", "max"], + "gpt-5.6-terra": ["low", "medium", "high", "xhigh", "max"], + "google/gemini-3.1-flash-lite": ["low", "medium", "high"], + "google/gemini-3.5-flash": ["low", "medium", "high"], + "google/gemini-3.5-flash-lite": ["low", "medium", "high"], + "google/gemini-3.6-flash": ["low", "medium", "high"], + "sakana/fugu-ultra": ["high", "xhigh"], + "xai/grok-4.5": ["low", "medium", "high"], + "zai-org/GLM-5.2": ["high", "max"], +} + +const PI_THINKING_LEVELS: readonly PiThinkingLevel[] = [ + "off", + "minimal", + "low", + "medium", + "high", + "xhigh", + "max", +] + +export function thinkingLevelMapForEfforts( + efforts: readonly string[], +): Partial> { + const map: Partial> = {} + for (const level of PI_THINKING_LEVELS) { + if (level === "off") continue + map[level] = efforts.includes(level) ? level : null + } + return map +} + +export interface ThinkingMetadata { + thinkingLevelMap: Partial> + thinking: { + mode: "effort" + effortMap: Partial> + efforts: readonly CommandCodeReasoningEffort[] + } +} + +export function thinkingMetadataForModel(modelId: string): ThinkingMetadata | undefined { + const efforts = MODEL_EFFORTS[modelId] + if (!efforts) return undefined + return { + thinkingLevelMap: thinkingLevelMapForEfforts(efforts), + thinking: { + mode: "effort", + effortMap: Object.fromEntries(efforts.map((effort) => [effort, effort])), + efforts, + }, + } +} + +function isReasoningModel(modelId: string): boolean { + return MODEL_EFFORTS[modelId] !== undefined +} + interface ApiModel { id: string name: string @@ -23,6 +110,8 @@ export interface CommandCodeModel { interface FetchCommandCodeModelsOptions { url?: string fetchImpl?: typeof fetch + signal?: AbortSignal + timeoutMs?: number } interface LoadCommandCodeModelsOptions extends FetchCommandCodeModelsOptions { @@ -74,10 +163,12 @@ function parseApiModel(value: unknown): ApiModel { function parseCachedModel(value: unknown): CommandCodeModel { if (!isRecord(value)) throw new Error("Expected cached model entry to be an object") + const id = stringField(value, "id") + booleanField(value, "reasoning") return { - id: stringField(value, "id"), + id, name: stringField(value, "name"), - reasoning: booleanField(value, "reasoning"), + reasoning: isReasoningModel(id), contextWindow: positiveNumberField(value, "contextWindow"), maxTokens: positiveNumberField(value, "maxTokens"), } @@ -92,6 +183,85 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } +function abortError(reason: unknown): Error { + if (reason instanceof Error) return reason + return new DOMException("The operation was aborted", "AbortError") +} + +function configuredTimeoutMs(timeoutMs: number | undefined): number { + return timeoutMs !== undefined && Number.isFinite(timeoutMs) && timeoutMs > 0 + ? timeoutMs + : DEFAULT_MODELS_TIMEOUT_MS +} + +export function getModelsTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.COMMANDCODE_MODELS_TIMEOUT_MS + if (!raw) return DEFAULT_MODELS_TIMEOUT_MS + + const parsed = Number(raw) + return configuredTimeoutMs(parsed) +} + +class ModelDiscoveryTimeoutError extends Error { + constructor(timeoutMs: number) { + super(`Command Code model discovery timed out after ${timeoutMs}ms`) + this.name = "ModelDiscoveryTimeoutError" + } +} + +function runWithTimeout( + operation: (signal: AbortSignal) => Promise, + timeoutMs: number, + externalSignal: AbortSignal | undefined, +): Promise { + const controller = new AbortController() + let timer: ReturnType | undefined + let settled = false + let onExternalAbort: (() => void) | undefined + + return new Promise((resolve, reject) => { + const cleanup = () => { + if (timer !== undefined) clearTimeout(timer) + if (onExternalAbort && externalSignal) { + externalSignal.removeEventListener("abort", onExternalAbort) + } + } + + const resolveOnce = (value: T) => { + if (settled) return + settled = true + cleanup() + resolve(value) + } + + const rejectOnce = (error: unknown) => { + if (settled) return + settled = true + cleanup() + reject(error) + } + + const abort = (reason: unknown) => { + const error = abortError(reason) + controller.abort(error) + rejectOnce(error) + } + + if (externalSignal?.aborted) { + abort(externalSignal.reason) + return + } + + onExternalAbort = () => abort(externalSignal?.reason) + externalSignal?.addEventListener("abort", onExternalAbort, { once: true }) + timer = setTimeout(() => abort(new ModelDiscoveryTimeoutError(timeoutMs)), timeoutMs) + + Promise.resolve() + .then(() => operation(controller.signal)) + .then(resolveOnce, rejectOnce) + }) +} + export function commandCodeModelsFromApiResponse(value: unknown): readonly CommandCodeModel[] { if (!isRecord(value)) throw new Error("Expected models response to be an object") if (value.object !== "list") throw new Error("Expected models response object to be 'list'") @@ -102,7 +272,7 @@ export function commandCodeModelsFromApiResponse(value: unknown): readonly Comma return data.map(parseApiModel).map((model) => ({ id: model.id, name: `${model.name} (CC)`, - reasoning: true, + reasoning: isReasoningModel(model.id), contextWindow: model.contextLength, maxTokens: Math.min(model.contextLength, DEFAULT_MAX_OUTPUT_TOKENS), })) @@ -123,19 +293,26 @@ export async function fetchCommandCodeModels( ): Promise { const url = options.url ?? DEFAULT_MODELS_URL const fetchImpl = options.fetchImpl ?? fetch - const response = await fetchImpl(url, { - headers: { - accept: "application/json", - }, - }) + const body: unknown = await runWithTimeout( + async (signal) => { + const response = await fetchImpl(url, { + headers: { + accept: "application/json", + }, + signal, + }) - if (!response.ok) { - throw new Error( - `Failed to fetch Command Code models: ${response.status} ${response.statusText}`, - ) - } + if (!response.ok) { + throw new Error( + `Failed to fetch Command Code models: ${response.status} ${response.statusText}`, + ) + } - const body: unknown = await response.json() + return await response.json() + }, + configuredTimeoutMs(options.timeoutMs), + options.signal, + ) return requireModels(commandCodeModelsFromApiResponse(body)) } @@ -187,6 +364,8 @@ export async function loadCommandCodeModels( } } } catch (liveError) { + if (options.signal?.aborted) throw abortError(options.signal.reason ?? liveError) + try { const models = await readCommandCodeModelsCache(cachePath) return { @@ -198,7 +377,7 @@ export async function loadCommandCodeModels( return { models: [], source: "empty", - warning: `Could not refresh the Command Code model catalog (${errorMessage(liveError)}), and no valid cached catalog is available at ${cachePath} (${errorMessage(cacheError)}). Command Code models will remain unavailable until /reload succeeds.`, + warning: `Could not refresh the Command Code model catalog (${errorMessage(liveError)}), and no valid cached catalog is available at ${cachePath} (${errorMessage(cacheError)}). Command Code models will remain unavailable until /commandcode-refresh succeeds.`, } } } diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..34c034a --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,279 @@ +import type { CommandCodeModel, LoadCommandCodeModelsResult } from "./models.ts" + +export interface CommandCodeUi { + notify(message: string, type?: "info" | "warning" | "error"): void +} + +export interface CommandCodeCommandContext { + ui: CommandCodeUi + waitForIdle?: () => Promise +} + +export interface CommandCodeRuntimeApi< + TProviderConfig, + TContext extends CommandCodeCommandContext, +> { + registerProvider(name: string, config: TProviderConfig): void + registerCommand( + name: string, + options: { + description: string + handler: (args: string, ctx: TContext) => Promise + }, + ): void +} + +export interface CommandCodeRuntimeOptions { + endpoint: string + cachePath: string + loadModels: () => Promise + createProviderConfig: (models: readonly CommandCodeModel[]) => TProviderConfig + now?: () => number + logWarning?: (message: string) => void +} + +export interface CommandCodeRuntimeStatus { + source: LoadCommandCodeModelsResult["source"] + modelCount: number + lastSuccess?: number + lastAttempt?: number + cachePath: string + endpoint: string + warning?: string + refreshing: boolean +} + +export interface CommandCodeRefreshResult { + refreshed: boolean + source: CommandCodeRuntimeStatus["source"] + modelCount: number + warning?: string +} + +const REDACTED = "[redacted]" + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function redactUrl(value: string): string { + try { + const url = new URL(value) + return `${url.protocol}//${url.host}${url.pathname}` + } catch { + return REDACTED + } +} + +export function redactDiagnosticText(value: string): string { + const redactedUrls = value.replace(/https?:\/\/[^\s)]+/gi, (match) => redactUrl(match)) + return redactedUrls + .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, `Bearer ${REDACTED}`) + .replace(/\b(?:user|cc)_[A-Za-z0-9_-]{8,}\b/gi, REDACTED) + .replace(/\b(?:api[-_ ]?key|token|secret|password)\s*[=:]\s*[^\s,;)]+/gi, (match) => { + const separator = match.match(/\s*[=:]\s*/)?.[0] ?? "=" + return `${match.slice(0, match.indexOf(separator))}${separator}${REDACTED}` + }) +} + +export function redactEndpoint(value: string): string { + return redactUrl(value) +} + +function formatTimestamp(timestamp: number | undefined): string { + return timestamp === undefined ? "never" : new Date(timestamp).toISOString() +} + +export function formatCommandCodeStatus(status: CommandCodeRuntimeStatus): string { + const lines = [ + `source: ${status.source}`, + `model count: ${status.modelCount}`, + `last success: ${formatTimestamp(status.lastSuccess)}`, + `last attempt: ${formatTimestamp(status.lastAttempt)}`, + `cache path: ${status.cachePath}`, + `endpoint: ${redactEndpoint(status.endpoint)}`, + `refresh: ${status.refreshing ? "in progress" : "idle"}`, + ] + + lines.push(`warning: ${status.warning ? redactDiagnosticText(status.warning) : "none"}`) + return lines.join("\n") +} + +export class CommandCodeRuntime { + private readonly now: () => number + private readonly logWarning: (message: string) => void + private status: CommandCodeRuntimeStatus + private providerRegistered = false + private refreshPromise: Promise | undefined + + constructor( + private readonly pi: CommandCodeRuntimeApi, + private readonly options: CommandCodeRuntimeOptions, + ) { + this.now = options.now ?? Date.now + this.logWarning = options.logWarning ?? ((message) => console.warn(`[commandcode] ${message}`)) + const initialStatus: CommandCodeRuntimeStatus = { + source: "empty", + modelCount: 0, + cachePath: options.cachePath, + endpoint: options.endpoint, + refreshing: false, + } + this.status = { ...initialStatus } + } + + getStatus(): CommandCodeRuntimeStatus { + return { ...this.status } + } + + async initialize(): Promise { + this.registerCommands() + await this.refresh() + } + + refresh(): Promise { + if (this.refreshPromise) return this.refreshPromise + + const refreshPromise = this.refreshCatalog().finally(() => { + if (this.refreshPromise === refreshPromise) this.refreshPromise = undefined + }) + this.refreshPromise = refreshPromise + return refreshPromise + } + + private async refreshCatalog(): Promise { + this.status = { + ...this.status, + lastAttempt: this.now(), + refreshing: true, + } + + try { + const loaded = await this.options.loadModels() + const warning = loaded.warning ? redactDiagnosticText(loaded.warning) : undefined + + const shouldRegister = + !this.providerRegistered || + loaded.source === "live" || + (this.status.modelCount === 0 && loaded.models.length > 0) + + if (shouldRegister) { + this.pi.registerProvider("commandcode", this.options.createProviderConfig(loaded.models)) + this.providerRegistered = true + + if (loaded.models.length === 0) { + const preservedWarning = warning ?? "Model catalog refresh returned no models" + this.status = { + ...this.status, + source: loaded.source, + modelCount: 0, + warning: preservedWarning, + refreshing: false, + } + this.warn(preservedWarning) + return { + refreshed: false, + source: loaded.source, + modelCount: 0, + warning: preservedWarning, + } + } + + this.status = { + ...this.status, + source: loaded.source, + modelCount: loaded.models.length, + lastSuccess: this.now(), + warning, + refreshing: false, + } + if (warning) this.warn(warning) + return { + refreshed: true, + source: loaded.source, + modelCount: loaded.models.length, + warning, + } + } + + const preservedWarning = warning ?? "Model catalog refresh returned no models" + this.status = { + ...this.status, + warning: preservedWarning, + refreshing: false, + } + this.warn(preservedWarning) + return { + refreshed: false, + source: this.status.source, + modelCount: this.status.modelCount, + warning: preservedWarning, + } + } catch (error) { + const warning = redactDiagnosticText( + `Could not refresh the Command Code model catalog: ${errorMessage(error)}`, + ) + this.status = { + ...this.status, + warning, + refreshing: false, + } + this.warn(warning) + return { + refreshed: false, + source: this.status.source, + modelCount: this.status.modelCount, + warning, + } + } + } + + private warn(message: string): void { + try { + this.logWarning(redactDiagnosticText(message)) + } catch { + // Diagnostics must never make a catalog refresh fail. + } + } + + private registerCommands(): void { + this.pi.registerCommand("commandcode-refresh", { + description: "Refresh the Command Code model catalog", + handler: async (_args, ctx) => { + await ctx.waitForIdle?.() + const result = await this.refresh() + if (result.refreshed) { + ctx.ui.notify( + `Command Code model catalog refreshed (${result.modelCount} models from ${result.source}).`, + "info", + ) + } else { + ctx.ui.notify( + `Command Code model catalog unchanged (${result.modelCount} models remain available).${result.warning ? ` ${result.warning}` : ""}`, + "warning", + ) + } + }, + }) + + this.pi.registerCommand("commandcode-status", { + description: "Show redacted Command Code provider diagnostics", + handler: async (_args, ctx) => { + ctx.ui.notify( + formatCommandCodeStatus(this.status), + this.status.warning ? "warning" : "info", + ) + }, + }) + } +} + +export function createCommandCodeRuntime< + TProviderConfig, + TContext extends CommandCodeCommandContext, +>( + pi: CommandCodeRuntimeApi, + options: CommandCodeRuntimeOptions, +): CommandCodeRuntime { + return new CommandCodeRuntime(pi, options) +} diff --git a/src/types.ts b/src/types.ts index c8db5a9..c520543 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,6 +72,13 @@ export interface ModelLike { provider: string maxTokens: number cost: ModelCost + reasoning?: boolean + thinkingLevelMap?: Partial> + thinking?: { + mode?: "effort" + effortMap?: Partial> + efforts?: readonly string[] + } } export interface MessageLike { @@ -104,6 +111,8 @@ export interface StreamOptions { signal?: AbortSignal headers?: Record maxTokens?: number + /** Resolved pi thinking level; forwarded only through the model's map. */ + reasoning?: string onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise onResponse?: (response: ProviderResponseInfo, model: ModelLike) => void | Promise /** diff --git a/tests/test-models.ts b/tests/test-models.ts index 8bbad9f..e0a34ad 100644 --- a/tests/test-models.ts +++ b/tests/test-models.ts @@ -7,7 +7,12 @@ import { describe, it } from "node:test" import { commandCodeModelsFromApiResponse, commandCodeModelsFromCache, + DEFAULT_MODELS_TIMEOUT_MS, + getModelsTimeoutMs, loadCommandCodeModels, + MODEL_EFFORTS, + thinkingLevelMapForEfforts, + thinkingMetadataForModel, type CommandCodeModel, } from "../src/models.ts" @@ -29,7 +34,7 @@ const EXPECTED_MODELS: readonly CommandCodeModel[] = [ { id: "Qwen/Qwen3.7-Max", name: "Qwen 3.7 Max (CC)", - reasoning: true, + reasoning: false, contextWindow: 1_000_000, maxTokens: 65_536, }, @@ -49,6 +54,17 @@ function failingFetch(message = "offline"): typeof fetch { return () => Promise.reject(new TypeError(message)) } +function hangingFetch(): typeof fetch { + return (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(init.signal?.reason ?? new DOMException("Aborted", "AbortError")), + { once: true }, + ) + }) +} + async function withTemporaryCache( run: (paths: { directory: string; cachePath: string }) => Promise, ): Promise { @@ -65,6 +81,79 @@ describe("commandCodeModelsFromApiResponse()", () => { assert.deepEqual(commandCodeModelsFromApiResponse(API_RESPONSE), EXPECTED_MODELS) }) + it("marks only known reasoning models as reasoning-capable", () => { + const models = commandCodeModelsFromApiResponse({ + object: "list", + data: [ + { ...API_RESPONSE.data[0], id: "deepseek/deepseek-v4-flash" }, + { ...API_RESPONSE.data[0], id: "new-model-without-metadata" }, + ], + }) + + assert.equal(models[0]?.reasoning, true) + assert.equal(models[1]?.reasoning, false) + }) + + it("matches the exact command-code@1.14.1 reasoning effort catalog", () => { + assert.deepEqual(MODEL_EFFORTS, { + "Qwen/Qwen3.8-Max": ["low", "medium", "xhigh"], + "claude-fable-5": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-4-7": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-4-8": ["low", "medium", "high", "xhigh", "max"], + "claude-opus-5": ["low", "medium", "high", "xhigh", "max"], + "claude-sonnet-4-6": ["low", "medium", "high", "xhigh", "max"], + "claude-sonnet-5": ["low", "medium", "high", "xhigh", "max"], + "deepseek/deepseek-v4-flash": ["high", "max"], + "deepseek/deepseek-v4-pro": ["high", "max"], + "gpt-5.3-codex": ["low", "medium", "high", "xhigh"], + "gpt-5.4": ["low", "medium", "high", "xhigh"], + "gpt-5.4-mini": ["low", "medium", "high"], + "gpt-5.5": ["low", "medium", "high", "xhigh"], + "gpt-5.6-luna": ["low", "medium", "high", "xhigh", "max"], + "gpt-5.6-sol": ["low", "medium", "high", "xhigh", "max"], + "gpt-5.6-terra": ["low", "medium", "high", "xhigh", "max"], + "google/gemini-3.1-flash-lite": ["low", "medium", "high"], + "google/gemini-3.5-flash": ["low", "medium", "high"], + "google/gemini-3.5-flash-lite": ["low", "medium", "high"], + "google/gemini-3.6-flash": ["low", "medium", "high"], + "sakana/fugu-ultra": ["high", "xhigh"], + "xai/grok-4.5": ["low", "medium", "high"], + "zai-org/GLM-5.2": ["high", "max"], + }) + }) + + it("builds separate canonical pi and OMP metadata", () => { + for (const [modelId, efforts] of Object.entries(MODEL_EFFORTS)) { + const metadata = thinkingMetadataForModel(modelId) + assert.ok(metadata, `${modelId} should have reasoning metadata`) + assert.equal(metadata.thinking.mode, "effort") + assert.deepEqual(metadata.thinking.efforts, efforts) + assert.deepEqual( + metadata.thinking.effortMap, + Object.fromEntries(efforts.map((effort) => [effort, effort])), + ) + assert.equal("defaultLevel" in metadata.thinking, false) + for (const level of ["minimal", "low", "medium", "high", "xhigh", "max"] as const) { + const expected = efforts.includes(level) + assert.equal( + metadata.thinkingLevelMap[level], + expected ? level : null, + `${modelId} should map ${level} according to its catalog entry`, + ) + } + } + + assert.deepEqual(thinkingLevelMapForEfforts(MODEL_EFFORTS["deepseek/deepseek-v4-flash"]), { + minimal: null, + low: null, + medium: null, + high: "high", + xhigh: null, + max: "max", + }) + assert.deepEqual(thinkingMetadataForModel("new-model-without-metadata"), undefined) + }) + it("rejects unexpected API shapes", () => { assert.throws(() => commandCodeModelsFromApiResponse({ object: "list", data: [{}] })) }) @@ -78,6 +167,20 @@ describe("commandCodeModelsFromCache()", () => { ) }) + it("normalizes cached reasoning metadata from the model id", () => { + const cached = commandCodeModelsFromCache({ + version: 1, + models: [ + { + ...EXPECTED_MODELS[0], + id: "deepseek/deepseek-v4-flash", + reasoning: false, + }, + ], + }) + assert.equal(cached[0]?.reasoning, true) + }) + it("rejects empty, invalid, and unsupported caches", () => { assert.throws(() => commandCodeModelsFromCache({ version: 1, models: [] })) assert.throws(() => commandCodeModelsFromCache({ version: 2, models: EXPECTED_MODELS })) @@ -90,7 +193,58 @@ describe("commandCodeModelsFromCache()", () => { }) }) +describe("model discovery configuration", () => { + it("uses a safe default timeout and ignores invalid environment values", () => { + assert.equal(getModelsTimeoutMs({}), DEFAULT_MODELS_TIMEOUT_MS) + assert.equal( + getModelsTimeoutMs({ COMMANDCODE_MODELS_TIMEOUT_MS: "0" }), + DEFAULT_MODELS_TIMEOUT_MS, + ) + assert.equal( + getModelsTimeoutMs({ COMMANDCODE_MODELS_TIMEOUT_MS: "invalid" }), + DEFAULT_MODELS_TIMEOUT_MS, + ) + assert.equal(getModelsTimeoutMs({ COMMANDCODE_MODELS_TIMEOUT_MS: "25" }), 25) + }) +}) + describe("loadCommandCodeModels()", () => { + it("falls back to cache when live discovery times out", async () => { + await withTemporaryCache(async ({ cachePath }) => { + await loadCommandCodeModels({ cachePath, fetchImpl: successfulFetch() }) + + const startedAt = Date.now() + const result = await loadCommandCodeModels({ + cachePath, + fetchImpl: hangingFetch(), + timeoutMs: 25, + }) + + assert.ok(Date.now() - startedAt < 500) + assert.deepEqual(result.models, EXPECTED_MODELS) + assert.equal(result.source, "cache") + assert.match(result.warning ?? "", /timed out after 25ms/) + assert.match(result.warning ?? "", /Using the cached catalog/) + }) + }) + + it("preserves an external abort instead of falling back to cache", async () => { + await withTemporaryCache(async ({ cachePath }) => { + await loadCommandCodeModels({ cachePath, fetchImpl: successfulFetch() }) + const controller = new AbortController() + const promise = loadCommandCodeModels({ + cachePath, + fetchImpl: hangingFetch(), + timeoutMs: 1_000, + signal: controller.signal, + }) + + controller.abort(new Error("caller cancelled discovery")) + + await assert.rejects(promise, /caller cancelled discovery/) + }) + }) + it("returns live models and writes a validated cache", async () => { await withTemporaryCache(async ({ cachePath }) => { const result = await loadCommandCodeModels({ @@ -132,7 +286,7 @@ describe("loadCommandCodeModels()", () => { assert.deepEqual(result.models, []) assert.equal(result.source, "empty") assert.match(result.warning ?? "", /no valid cached catalog/) - assert.match(result.warning ?? "", /until \/reload succeeds/) + assert.match(result.warning ?? "", /until \/commandcode-refresh succeeds/) }) }) diff --git a/tests/test-pi-local.mjs b/tests/test-pi-local.mjs index 8423158..a8566c9 100644 --- a/tests/test-pi-local.mjs +++ b/tests/test-pi-local.mjs @@ -277,9 +277,12 @@ try { ) assert.equal(firstOfflineList.code, 0, firstOfflineList.stderr) assert.doesNotMatch(firstOfflineList.stderr, /Failed to load extension/) - assert.match(firstOfflineList.stdout || firstOfflineList.stderr, /No models matching/) + assert.match( + firstOfflineList.stdout || firstOfflineList.stderr, + /No models matching|No models available/, + ) assert.match(firstOfflineList.stderr, /no valid cached catalog/) - assert.match(firstOfflineList.stderr, /until \/reload succeeds/) + assert.match(firstOfflineList.stderr, /until \/commandcode-refresh succeeds/) assert.throws(() => accessSync(modelsCachePath, constants.R_OK), /ENOENT|no such file/i) // A fresh process re-runs the extension entrypoint, which is the same path /reload uses. diff --git a/tests/test-runtime.ts b/tests/test-runtime.ts new file mode 100644 index 0000000..c8b0af0 --- /dev/null +++ b/tests/test-runtime.ts @@ -0,0 +1,293 @@ +import assert from "node:assert/strict" +import { describe, it } from "node:test" + +import { + createCommandCodeRuntime, + type CommandCodeCommandContext, + type CommandCodeRuntimeApi, +} from "../src/runtime.ts" +import type { CommandCodeModel, LoadCommandCodeModelsResult } from "../src/models.ts" + +type ProviderConfig = { + models: readonly CommandCodeModel[] +} + +class ExtensionAPITestDouble implements CommandCodeRuntimeApi { + readonly providers: ProviderConfig[] = [] + readonly commands = new Map Promise | void>() + + registerProvider(_name: string, config: ProviderConfig): void { + this.providers.push(config) + } + + registerCommand( + name: string, + options: { + description: string + handler: (args: string, ctx: CommandContext) => Promise | void + }, + ): void { + this.commands.set(name, options.handler) + } +} + +class CommandContext implements CommandCodeCommandContext { + readonly notifications: Array<{ message: string; type?: "info" | "warning" | "error" }> = [] + waitForIdleCalls = 0 + + readonly ui = { + notify: (message: string, type?: "info" | "warning" | "error") => { + this.notifications.push({ message, type }) + }, + } + + async waitForIdle(): Promise { + this.waitForIdleCalls += 1 + } +} + +const FIRST_MODEL: CommandCodeModel = { + id: "first-model", + name: "First Model", + reasoning: true, + contextWindow: 128_000, + maxTokens: 16_384, +} + +const SECOND_MODEL: CommandCodeModel = { + id: "second-model", + name: "Second Model", + reasoning: true, + contextWindow: 256_000, + maxTokens: 32_768, +} + +function loaded( + models: readonly CommandCodeModel[], + source: LoadCommandCodeModelsResult["source"] = "live", + warning?: string, +): LoadCommandCodeModelsResult { + return warning ? { models, source, warning } : { models, source } +} + +function deferred(): { + promise: Promise + resolve(value: T): void + reject(error: unknown): void +} { + let resolvePromise: (value: T) => void = () => {} + let rejectPromise: (error: unknown) => void = () => {} + const promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + return { promise, resolve: resolvePromise, reject: rejectPromise } +} + +describe("Command Code runtime", () => { + it("registers refresh and status commands and exposes redacted state", async () => { + const pi = new ExtensionAPITestDouble() + const context = new CommandContext() + let now = 1_700_000_000_000 + const firstLoad = deferred() + + const runtime = createCommandCodeRuntime(pi, { + endpoint: "https://api.commandcode.ai/provider/v1/models?token=user_secret_value", + cachePath: "/tmp/commandcode-models.json", + loadModels: () => firstLoad.promise, + createProviderConfig: (models) => ({ models }), + now: () => now, + logWarning: () => {}, + }) + + const initialization = runtime.initialize() + assert.deepEqual([...pi.commands.keys()], ["commandcode-refresh", "commandcode-status"]) + assert.equal(runtime.getStatus().refreshing, true) + assert.equal(runtime.getStatus().lastAttempt, now) + + firstLoad.resolve(loaded([FIRST_MODEL])) + await initialization + now += 1_000 + + const statusCommand = pi.commands.get("commandcode-status") + assert.ok(statusCommand) + await statusCommand("", context) + const statusMessage = context.notifications.at(-1)?.message ?? "" + assert.match(statusMessage, /source: live/) + assert.match(statusMessage, /model count: 1/) + assert.match(statusMessage, /last success:/) + assert.match(statusMessage, /last attempt:/) + assert.match(statusMessage, /cache path: \/tmp\/commandcode-models\.json/) + assert.match(statusMessage, /endpoint: https:\/\/api\.commandcode\.ai\/provider\/v1\/models/) + assert.doesNotMatch(statusMessage, /token=user_secret_value/) + assert.doesNotMatch(statusMessage, /user_secret_value/) + }) + + it("coalesces overlapping refreshes and preserves the current catalog on failure", async () => { + const pi = new ExtensionAPITestDouble() + const warnings: string[] = [] + const loads = [Promise.resolve(loaded([FIRST_MODEL])), deferred()] + let loadCount = 0 + + const runtime = createCommandCodeRuntime(pi, { + endpoint: "https://api.commandcode.ai/provider/v1/models", + cachePath: "/tmp/commandcode-models.json", + loadModels: () => { + const next = loads[loadCount] + loadCount += 1 + if (!next) throw new Error("unexpected refresh") + return next instanceof Promise ? next : next.promise + }, + createProviderConfig: (models) => ({ models }), + logWarning: (warning) => warnings.push(warning), + }) + + await runtime.initialize() + assert.equal(pi.providers.length, 1) + assert.deepEqual(pi.providers[0]?.models, [FIRST_MODEL]) + + const pending = loads[1] + assert.ok(!(pending instanceof Promise)) + const firstRefresh = runtime.refresh() + const secondRefresh = runtime.refresh() + assert.strictEqual(firstRefresh, secondRefresh) + assert.equal(runtime.getStatus().refreshing, true) + + pending.reject(new Error("request failed with apiKey=user_secret_value")) + const result = await firstRefresh + + assert.equal(result.refreshed, false) + assert.equal(result.modelCount, 1) + assert.equal(runtime.getStatus().modelCount, 1) + assert.equal(runtime.getStatus().source, "live") + assert.equal(pi.providers.length, 1) + assert.equal(runtime.getStatus().refreshing, false) + assert.match(runtime.getStatus().warning ?? "", /Could not refresh/) + assert.doesNotMatch(runtime.getStatus().warning ?? "", /user_secret_value/) + assert.doesNotMatch(warnings.join("\n"), /user_secret_value/) + }) + + it("runs the refresh command and reports the updated catalog", async () => { + const pi = new ExtensionAPITestDouble() + const context = new CommandContext() + const results = [ + Promise.resolve(loaded([FIRST_MODEL])), + Promise.resolve(loaded([FIRST_MODEL, SECOND_MODEL])), + ] + let index = 0 + + const runtime = createCommandCodeRuntime(pi, { + endpoint: "https://api.commandcode.ai/provider/v1/models", + cachePath: "/tmp/commandcode-models.json", + loadModels: () => { + const result = results[index] + index += 1 + if (!result) throw new Error("unexpected refresh") + return result + }, + createProviderConfig: (models) => ({ models }), + logWarning: () => {}, + }) + + await runtime.initialize() + const refreshCommand = pi.commands.get("commandcode-refresh") + assert.ok(refreshCommand) + await refreshCommand("", context) + + assert.equal(context.waitForIdleCalls, 1) + assert.equal(context.notifications.at(-1)?.type, "info") + assert.match(context.notifications.at(-1)?.message ?? "", /2 models from live/) + assert.deepEqual(pi.providers.at(-1)?.models, [FIRST_MODEL, SECOND_MODEL]) + }) + + it("installs a cached catalog after an initially empty start", async () => { + const pi = new ExtensionAPITestDouble() + const results = [ + Promise.resolve(loaded([], "empty", "offline")), + Promise.resolve(loaded([SECOND_MODEL], "cache")), + ] + let index = 0 + + const runtime = createCommandCodeRuntime(pi, { + endpoint: "https://api.commandcode.ai/provider/v1/models", + cachePath: "/tmp/commandcode-models.json", + loadModels: () => { + const result = results[index] + index += 1 + if (!result) throw new Error("unexpected refresh") + return result + }, + createProviderConfig: (models) => ({ models }), + logWarning: () => {}, + }) + + await runtime.initialize() + assert.equal(pi.providers.length, 1) + assert.deepEqual(pi.providers[0]?.models, []) + + const result = await runtime.refresh() + assert.equal(result.refreshed, true) + assert.equal(result.source, "cache") + assert.deepEqual(pi.providers.at(-1)?.models, [SECOND_MODEL]) + assert.equal(runtime.getStatus().modelCount, 1) + }) + + it("does not replace an existing provider with an empty failed catalog", async () => { + const pi = new ExtensionAPITestDouble() + const results = [ + Promise.resolve(loaded([FIRST_MODEL])), + Promise.resolve(loaded([], "cache", "No valid catalog is available at /private/cache")), + Promise.resolve(loaded([SECOND_MODEL])), + ] + let index = 0 + + const runtime = createCommandCodeRuntime(pi, { + endpoint: "http://127.0.0.1:1234/provider/v1/models", + cachePath: "/private/cache", + loadModels: () => { + const result = results[index] + index += 1 + if (!result) throw new Error("unexpected refresh") + return result + }, + createProviderConfig: (models) => ({ models }), + logWarning: () => {}, + }) + + await runtime.initialize() + const refreshResult = await runtime.refresh() + assert.equal(refreshResult.refreshed, false) + assert.equal(pi.providers.length, 1) + assert.deepEqual(pi.providers[0]?.models, [FIRST_MODEL]) + assert.equal(runtime.getStatus().modelCount, 1) + assert.equal(runtime.getStatus().source, "live") + + await runtime.refresh() + assert.equal(pi.providers.length, 2) + assert.deepEqual(pi.providers[1]?.models, [SECOND_MODEL]) + }) + + it("reports a failed initial refresh without leaking diagnostics", async () => { + const pi = new ExtensionAPITestDouble() + const context = new CommandContext() + const runtime = createCommandCodeRuntime(pi, { + endpoint: "https://api.commandcode.ai/provider/v1/models?api_key=user_initial_secret", + cachePath: "/tmp/commandcode-models.json", + loadModels: async () => { + throw new Error("offline; api_key=user_initial_secret") + }, + createProviderConfig: (models) => ({ models }), + logWarning: () => {}, + }) + + await runtime.initialize() + const statusCommand = pi.commands.get("commandcode-status") + assert.ok(statusCommand) + await statusCommand("", context) + const message = context.notifications.at(-1)?.message ?? "" + assert.match(message, /source: empty/) + assert.match(message, /model count: 0/) + assert.match(message, /warning:/) + assert.doesNotMatch(message, /user_initial_secret/) + }) +}) diff --git a/tests/test-stream.ts b/tests/test-stream.ts index 745bf06..c185588 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -7,6 +7,7 @@ import assert from "node:assert/strict" import { after, before, beforeEach, describe, it } from "node:test" import type { AssistantMessageEvent } from "../src/core.ts" +import { MODEL_EFFORTS, thinkingLevelMapForEfforts } from "../src/models.ts" import { collectEvents, createTestDeps, @@ -415,6 +416,84 @@ describe("streamCommandCode — request serialization", () => { assert.equal(headers["x-session-id"], undefined) }) + it("accepts the legacy OMP nested reasoning map", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + const model = makeModel({ + id: "omp-compat-reasoning-model", + reasoning: true, + thinking: { effortMap: { high: "legacy-high" } }, + }) + + await collectEvents( + streamCommandCode(model, makeContext(), { apiKey: "mock-key", reasoning: "high" }), + ) + + assert.equal(objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), "legacy-high") + }) + + it("forwards a supported Pi reasoning level as reasoning_effort", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + const model = makeModel({ + id: "deepseek/deepseek-v4-flash", + reasoning: true, + thinkingLevelMap: thinkingLevelMapForEfforts(MODEL_EFFORTS["deepseek/deepseek-v4-flash"]), + }) + + await collectEvents( + streamCommandCode(model, makeContext(), { apiKey: "mock-key", reasoning: "max" }), + ) + + assert.equal(objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), "max") + }) + + it("omits reasoning_effort for off, unsupported, and unknown reasoning levels", async () => { + const model = makeModel({ + id: "deepseek/deepseek-v4-flash", + reasoning: true, + thinkingLevelMap: thinkingLevelMapForEfforts(MODEL_EFFORTS["deepseek/deepseek-v4-flash"]), + }) + + for (const reasoning of ["off", "low"] as const) { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + + await collectEvents( + streamCommandCode(model, makeContext(), { apiKey: "mock-key", reasoning }), + ) + assert.equal( + objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), + undefined, + `${reasoning} should not be sent when it has no supported Command Code field`, + ) + server.reset() + } + + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) + await collectEvents( + streamCommandCode( + makeModel({ id: "new-model-without-metadata", reasoning: false }), + makeContext(), + { apiKey: "mock-key", reasoning: "high" }, + ), + ) + assert.equal(objectAt(server.lastRequestBody(), ["params", "reasoning_effort"]), undefined) + }) + it("caps maxTokens and passes custom headers", async () => { server.mockResponse({ type: "success",