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 |
|
| `contextWindow` | No | `128000` | Context window size in tokens |
|
||||||
| `maxTokens` | No | `16384` | Maximum output tokens |
|
| `maxTokens` | No | `16384` | Maximum output tokens |
|
||||||
| `cost` | No | all zeros | `{"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}` (per million 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:
|
Current behavior:
|
||||||
- `/model` and `--list-models` list entries by model `id`.
|
- `/model` and `--list-models` list entries by model `id`.
|
||||||
@@ -211,7 +212,10 @@ Behavior notes:
|
|||||||
|
|
||||||
## OpenAI Compatibility
|
## 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
|
```json
|
||||||
{
|
{
|
||||||
@@ -234,8 +238,14 @@ For providers with partial OpenAI compatibility, use the `compat` field:
|
|||||||
| `supportsStore` | Provider supports `store` field |
|
| `supportsStore` | Provider supports `store` field |
|
||||||
| `supportsDeveloperRole` | Use `developer` vs `system` role |
|
| `supportsDeveloperRole` | Use `developer` vs `system` role |
|
||||||
| `supportsReasoningEffort` | Support for `reasoning_effort` parameter |
|
| `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`) |
|
| `supportsUsageInStreaming` | Supports `stream_options: { include_usage: true }` (default: `true`) |
|
||||||
| `maxTokensField` | Use `max_completion_tokens` or `max_tokens` |
|
| `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 |
|
| `openRouterRouting` | OpenRouter routing config passed to OpenRouter for model/provider selection |
|
||||||
| `vercelGatewayRouting` | Vercel AI Gateway routing config for provider selection (`only`, `order`) |
|
| `vercelGatewayRouting` | Vercel AI Gateway routing config for provider selection (`only`, `order`) |
|
||||||
|
|
||||||
|
|||||||
@@ -42,19 +42,28 @@ const VercelGatewayRoutingSchema = Type.Object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Schema for OpenAI compatibility settings
|
// 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({
|
const OpenAICompletionsCompatSchema = Type.Object({
|
||||||
supportsStore: Type.Optional(Type.Boolean()),
|
supportsStore: Type.Optional(Type.Boolean()),
|
||||||
supportsDeveloperRole: Type.Optional(Type.Boolean()),
|
supportsDeveloperRole: Type.Optional(Type.Boolean()),
|
||||||
supportsReasoningEffort: Type.Optional(Type.Boolean()),
|
supportsReasoningEffort: Type.Optional(Type.Boolean()),
|
||||||
|
reasoningEffortMap: Type.Optional(ReasoningEffortMapSchema),
|
||||||
supportsUsageInStreaming: Type.Optional(Type.Boolean()),
|
supportsUsageInStreaming: Type.Optional(Type.Boolean()),
|
||||||
maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])),
|
maxTokensField: Type.Optional(Type.Union([Type.Literal("max_completion_tokens"), Type.Literal("max_tokens")])),
|
||||||
requiresToolResultName: Type.Optional(Type.Boolean()),
|
requiresToolResultName: Type.Optional(Type.Boolean()),
|
||||||
requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()),
|
requiresAssistantAfterToolResult: Type.Optional(Type.Boolean()),
|
||||||
requiresThinkingAsText: 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")])),
|
thinkingFormat: Type.Optional(Type.Union([Type.Literal("openai"), Type.Literal("zai"), Type.Literal("qwen")])),
|
||||||
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
|
openRouterRouting: Type.Optional(OpenRouterRoutingSchema),
|
||||||
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
|
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
|
||||||
|
supportsStrictMode: Type.Optional(Type.Boolean()),
|
||||||
});
|
});
|
||||||
|
|
||||||
const OpenAIResponsesCompatSchema = Type.Object({
|
const OpenAIResponsesCompatSchema = Type.Object({
|
||||||
@@ -112,6 +121,7 @@ const ProviderConfigSchema = Type.Object({
|
|||||||
apiKey: Type.Optional(Type.String({ minLength: 1 })),
|
apiKey: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
api: Type.Optional(Type.String({ minLength: 1 })),
|
api: Type.Optional(Type.String({ minLength: 1 })),
|
||||||
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
headers: Type.Optional(Type.Record(Type.String(), Type.String())),
|
||||||
|
compat: Type.Optional(OpenAICompatSchema),
|
||||||
authHeader: Type.Optional(Type.Boolean()),
|
authHeader: Type.Optional(Type.Boolean()),
|
||||||
models: Type.Optional(Type.Array(ModelDefinitionSchema)),
|
models: Type.Optional(Type.Array(ModelDefinitionSchema)),
|
||||||
modelOverrides: Type.Optional(Type.Record(Type.String(), ModelOverrideSchema)),
|
modelOverrides: Type.Optional(Type.Record(Type.String(), ModelOverrideSchema)),
|
||||||
@@ -125,11 +135,12 @@ ajv.addSchema(ModelsConfigSchema, "ModelsConfig");
|
|||||||
|
|
||||||
type ModelsConfig = Static<typeof ModelsConfigSchema>;
|
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 {
|
interface ProviderOverride {
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
|
compat?: Model<Api>["compat"];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Result of loading custom models from models.json */
|
/** Result of loading custom models from models.json */
|
||||||
@@ -308,13 +319,14 @@ export class ModelRegistry {
|
|||||||
return models.map((m) => {
|
return models.map((m) => {
|
||||||
let model = m;
|
let model = m;
|
||||||
|
|
||||||
// Apply provider-level baseUrl/headers override
|
// Apply provider-level baseUrl/headers/compat override
|
||||||
if (providerOverride) {
|
if (providerOverride) {
|
||||||
const resolvedHeaders = resolveHeaders(providerOverride.headers);
|
const resolvedHeaders = resolveHeaders(providerOverride.headers);
|
||||||
model = {
|
model = {
|
||||||
...model,
|
...model,
|
||||||
baseUrl: providerOverride.baseUrl ?? model.baseUrl,
|
baseUrl: providerOverride.baseUrl ?? model.baseUrl,
|
||||||
headers: resolvedHeaders ? { ...model.headers, ...resolvedHeaders } : model.headers,
|
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>>();
|
const modelOverrides = new Map<string, Map<string, ModelOverride>>();
|
||||||
|
|
||||||
for (const [providerName, providerConfig] of Object.entries(config.providers)) {
|
for (const [providerName, providerConfig] of Object.entries(config.providers)) {
|
||||||
// Apply provider-level baseUrl/headers/apiKey override to built-in models when configured.
|
// Apply provider-level baseUrl/headers/apiKey/compat override to built-in models when configured.
|
||||||
if (providerConfig.baseUrl || providerConfig.headers || providerConfig.apiKey) {
|
if (providerConfig.baseUrl || providerConfig.headers || providerConfig.apiKey || providerConfig.compat) {
|
||||||
overrides.set(providerName, {
|
overrides.set(providerName, {
|
||||||
baseUrl: providerConfig.baseUrl,
|
baseUrl: providerConfig.baseUrl,
|
||||||
headers: providerConfig.headers,
|
headers: providerConfig.headers,
|
||||||
apiKey: providerConfig.apiKey,
|
apiKey: providerConfig.apiKey,
|
||||||
|
compat: providerConfig.compat,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,9 +419,11 @@ export class ModelRegistry {
|
|||||||
providerConfig.modelOverrides && Object.keys(providerConfig.modelOverrides).length > 0;
|
providerConfig.modelOverrides && Object.keys(providerConfig.modelOverrides).length > 0;
|
||||||
|
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
// Override-only config: needs baseUrl OR modelOverrides (or both)
|
// Override-only config: needs baseUrl, compat, modelOverrides, or some combination.
|
||||||
if (!providerConfig.baseUrl && !hasModelOverrides) {
|
if (!providerConfig.baseUrl && !providerConfig.compat && !hasModelOverrides) {
|
||||||
throw new Error(`Provider ${providerName}: must specify "baseUrl", "modelOverrides", or "models".`);
|
throw new Error(
|
||||||
|
`Provider ${providerName}: must specify "baseUrl", "compat", "modelOverrides", or "models".`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Custom models are merged into provider models and require endpoint + auth.
|
// 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
|
// Resolve env vars and shell commands in header values
|
||||||
const providerHeaders = resolveHeaders(providerConfig.headers);
|
const providerHeaders = resolveHeaders(providerConfig.headers);
|
||||||
const modelHeaders = resolveHeaders(modelDef.headers);
|
const modelHeaders = resolveHeaders(modelDef.headers);
|
||||||
|
const compat = mergeCompat(providerConfig.compat, modelDef.compat);
|
||||||
let headers = providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined;
|
let headers = providerHeaders || modelHeaders ? { ...providerHeaders, ...modelHeaders } : undefined;
|
||||||
|
|
||||||
// If authHeader is true, add Authorization header with resolved API key
|
// If authHeader is true, add Authorization header with resolved API key
|
||||||
@@ -484,7 +500,7 @@ export class ModelRegistry {
|
|||||||
contextWindow: modelDef.contextWindow ?? 128000,
|
contextWindow: modelDef.contextWindow ?? 128000,
|
||||||
maxTokens: modelDef.maxTokens ?? 16384,
|
maxTokens: modelDef.maxTokens ?? 16384,
|
||||||
headers,
|
headers,
|
||||||
compat: modelDef.compat,
|
compat,
|
||||||
} as Model<Api>);
|
} 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", () => {
|
test("model-level baseUrl overrides provider-level baseUrl for custom models", () => {
|
||||||
writeRawModelsJson({
|
writeRawModelsJson({
|
||||||
"opencode-go": {
|
"opencode-go": {
|
||||||
|
|||||||
Reference in New Issue
Block a user