summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--src/app/App.test.ts4
-rw-r--r--src/app/store.svelte.ts33
-rw-r--r--src/app/store.test.ts73
-rw-r--r--src/features/chat/index.ts8
-rw-r--r--src/features/chat/model-select.test.ts56
-rw-r--r--src/features/chat/model-select.ts58
-rw-r--r--src/features/chat/ui.test.ts37
-rw-r--r--src/features/chat/ui/ModelSelector.svelte31
-rw-r--r--src/features/concurrency/logic/view-model.test.ts28
-rw-r--r--src/features/concurrency/logic/view-model.ts12
-rw-r--r--src/features/concurrency/ui/ConcurrencyLimitRow.svelte5
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.svelte3
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.test.ts4
13 files changed, 309 insertions, 43 deletions
diff --git a/src/app/App.test.ts b/src/app/App.test.ts
index f43a6b8..06e562f 100644
--- a/src/app/App.test.ts
+++ b/src/app/App.test.ts
@@ -58,7 +58,7 @@ function fakeFetchImpl(): typeof fetch {
return async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
@@ -492,7 +492,7 @@ describe("App component interaction tests", () => {
const failingFetch = async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 8997ce0..e11a445 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -53,7 +53,7 @@ import {
unsubscribe as protocolUnsubscribe,
} from "../core/protocol";
import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
-import { createChatStore } from "../features/chat";
+import { createChatStore, migrateLegacyModelName } from "../features/chat";
import type { SetThinkingRequest, ThinkingResponse } from "../features/chat/reasoning-effort";
import type {
ConcurrencyCooldownResult,
@@ -91,7 +91,7 @@ import { resolveHttpUrl } from "./resolve-http-url";
import { resolveWsUrl } from "./resolve-ws-url";
import { randomId } from "./uuid";
-const DEFAULT_MODEL = "opencode/deepseek-v4-flash";
+const DEFAULT_MODEL = "opencode-1/deepseek-v4-flash";
/** Outcome of a manual `POST /chat/warm` (the "warm now" affordance). */
export type WarmResult =
@@ -1112,7 +1112,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
// authoritative conversation record is the source of truth, so a
// differing tab workspace is corrected here.
tabsStore.setTitle(conv.id, conv.title);
- if (tabsStore.tabs.find((t) => t.conversationId === conv.id)?.workspaceId !== conv.workspaceId) {
+ if (
+ tabsStore.tabs.find((t) => t.conversationId === conv.id)?.workspaceId !==
+ conv.workspaceId
+ ) {
tabsStore.setWorkspaceId(conv.id, conv.workspaceId);
}
}
@@ -1231,12 +1234,13 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
if (data === undefined) return;
models = data.models;
modelInfo = data.modelInfo ?? {};
- if (data.models.length > 0 && !data.models.includes(activeModel)) {
- const first = data.models[0];
- if (first !== undefined) {
- activeModel = first;
- draftStore.setModel(first);
- }
+ // Do NOT silently switch the active model when the current selection is
+ // missing from the catalog (e.g. a saved numbered OpenCode credential was
+ // removed). The model selector surfaces it as unavailable; the user picks
+ // explicitly. activeModel is still normalized through the legacy-migration
+ // helper so an old `opencode/...` value is rewritten once.
+ if (migrateLegacyModelName(activeModel) !== activeModel) {
+ activeModel = migrateLegacyModelName(activeModel);
}
})
.catch((err) => {
@@ -1262,7 +1266,14 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
const persistedState = storageAdapter.load();
if (persistedState !== null && persistedState.tabs.length > 0) {
for (const tab of persistedState.tabs) {
- const store = createChatFor(tab.conversationId, tab.model, tab.workspaceId);
+ // One-time persisted-state migration: rewrite a legacy `opencode/<model>`
+ // tab model to the numbered `opencode-1/<model>` namespace, and persist the
+ // migrated value back so a re-load is a no-op (idempotent).
+ const model = migrateLegacyModelName(tab.model);
+ if (model !== tab.model) {
+ tabsStore.setModel(tab.conversationId, model);
+ }
+ const store = createChatFor(tab.conversationId, model, tab.workspaceId);
chatStores.set(tab.conversationId, store);
void store.load();
// Watch each restored conversation's live turns: after a reload mid-turn the
@@ -1275,7 +1286,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
(t) => t.conversationId === persistedState.activeConversationId,
);
if (activeTab !== undefined) {
- activeModel = activeTab.model;
+ activeModel = migrateLegacyModelName(activeTab.model);
}
}
}
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 47c3977..16d1760 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -107,7 +107,7 @@ interface FakeFetchOptions {
}
function fakeFetchImpl(opts?: FakeFetchOptions): typeof fetch {
- const models = opts?.models ?? ["opencode/deepseek-v4-flash", "openai/gpt-4o"];
+ const models = opts?.models ?? ["opencode-1/deepseek-v4-flash", "openai/gpt-4o"];
const history = opts?.history ?? {};
return async (input: string | URL | Request): Promise<Response> => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
@@ -524,7 +524,7 @@ describe("createAppStore", () => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
fetchedUrls.push(url);
if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
@@ -573,7 +573,7 @@ describe("createAppStore", () => {
const store = createAppStore({
socketFactory: () => ws,
fetchImpl: fakeFetchImpl({
- models: ["opencode/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"],
+ models: ["opencode-1/deepseek-v4-flash", "openai/gpt-4o", "anthropic/claude-3"],
}),
localStorage: createFakeStorage(),
});
@@ -582,7 +582,7 @@ describe("createAppStore", () => {
await new Promise((r) => setTimeout(r, 50));
expect(store.models).toEqual([
- "opencode/deepseek-v4-flash",
+ "opencode-1/deepseek-v4-flash",
"openai/gpt-4o",
"anthropic/claude-3",
]);
@@ -599,11 +599,62 @@ describe("createAppStore", () => {
});
ws.resolveOpen();
- expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
store.dispose();
});
+ it("does not silently switch the active model when the selected one is absent from the catalog", async () => {
+ // The catalog only has umans; the active (default/selected) OpenCode model is
+ // NOT available. The FE must NOT auto-fall back to the first catalog model —
+ // it keeps the selection and lets the model selector surface it as unavailable.
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl({ models: ["umans/umans-glm-5.2"] }),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+ await new Promise((r) => setTimeout(r, 50));
+
+ expect(store.models).toEqual(["umans/umans-glm-5.2"]);
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
+
+ store.dispose();
+ });
+
+ it("migrates a legacy persisted opencode/<model> tab model to opencode-1/<model>", async () => {
+ const ws = fakeSocket();
+ // Seed localStorage with a persisted tab whose model is the OBSOLETE
+ // `opencode/deepseek-v4-flash` namespace.
+ const storage = createFakeStorage();
+ storage.setItem(
+ "dispatch.tabs",
+ JSON.stringify({
+ tabs: [
+ {
+ conversationId: "c-legacy",
+ title: "Legacy",
+ model: "opencode/deepseek-v4-flash",
+ workspaceId: "default",
+ },
+ ],
+ activeConversationId: null,
+ }),
+ );
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: storage,
+ });
+ ws.resolveOpen();
+ // After restore, the restored tab's model + the persisted store are migrated.
+ const restored = store.tabs.find((t) => t.conversationId === "c-legacy");
+ expect(restored?.model).toBe("opencode-1/deepseek-v4-flash");
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
+ store.dispose();
+ });
+
it("draft: sending the first message creates a tab titled from the message", () => {
const ws = fakeSocket();
const store = createAppStore({
@@ -679,7 +730,7 @@ describe("createAppStore", () => {
store.send("first message");
// New tab opens with the default model until the persisted-model fetch resolves.
- expect(store.activeModel).toBe("opencode/deepseek-v4-flash");
+ expect(store.activeModel).toBe("opencode-1/deepseek-v4-flash");
// Wait for the persisted model fetch to resolve.
await vi.waitFor(() => expect(store.activeModel).toBe("openai/gpt-4o"));
@@ -1212,7 +1263,7 @@ describe("createAppStore", () => {
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
fetchedUrls.push(url);
if (url.endsWith("/models")) {
- return new Response(JSON.stringify({ models: ["opencode/deepseek-v4-flash"] }), {
+ return new Response(JSON.stringify({ models: ["opencode-1/deepseek-v4-flash"] }), {
status: 200,
});
}
@@ -1518,7 +1569,7 @@ describe("createAppStore", () => {
const limits = opts?.limits ?? {
limits: [
{ providerId: "umans", limit: 4 },
- { providerId: "openai-compat", limit: 5 },
+ { providerId: "opencode-1", limit: 5 },
],
};
const limit = opts?.limit ?? { providerId: "umans", limit: 4 };
@@ -1526,7 +1577,7 @@ describe("createAppStore", () => {
providers: [
{ providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
{
- providerId: "openai-compat",
+ providerId: "opencode-1",
limit: 5,
inFlight: 5,
queued: 3,
@@ -1586,7 +1637,7 @@ describe("createAppStore", () => {
if (!result.ok) throw new Error("unreachable");
expect(result.limits).toEqual([
{ providerId: "umans", limit: 4 },
- { providerId: "openai-compat", limit: 5 },
+ { providerId: "opencode-1", limit: 5 },
]);
store.dispose();
});
@@ -1758,7 +1809,7 @@ describe("createAppStore", () => {
autoReduced: false,
});
expect(result.providers[1]).toMatchObject({
- providerId: "openai-compat",
+ providerId: "opencode-1",
paused: true,
pausedUntil: 1_719_408_000_000,
cooldownMs: 350,
diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts
index 2f98a0e..8cf776b 100644
--- a/src/features/chat/index.ts
+++ b/src/features/chat/index.ts
@@ -6,7 +6,13 @@ export type {
} from "../../core/chunks";
export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks";
export type { TurnMetricsEntry } from "../../core/metrics";
-export { isVisionModel } from "./model-select";
+export {
+ isNumberedOpenCode,
+ isVisionModel,
+ migrateLegacyModelName,
+ modelKeyLabel,
+ openCodeNumber,
+} from "./model-select";
export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports";
export type {
EffortOption,
diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts
index 6d3081d..33c1a16 100644
--- a/src/features/chat/model-select.test.ts
+++ b/src/features/chat/model-select.test.ts
@@ -2,8 +2,11 @@ import { describe, expect, it } from "vitest";
import {
isVisionModel,
joinModelName,
+ migrateLegacyModelName,
+ modelKeyLabel,
modelKeys,
modelsForKey,
+ openCodeNumber,
splitModelName,
} from "./model-select";
@@ -47,11 +50,64 @@ describe("modelKeys", () => {
).toEqual(["openai", "anthropic", "google"]);
});
+ it("sorts numbered OpenCode keys numerically (#2 before #10)", () => {
+ expect(
+ modelKeys(["opencode-10/models", "opencode-2/models", "opencode-1/models", "openai/gpt-4"]),
+ ).toEqual(["opencode-1", "opencode-2", "opencode-10", "openai"]);
+ });
+
it("is empty for no models", () => {
expect(modelKeys([])).toEqual([]);
});
});
+describe("openCodeNumber", () => {
+ it("parses a numbered OpenCode key", () => {
+ expect(openCodeNumber("opencode-1")).toBe(1);
+ expect(openCodeNumber("opencode-10")).toBe(10);
+ });
+
+ it("returns null for non-openCode / malformed keys", () => {
+ expect(openCodeNumber("umans")).toBeNull();
+ expect(openCodeNumber("opencode")).toBeNull();
+ expect(openCodeNumber("opencode-0")).toBeNull();
+ expect(openCodeNumber("opencode-01")).toBeNull();
+ });
+});
+
+describe("modelKeyLabel", () => {
+ it("labels a numbered OpenCode key as OpenCode #N", () => {
+ expect(modelKeyLabel("opencode-2")).toBe("OpenCode #2");
+ expect(modelKeyLabel("opencode-10")).toBe("OpenCode #10");
+ });
+
+ it("leaves non-OpenCode keys unchanged", () => {
+ expect(modelKeyLabel("umans")).toBe("umans");
+ });
+});
+
+describe("migrateLegacyModelName", () => {
+ it("rewrites opencode/<model> to opencode-1/<model>", () => {
+ expect(migrateLegacyModelName("opencode/deepseek-v4-flash")).toBe(
+ "opencode-1/deepseek-v4-flash",
+ );
+ expect(migrateLegacyModelName("opencode/nested/model")).toBe("opencode-1/nested/model");
+ });
+
+ it("leaves already-numbered and other-provider names unchanged", () => {
+ expect(migrateLegacyModelName("opencode-1/deepseek-v4-flash")).toBe(
+ "opencode-1/deepseek-v4-flash",
+ );
+ expect(migrateLegacyModelName("umans/umans-glm-5.2")).toBe("umans/umans-glm-5.2");
+ expect(migrateLegacyModelName("opencode-2/x")).toBe("opencode-2/x");
+ });
+
+ it("is idempotent", () => {
+ const once = migrateLegacyModelName("opencode/deepseek-v4-flash");
+ expect(migrateLegacyModelName(once)).toBe(once);
+ });
+});
+
describe("modelsForKey", () => {
it("returns the model suffixes under a key, in order", () => {
const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"];
diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts
index 602e0ef..5da6e7b 100644
--- a/src/features/chat/model-select.ts
+++ b/src/features/chat/model-select.ts
@@ -29,15 +29,61 @@ export function joinModelName(key: string, model: string): string {
/** Distinct keys across all models, in first-seen order. */
export function modelKeys(models: readonly string[]): string[] {
const seen = new Set<string>();
- const out: string[] = [];
+ const openCodeKeys: Array<{ key: string; n: number }> = [];
+ const otherKeys: string[] = [];
for (const full of models) {
const { key } = splitModelName(full);
- if (!seen.has(key)) {
- seen.add(key);
- out.push(key);
- }
+ if (seen.has(key)) continue;
+ seen.add(key);
+ const n = openCodeNumber(key);
+ if (n !== null) openCodeKeys.push({ key, n });
+ else otherKeys.push(key);
}
- return out;
+ // Numbered OpenCode credentials sort NUMERICALLY (#2 before #10), placed
+ // before other providers (the primary credential in this app). Others keep
+ // first-seen order.
+ openCodeKeys.sort((a, b) => a.n - b.n);
+ return [...openCodeKeys.map((e) => e.key), ...otherKeys];
+}
+
+/**
+ * The stable numeric identity of a numbered OpenCode provider id/key
+ * (`opencode-1` → 1, `opencode-10` → 10), or `null` when the id is not a
+ * numbered OpenCode key. Numbers are canonical positive integers with no
+ * leading zeros.
+ */
+export function openCodeNumber(key: string): number | null {
+ const m = /^opencode-([1-9][0-9]*)$/.exec(key);
+ return m === null ? null : Number(m[1]);
+}
+
+/** True iff `id` is a numbered OpenCode provider/key (`opencode-<n>`). */
+export function isNumberedOpenCode(id: string): boolean {
+ return openCodeNumber(id) !== null;
+}
+
+/**
+ * A friendly human label for a model key/provider id: `opencode-<n>` →
+ * "OpenCode #<n>"; any other id is returned unchanged. The RAW id is still what
+ * gets selected/persisted — this only affects display.
+ */
+export function modelKeyLabel(key: string): string {
+ const n = openCodeNumber(key);
+ return n === null ? key : `OpenCode #${n}`;
+}
+
+/**
+ * Migration helper: rewrite a legacy `opencode/<model>` full model name to the
+ * numbered `opencode-1/<model>` namespace. Returns the input unchanged for
+ * anything that is not `opencode/`-prefixed (already-numbered names, other
+ * providers). Idempotent. This is the FE-side persisted-state migration; the
+ * backend performs its own equivalent on its own store.
+ */
+export function migrateLegacyModelName(full: string): string {
+ const legacyPrefix = "opencode/";
+ if (!full.startsWith(legacyPrefix)) return full;
+ const model = full.slice(legacyPrefix.length);
+ return model.length === 0 ? full : `opencode-1/${model}`;
}
/** The model suffixes available under a given key, in order. */
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index f4006f7..aa43e04 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -1089,6 +1089,43 @@ describe("ModelSelector", () => {
});
expect(screen.getByText(/auto-described/i)).toBeInTheDocument();
});
+
+ it("labels numbered OpenCode keys as OpenCode #N and sorts them numerically", () => {
+ const models = ["opencode-10/m1", "opencode-2/m2", "opencode-1/m3"];
+ const onSelect = vi.fn();
+ render(ModelSelector, {
+ props: { models, selected: "opencode-2/m2", onSelect },
+ });
+
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ const options = within(keySelect).getAllByRole("option");
+ // Numeric order: #1 < #2 < #10, and values stay RAW (opencode-N).
+ expect(options.map((o) => (o as HTMLOptionElement).value)).toEqual([
+ "opencode-1",
+ "opencode-2",
+ "opencode-10",
+ ]);
+ expect(options[0]?.textContent).toContain("OpenCode #1");
+ expect(options[1]?.textContent).toContain("OpenCode #2");
+ expect(options[2]?.textContent).toContain("OpenCode #10");
+ });
+
+ it("shows a disappeared saved credential as unavailable without silently falling back", () => {
+ // Saved selection references opencode-5, but the catalog no longer has it.
+ const models = ["opencode-1/deepseek-v4-flash"];
+ const onSelect = vi.fn();
+ render(ModelSelector, {
+ props: { models, selected: "opencode-5/deepseek-v4-flash", onSelect },
+ });
+
+ // The unavailable key is surfaced as an explicit (disabled) option + notice.
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ const unavailable = within(keySelect).getByText(/OpenCode #5 \(unavailable\)/i);
+ expect(unavailable).toBeInTheDocument();
+ expect(screen.getByText(/Selected model .* unavailable/)).toBeInTheDocument();
+ // No silent switch is attempted.
+ expect(onSelect).not.toHaveBeenCalled();
+ });
});
describe("ReasoningEffortSelector", () => {
diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte
index 11b9feb..1d06848 100644
--- a/src/features/chat/ui/ModelSelector.svelte
+++ b/src/features/chat/ui/ModelSelector.svelte
@@ -1,6 +1,13 @@
<script lang="ts">
import type { ModelMetadata } from "@dispatch/transport-contract";
- import { isVisionModel, joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select";
+ import {
+ isVisionModel,
+ joinModelName,
+ modelKeyLabel,
+ modelKeys,
+ modelsForKey,
+ splitModelName,
+ } from "../model-select";
let {
models,
@@ -27,8 +34,17 @@
// Whether the currently-selected full model name is vision-capable.
const selectedVision = $derived(isVisionModel(modelInfo, selected));
+ // Whether the currently-selected full name is PRESENT in the catalog. When a
+ // saved credential/model disappears, we show it as unavailable rather than
+ // silently switching to something else.
+ const selectedUnavailable = $derived(selected !== "" && !models.includes(selected));
+ // The key part is itself missing from the catalog (e.g. a whole numbered
+ // OpenCode credential was removed).
+ const keyUnavailable = $derived(selected !== "" && !keys.includes(current.key));
+
// Switching key jumps to the first model available under it.
function selectKey(key: string): void {
+ if (key === current.key) return;
const first = modelsForKey(models, key)[0] ?? "";
onSelect(joinModelName(key, first));
}
@@ -50,8 +66,11 @@
onchange={(e) => selectKey(e.currentTarget.value)}
aria-label="Key selector"
>
+ {#if keyUnavailable}
+ <option value={current.key} disabled>{modelKeyLabel(current.key)} (unavailable)</option>
+ {/if}
{#each keys as key (key)}
- <option value={key}>{key}</option>
+ <option value={key}>{modelKeyLabel(key)}</option>
{/each}
</select>
<select
@@ -65,7 +84,15 @@
{model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if}
</option>
{/each}
+ {#if selectedUnavailable && !keyUnavailable}
+ <option value={current.model} disabled>{current.model} (unavailable)</option>
+ {/if}
</select>
+ {#if selectedUnavailable}
+ <div class="text-xs text-warning" role="status">
+ Selected model “{selected}” is unavailable — choose another credential.
+ </div>
+ {/if}
{#if selectedVision}
<div class="flex items-center gap-1 text-xs text-base-content/60">
<svg
diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts
index 6e6b770..9c557f5 100644
--- a/src/features/concurrency/logic/view-model.test.ts
+++ b/src/features/concurrency/logic/view-model.test.ts
@@ -13,6 +13,7 @@ import {
parseLimitInput,
pauseLabel,
providerFromModel,
+ providerLabel,
providerOptions,
statusLabel,
summarizeLimits,
@@ -95,7 +96,7 @@ describe("cooldownLabel", () => {
describe("providerFromModel", () => {
it("takes the part before the first slash", () => {
expect(providerFromModel("openai/gpt-4o")).toBe("openai");
- expect(providerFromModel("openai-compat/gpt-4o-mini")).toBe("openai-compat");
+ expect(providerFromModel("opencode-1/gpt-4o-mini")).toBe("opencode-1");
});
it("returns the whole string when there is no slash", () => {
expect(providerFromModel("umans")).toBe("umans");
@@ -127,6 +128,23 @@ describe("providerOptions", () => {
});
});
+describe("providerLabel", () => {
+ it("labels a numbered OpenCode provider as OpenCode #N", () => {
+ expect(providerLabel("opencode-2")).toBe("OpenCode #2");
+ expect(providerLabel("opencode-10")).toBe("OpenCode #10");
+ });
+
+ it("leaves non-OpenCode provider ids unchanged", () => {
+ expect(providerLabel("umans")).toBe("umans");
+ expect(providerLabel("anthropic")).toBe("anthropic");
+ });
+
+ it("rejects noncanonical numbered ids (label passthrough)", () => {
+ expect(providerLabel("opencode")).toBe("opencode");
+ expect(providerLabel("opencode-0")).toBe("opencode-0");
+ });
+});
+
// ── pauseLabel + formatPauseDuration ───────────────────────────────────────────
describe("formatPauseDuration", () => {
@@ -450,12 +468,12 @@ describe("normalizeConcurrencyLimits", () => {
const limits = normalizeConcurrencyLimits({
limits: [
{ providerId: "umans", limit: 4 },
- { providerId: "openai-compat", limit: 5 },
+ { providerId: "opencode-1", limit: 5 },
],
});
expect(limits).toEqual([
{ providerId: "umans", limit: 4 },
- { providerId: "openai-compat", limit: 5 },
+ { providerId: "opencode-1", limit: 5 },
]);
});
@@ -510,7 +528,7 @@ describe("normalizeConcurrencyStatus", () => {
providers: [
{ providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false },
{
- providerId: "openai-compat",
+ providerId: "opencode-1",
limit: 5,
inFlight: 5,
queued: 3,
@@ -532,7 +550,7 @@ describe("normalizeConcurrencyStatus", () => {
});
expect(first !== undefined && !("pausedUntil" in first)).toBe(true);
expect(second).toEqual({
- providerId: "openai-compat",
+ providerId: "opencode-1",
limit: 5,
inFlight: 5,
queued: 3,
diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts
index 8a37198..80c155c 100644
--- a/src/features/concurrency/logic/view-model.ts
+++ b/src/features/concurrency/logic/view-model.ts
@@ -170,6 +170,18 @@ export function providerOptions(
return out;
}
+/**
+ * Friendly display label for a concurrency provider id. A numbered OpenCode
+ * provider (`opencode-<n>`) renders as "OpenCode #<n>" (so "opencode-2" reads
+ * as a coherent credential identity, matching the model picker); any other id is
+ * shown unchanged. The RAW id is still what is sent to / displayed-with the
+ * backend — this only affects presentation.
+ */
+export function providerLabel(providerId: string): string {
+ const m = /^opencode-([1-9][0-9]*)$/.exec(providerId);
+ return m === null ? providerId : `OpenCode #${m[1]}`;
+}
+
// ── Status → display view ──────────────────────────────────────────────────────
const NO_LIMITS = "No limits configured";
diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
index bf06ac0..c8f6c8f 100644
--- a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
+++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte
@@ -4,6 +4,7 @@
DEFAULT_COOLDOWN_MS,
parseCooldownInput,
parseLimitInput,
+ providerLabel,
statusLabel,
type Badge,
type ConcurrencyLimitView,
@@ -132,8 +133,8 @@
the buttons never wrap; tight gap + narrow inputs keep the provider name
visible; the provider shrinks via flex-1 + min-w-0). -->
<div class="flex flex-nowrap items-center gap-1">
- <span class="min-w-0 flex-1 truncate font-medium font-mono" title={limit.providerId}
- >{limit.providerId}</span
+ <span class="min-w-0 flex-1 truncate font-medium" title={limit.providerId}
+ >{providerLabel(limit.providerId)}</span
>
<input
type="text"
diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte
index aadb8d1..2e23f5c 100644
--- a/src/features/concurrency/ui/ConcurrencyView.svelte
+++ b/src/features/concurrency/ui/ConcurrencyView.svelte
@@ -6,6 +6,7 @@
DEFAULT_COOLDOWN_MS,
parseCooldownInput,
parseLimitInput,
+ providerLabel,
providerOptions,
summarizeLimits,
viewConcurrencyLimits,
@@ -376,7 +377,7 @@
<option value="" disabled>No providers available</option>
{:else}
{#each providerOpts as provider (provider)}
- <option value={provider}>{provider}</option>
+ <option value={provider}>{providerLabel(provider)}</option>
{/each}
{/if}
</select>
diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts
index a8163c2..e214692 100644
--- a/src/features/concurrency/ui/ConcurrencyView.test.ts
+++ b/src/features/concurrency/ui/ConcurrencyView.test.ts
@@ -11,8 +11,8 @@ import type {
} from "../logic/types";
import ConcurrencyView from "./ConcurrencyView.svelte";
-// Available models → provider ids are "umans", "anthropic", "openai-compat".
-const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "openai-compat/gpt-4o"] as const;
+// Available models → provider ids are "umans", "anthropic", "opencode-1".
+const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "opencode-1/gpt-4o"] as const;
// A status entry factory (defaults to a healthy limited provider). The new
// concurrency-fixes fields (`cooldownMs`, `autoReduced`) are always present.