fix(coding-agent): honor provider compat in models.json closes #2062
This commit is contained in:
@@ -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`) |
|
||||
|
||||
|
||||
@@ -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>);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user