From d874474c505d5516149ab74d9ca4e0ea6f17fef7 Mon Sep 17 00:00:00 2001 From: AndyS77 Date: Wed, 19 Aug 2026 19:40:49 +0200 Subject: [PATCH] fix(provider): strip trailing slash from model name strings Model names with trailing slashes (e.g. 'qwen3-4b/') caused lookup failures because split('/') produced an empty or slash-bearing modelID that didn't match stored keys. Strip trailing slashes before splitting in all model-string parse functions. Closes #43473 Co-Authored-By: zai-glm-52 Agent: @bug-fix Scope: #43473 --- packages/core/src/model.ts | 2 +- packages/core/test/model.test.ts | 14 ++++++++++++++ packages/opencode/src/acp/config-option.ts | 13 +++++++------ packages/opencode/src/cli/cmd/run.ts | 2 +- packages/opencode/src/provider/provider.ts | 2 +- .../opencode/test/acp/config-option.test.ts | 6 ++++++ .../opencode/test/provider/provider.test.ts | 18 ++++++++++++++++++ packages/tui/src/util/model.ts | 2 +- packages/tui/test/util/model.test.ts | 4 ++++ 9 files changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 52fff98733b1..5e277f2acacd 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -31,7 +31,7 @@ export type MutableInfo = Omit, "api"> & { } export function parse(input: string): { providerID: ProviderV2.ID; modelID: ID } { - const [providerID, ...modelID] = input.split("/") + const [providerID, ...modelID] = input.replace(/\/+$/, "").split("/") return { providerID: ProviderV2.ID.make(providerID), modelID: ID.make(modelID.join("/")), diff --git a/packages/core/test/model.test.ts b/packages/core/test/model.test.ts index fe97acc25aad..3d3c9198a3ae 100644 --- a/packages/core/test/model.test.ts +++ b/packages/core/test/model.test.ts @@ -21,3 +21,17 @@ describe("ModelV2.Ref", () => { }) }) }) + +describe("ModelV2.parse", () => { + test("parses provider/model string", () => { + const result = ModelV2.parse("anthropic/claude-sonnet-4") + expect(String(result.providerID)).toBe("anthropic") + expect(String(result.modelID)).toBe("claude-sonnet-4") + }) + + test("strips trailing slash", () => { + const result = ModelV2.parse("anthropic/claude-sonnet-4/") + expect(String(result.providerID)).toBe("anthropic") + expect(String(result.modelID)).toBe("claude-sonnet-4") + }) +}) diff --git a/packages/opencode/src/acp/config-option.ts b/packages/opencode/src/acp/config-option.ts index b730ae075336..7555c3c40902 100644 --- a/packages/opencode/src/acp/config-option.ts +++ b/packages/opencode/src/acp/config-option.ts @@ -113,9 +113,10 @@ export function buildConfigOptions(input: { } export function parseModelSelection(modelId: string, providers: readonly ConfigOptionProvider[]): ModelSelection { - const provider = providers.find((item) => modelId.startsWith(`${item.id}/`)) + const trimmed = modelId.replace(/\/+$/, "") + const provider = providers.find((item) => trimmed.startsWith(`${item.id}/`)) if (provider) { - const modelID = modelId.slice(provider.id.length + 1) + const modelID = trimmed.slice(provider.id.length + 1) if (provider.models[modelID]) { return { model: { providerID: provider.id, modelID } } } @@ -132,15 +133,15 @@ export function parseModelSelection(modelId: string, providers: readonly ConfigO return { model: { providerID: provider.id, modelID } } } - const separator = modelId.indexOf("/") + const separator = trimmed.indexOf("/") if (separator === -1) { - return { model: { providerID: modelId, modelID: "" } } + return { model: { providerID: trimmed, modelID: "" } } } return { model: { - providerID: modelId.slice(0, separator), - modelID: modelId.slice(separator + 1), + providerID: trimmed.slice(0, separator), + modelID: trimmed.slice(separator + 1), }, } } diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 3927f615a080..3dd04303120b 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -30,7 +30,7 @@ type ModelInput = Parameters[0]["model"] function pick(value: string | undefined): ModelInput | undefined { if (!value) return undefined - const [providerID, ...rest] = value.split("/") + const [providerID, ...rest] = value.replace(/\/+$/, "").split("/") return { providerID, modelID: rest.join("/"), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0200b212b21e..dfed15b1f200 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -2026,7 +2026,7 @@ export function sort(models: T[]) { } export function parseModel(model: string) { - const [providerID, ...rest] = model.split("/") + const [providerID, ...rest] = model.replace(/\/+$/, "").split("/") return { providerID: ProviderV2.ID.make(providerID), modelID: ModelV2.ID.make(rest.join("/")), diff --git a/packages/opencode/test/acp/config-option.test.ts b/packages/opencode/test/acp/config-option.test.ts index 846cfadad67e..f74700069f9d 100644 --- a/packages/opencode/test/acp/config-option.test.ts +++ b/packages/opencode/test/acp/config-option.test.ts @@ -194,6 +194,12 @@ describe("acp config options", () => { }) }) + test("strips trailing slash from model selection", () => { + expect(parseModelSelection("openai/gpt-5/", providers)).toEqual({ + model: { providerID: "openai", modelID: "gpt-5" }, + }) + }) + test("formats current model ids with and without selected variants", () => { expect( formatCurrentModelId({ diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index df23a5c4963e..2dfd8f1baef8 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -347,6 +347,24 @@ test("parseModel handles model IDs with slashes", () => { expect(String(result.modelID)).toBe("anthropic/claude-3-opus") }) +test("parseModel strips trailing slash from model string", () => { + const result = Provider.parseModel("lmstudio/qwen3-4b/") + expect(String(result.providerID)).toBe("lmstudio") + expect(String(result.modelID)).toBe("qwen3-4b") +}) + +test("parseModel strips trailing slash from provider/model string", () => { + const result = Provider.parseModel("anthropic/claude-sonnet-4/") + expect(String(result.providerID)).toBe("anthropic") + expect(String(result.modelID)).toBe("claude-sonnet-4") +}) + +test("parseModel strips multiple trailing slashes", () => { + const result = Provider.parseModel("anthropic/claude-sonnet-4//") + expect(String(result.providerID)).toBe("anthropic") + expect(String(result.modelID)).toBe("claude-sonnet-4") +}) + it.instance("defaultModel returns first available model when no config set", () => Effect.gen(function* () { yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key") diff --git a/packages/tui/src/util/model.ts b/packages/tui/src/util/model.ts index b6a5c77f545d..ce1d301f7ae0 100644 --- a/packages/tui/src/util/model.ts +++ b/packages/tui/src/util/model.ts @@ -1,7 +1,7 @@ import type { Provider } from "@opencode-ai/sdk/v2" export function parse(value: string) { - const [providerID, ...modelID] = value.split("/") + const [providerID, ...modelID] = value.replace(/\/+$/, "").split("/") return { providerID, modelID: modelID.join("/") } } diff --git a/packages/tui/test/util/model.test.ts b/packages/tui/test/util/model.test.ts index 662b1b321033..1c5adf534abe 100644 --- a/packages/tui/test/util/model.test.ts +++ b/packages/tui/test/util/model.test.ts @@ -6,4 +6,8 @@ describe("util.model", () => { expect(parse("provider/org/model")).toEqual({ providerID: "provider", modelID: "org/model" }) expect(parse("invalid")).toEqual({ providerID: "invalid", modelID: "" }) }) + + test("strips trailing slash", () => { + expect(parse("provider/org/model/")).toEqual({ providerID: "provider", modelID: "org/model" }) + }) })