diff --git a/packages/coding-agent/docs/models.md b/packages/coding-agent/docs/models.md index 4c36f38a..5b5be4ae 100644 --- a/packages/coding-agent/docs/models.md +++ b/packages/coding-agent/docs/models.md @@ -136,6 +136,7 @@ The `apiKey` and `headers` fields support three formats: | `contextWindow` | No | `128000` | Context window size in tokens | | `maxTokens` | No | `16384` | Maximum output tokens | | `cost` | No | all zeros | `{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}` (per million tokens) | +| `compat` | No | provider `compat` | OpenAI compatibility overrides. Merged with provider-level `compat` when both are set. | Current behavior: - `/model` and `--list-models` list entries by model `id`. @@ -211,7 +212,10 @@ Behavior notes: ## OpenAI Compatibility -For providers with partial OpenAI compatibility, use the `compat` field: +For providers with partial OpenAI compatibility, use the `compat` field. + +- Provider-level `compat` applies defaults to all models under that provider. +- Model-level `compat` overrides provider-level values for that model. ```json { @@ -234,8 +238,14 @@ For providers with partial OpenAI compatibility, use the `compat` field: | `supportsStore` | Provider supports `store` field | | `supportsDeveloperRole` | Use `developer` vs `system` role | | `supportsReasoningEffort` | Support for `reasoning_effort` parameter | +| `reasoningEffortMap` | Map pi thinking levels to provider-specific `reasoning_effort` values | | `supportsUsageInStreaming` | Supports `stream_options: { include_usage: true }` (default: `true`) | | `maxTokensField` | Use `max_completion_tokens` or `max_tokens` | +| `requiresToolResultName` | Include `name` on tool result messages | +| `requiresAssistantAfterToolResult` | Insert an assistant message before a user message after tool results | +| `requiresThinkingAsText` | Convert thinking blocks to plain text | +| `thinkingFormat` | Use `reasoning_effort`, `zai`, or `qwen` thinking parameters | +| `supportsStrictMode` | Include the `strict` field in tool definitions | | `openRouterRouting` | OpenRouter routing config passed to OpenRouter for model/provider selection | | `vercelGatewayRouting` | Vercel AI Gateway routing config for provider selection (`only`, `order`) | diff --git a/packages/coding-agent/src/core/model-registry.ts b/packages/coding-agent/src/core/model-registry.ts index 94f80a0f..9fe01f0e 100644 --- a/packages/coding-agent/src/core/model-registry.ts +++ b/packages/coding-agent/src/core/model-registry.ts @@ -42,19 +42,28 @@ const VercelGatewayRoutingSchema = Type.Object({ }); // Schema for OpenAI compatibility settings +const ReasoningEffortMapSchema = Type.Object({ + minimal: Type.Optional(Type.String()), + low: Type.Optional(Type.String()), + medium: Type.Optional(Type.String()), + high: Type.Optional(Type.String()), + xhigh: Type.Optional(Type.String()), +}); + const OpenAICompletionsCompatSchema = Type.Object({ supportsStore: Type.Optional(Type.Boolean()), supportsDeveloperRole: Type.Optional(Type.Boolean()), supportsReasoningEffort: Type.Optional(Type.Boolean()), + reasoningEffortMap: Type.Optional(ReasoningEffortMapSchema), supportsUsageInStreaming: Type.Optional(Type.Boolean()), maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])), requiresToolResultName: Type.Optional(Type.Boolean()), requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()), requiresThinkingAsText: Type.Optional(Type.Boolean()), - requiresMistralToolIds: Type.Optional(Type.Boolean()), thinkingFormat: Type.Optional(Type.Union([Type.Literal("openai"), Type.Literal("zai"), Type.Literal("qwen")])), openRouterRouting: Type.Optional(OpenRouterRoutingSchema), vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema), + supportsStrictMode: Type.Optional(Type.Boolean()), }); const OpenAIResponsesCompatSchema = Type.Object({ @@ -112,6 +121,7 @@ const ProviderConfigSchema = Type.Object({ apiKey: Type.Optional(Type.String({ minLength: 1 })), api: Type.Optional(Type.String({ minLength: 1 })), headers: Type.Optional(Type.Record(Type.String(), Type.String())), + compat: Type.Optional(OpenAICompatSchema), authHeader: Type.Optional(Type.Boolean()), models: Type.Optional(Type.Array(ModelDefinitionSchema)), modelOverrides: Type.Optional(Type.Record(Type.String(), ModelOverrideSchema)), @@ -125,11 +135,12 @@ ajv.addSchema(ModelsConfigSchema, "ModelsConfig"); type ModelsConfig = Static; -/** Provider override config (baseUrl, headers, apiKey) without custom models */ +/** Provider override config (baseUrl, headers, apiKey, compat) without custom models */ interface ProviderOverride { baseUrl?: string; headers?: Record; apiKey?: string; + compat?: Model["compat"]; } /** Result of loading custom models from models.json */ @@ -308,13 +319,14 @@ export class ModelRegistry { return models.map((m) => { let model = m; - // Apply provider-level baseUrl/headers override + // Apply provider-level baseUrl/headers/compat override if (providerOverride) { const resolvedHeaders = resolveHeaders(providerOverride.headers); model = { ...model, baseUrl: providerOverride.baseUrl ?? model.baseUrl, headers: resolvedHeaders ? { ...model.headers, ...resolvedHeaders } : model.headers, + compat: mergeCompat(model.compat, providerOverride.compat), }; } @@ -368,12 +380,13 @@ export class ModelRegistry { const modelOverrides = new Map>(); for (const [providerName, providerConfig] of Object.entries(config.providers)) { - // Apply provider-level baseUrl/headers/apiKey override to built-in models when configured. - if (providerConfig.baseUrl || providerConfig.headers || providerConfig.apiKey) { + // Apply provider-level baseUrl/headers/apiKey/compat override to built-in models when configured. + if (providerConfig.baseUrl || providerConfig.headers || providerConfig.apiKey || providerConfig.compat) { overrides.set(providerName, { baseUrl: providerConfig.baseUrl, headers: providerConfig.headers, apiKey: providerConfig.apiKey, + compat: providerConfig.compat, }); } @@ -406,9 +419,11 @@ export class ModelRegistry { providerConfig.modelOverrides && Object.keys(providerConfig.modelOverrides).length > 0; if (models.length === 0) { - // Override-only config: needs baseUrl OR modelOverrides (or both) - if (!providerConfig.baseUrl && !hasModelOverrides) { - throw new Error(`Provider ${providerName}: must specify "baseUrl", "modelOverrides", or "models".`); + // Override-only config: needs baseUrl, compat, modelOverrides, or some combination. + if (!providerConfig.baseUrl && !providerConfig.compat && !hasModelOverrides) { + throw new Error( + `Provider ${providerName}: must specify "baseUrl", "compat", "modelOverrides", or "models".`, + ); } } else { // Custom models are merged into provider models and require endpoint + auth. @@ -459,6 +474,7 @@ export class ModelRegistry { // Resolve env vars and shell commands in header values const providerHeaders = resolveHeaders(providerConfig.headers); const modelHeaders = resolveHeaders(modelDef.headers); + const compat = mergeCompat(providerConfig.compat, modelDef.compat); let headers = providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined; // If authHeader is true, add Authorization header with resolved API key @@ -484,7 +500,7 @@ export class ModelRegistry { contextWindow: modelDef.contextWindow ?? 128000, maxTokens: modelDef.maxTokens ?? 16384, headers, - compat: modelDef.compat, + compat, } as Model); } } diff --git a/packages/coding-agent/test/model-registry.test.ts b/packages/coding-agent/test/model-registry.test.ts index 42c07359..5d1f040f 100644 --- a/packages/coding-agent/test/model-registry.test.ts +++ b/packages/coding-agent/test/model-registry.test.ts @@ -238,6 +238,125 @@ describe("ModelRegistry", () => { } }); + test("provider-level compat applies to custom models", () => { + writeRawModelsJson({ + demo: { + baseUrl: "https://example.com/v1", + apiKey: "DEMO_KEY", + api: "openai-completions", + compat: { + supportsUsageInStreaming: false, + maxTokensField: "max_tokens", + }, + models: [ + { + id: "demo-model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + }, + ], + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const compat = registry.find("demo", "demo-model")?.compat as OpenAICompletionsCompat | undefined; + + expect(compat?.supportsUsageInStreaming).toBe(false); + expect(compat?.maxTokensField).toBe("max_tokens"); + }); + + test("model-level compat overrides provider-level compat for custom models", () => { + writeRawModelsJson({ + demo: { + baseUrl: "https://example.com/v1", + apiKey: "DEMO_KEY", + api: "openai-completions", + compat: { + supportsUsageInStreaming: false, + maxTokensField: "max_tokens", + }, + models: [ + { + id: "demo-model", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + compat: { + supportsUsageInStreaming: true, + maxTokensField: "max_completion_tokens", + }, + }, + ], + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const compat = registry.find("demo", "demo-model")?.compat as OpenAICompletionsCompat | undefined; + + expect(compat?.supportsUsageInStreaming).toBe(true); + expect(compat?.maxTokensField).toBe("max_completion_tokens"); + }); + + test("provider-level compat applies to built-in models", () => { + writeRawModelsJson({ + openrouter: { + compat: { + supportsUsageInStreaming: false, + supportsStrictMode: false, + }, + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const models = getModelsForProvider(registry, "openrouter"); + + expect(models.length).toBeGreaterThan(0); + for (const model of models) { + const compat = model.compat as OpenAICompletionsCompat | undefined; + expect(compat?.supportsUsageInStreaming).toBe(false); + expect(compat?.supportsStrictMode).toBe(false); + } + }); + + test("compat schema accepts reasoningEffortMap and supportsStrictMode", () => { + writeRawModelsJson({ + demo: { + baseUrl: "https://example.com/v1", + apiKey: "DEMO_KEY", + api: "openai-completions", + models: [ + { + id: "demo-model", + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 1000, + maxTokens: 100, + compat: { + reasoningEffortMap: { + minimal: "default", + high: "max", + }, + supportsStrictMode: false, + }, + }, + ], + }, + }); + + const registry = new ModelRegistry(authStorage, modelsJsonPath); + const compat = registry.find("demo", "demo-model")?.compat as OpenAICompletionsCompat | undefined; + + expect(registry.getError()).toBeUndefined(); + expect(compat?.reasoningEffortMap).toEqual({ minimal: "default", high: "max" }); + expect(compat?.supportsStrictMode).toBe(false); + }); + test("model-level baseUrl overrides provider-level baseUrl for custom models", () => { writeRawModelsJson({ "opencode-go": {