fix(coding-agent): honor provider compat in models.json closes #2062

This commit is contained in:
Mario Zechner
2026-03-14 04:54:55 +01:00
parent 62064b2b64
commit bdf2419ac3
3 changed files with 155 additions and 10 deletions

View File

@@ -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`) |

View File

@@ -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<typeof ModelsConfigSchema>;
/** 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<string, string>;
apiKey?: string;
compat?: Model<Api>["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<string, Map<string, ModelOverride>>();
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<Api>);
}
}

View File

@@ -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": {