fix(coding-agent): allow provider-scoped custom model ids (#1759)

This commit is contained in:
Scott 2026-03-04 00:20:49 +11:00 committed by GitHub
parent 693187a3fb
commit 6f4bd814b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 73 additions and 7 deletions

View file

@ -113,6 +113,22 @@ export interface ParsedModelResult {
warning: string | undefined;
}
function buildFallbackModel(provider: string, modelId: string, availableModels: Model<Api>[]): Model<Api> | undefined {
const providerModels = availableModels.filter((m) => m.provider === provider);
if (providerModels.length === 0) return undefined;
const defaultId = defaultModelPerProvider[provider as KnownProvider];
const baseModel = defaultId
? (providerModels.find((m) => m.id === defaultId) ?? providerModels[0])
: providerModels[0];
return {
...baseModel,
id: modelId,
name: modelId,
};
}
/**
* Parse a pattern to extract model and thinking level.
* Handles models with colons in their IDs (e.g., OpenRouter's :exacto suffix).
@ -387,6 +403,16 @@ export function resolveCliModel(options: {
}
}
if (provider) {
const fallbackModel = buildFallbackModel(provider, pattern, availableModels);
if (fallbackModel) {
const fallbackWarning = warning
? `${warning} Model "${pattern}" not found for provider "${provider}". Using custom model id.`
: `Model "${pattern}" not found for provider "${provider}". Using custom model id.`;
return { model: fallbackModel, thinkingLevel: undefined, warning: fallbackWarning, error: undefined };
}
}
const display = provider ? `${provider}/${pattern}` : cliModel;
return {
model: undefined,
@ -436,12 +462,18 @@ export async function findInitialModel(options: {
// 1. CLI args take priority
if (cliProvider && cliModel) {
const found = modelRegistry.find(cliProvider, cliModel);
if (!found) {
console.error(chalk.red(`Model ${cliProvider}/${cliModel} not found`));
const resolved = resolveCliModel({
cliProvider,
cliModel,
modelRegistry,
});
if (resolved.error) {
console.error(chalk.red(resolved.error));
process.exit(1);
}
return { model: found, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
if (resolved.model) {
return { model: resolved.model, thinkingLevel: DEFAULT_THINKING_LEVEL, fallbackMessage: undefined };
}
}
// 2. Use first model from scoped models (skip if continuing/resuming)

View file

@ -268,7 +268,7 @@ describe("resolveCliModel", () => {
expect(result.model?.id).toBe("openai/gpt-4o:extended");
});
test("does not strip invalid :suffix as thinking level in --model (fail fast)", () => {
test("does not strip invalid :suffix as thinking level in --model (treat as raw id)", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
@ -279,8 +279,25 @@ describe("resolveCliModel", () => {
modelRegistry: registry,
});
expect(result.model).toBeUndefined();
expect(result.error).toContain("not found");
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("openai");
expect(result.model?.id).toBe("gpt-4o:extended");
});
test("allows custom model ids for explicit providers without double prefixing", () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof resolveCliModel>[0]["modelRegistry"];
const result = resolveCliModel({
cliProvider: "openrouter",
cliModel: "openrouter/openai/ghost-model",
modelRegistry: registry,
});
expect(result.error).toBeUndefined();
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("openai/ghost-model");
});
test("returns a clear error when there are no models", () => {
@ -360,6 +377,23 @@ describe("default model selection", () => {
expect(defaultModelPerProvider["vercel-ai-gateway"]).toBe("anthropic/claude-opus-4-6");
});
test("findInitialModel accepts explicit provider custom model ids", async () => {
const registry = {
getAll: () => allModels,
} as unknown as Parameters<typeof findInitialModel>[0]["modelRegistry"];
const result = await findInitialModel({
cliProvider: "openrouter",
cliModel: "openrouter/openai/ghost-model",
scopedModels: [],
isContinuing: false,
modelRegistry: registry,
});
expect(result.model?.provider).toBe("openrouter");
expect(result.model?.id).toBe("openai/ghost-model");
});
test("findInitialModel selects ai-gateway default when available", async () => {
const aiGatewayModel: Model<"anthropic-messages"> = {
id: "anthropic/claude-opus-4-6",