Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import type { ComposerEditorSelection } from "../../components/ComposerEditor";
import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView";
import { SegmentedControl } from "../../components/SegmentedControl";
import { ThemedSwitch } from "../../components/ThemedSwitch";
import { buildModelOptions } from "../../lib/modelOptions";
import { buildModelOptions, selectedModelOptionLabel } from "../../lib/modelOptions";
import { NativeStackScreenOptions } from "../../native/StackHeader";
import { useProjects, useEnvironmentServerConfig } from "../../state/entities";
import { useEnvironmentQuery } from "../../state/query";
Expand Down Expand Up @@ -569,6 +569,11 @@ function TaskForm({
const projects = useProjects().filter((project) => project.environmentId === environmentId);
const config = useEnvironmentServerConfig(environmentId);
const modelOptions = useMemo(() => buildModelOptions(config, null), [config]);
const selectedModelOption = modelOptions.find(
(option) =>
option.selection.instanceId === draft.modelSelection?.instanceId &&
option.selection.model === draft.modelSelection?.model,
);
const upsert = useAtomCommand(serverEnvironment.upsertScheduledTask, {
label: "scheduled task upsert",
reportFailure: false,
Expand Down Expand Up @@ -736,13 +741,10 @@ function TaskForm({
label="Model"
borderTop
value={
modelOptions.find(
(option) =>
option.selection.instanceId === draft.modelSelection?.instanceId &&
option.selection.model === draft.modelSelection?.model,
)?.label ??
draft.modelSelection?.model ??
(modelOptions.length ? "Choose model" : "No models available")
selectedModelOption
? selectedModelOptionLabel(selectedModelOption)
: (draft.modelSelection?.model ??
(modelOptions.length ? "Choose model" : "No models available"))
}
onPress={() => navigation.navigate("SettingsScheduledTaskModel")}
disabled={saving || dictationPending || environmentUnavailable}
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/features/threads/NewTaskDraftScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ import { ProjectCloneBanner } from "../../components/ProjectCloneBanner";
import {
isModelSelectionUnavailable,
resolveSelectableModelSelection,
selectedModelOptionLabel,
} from "../../lib/modelOptions";
import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration";
import { enqueueThreadOutboxMessage } from "../../state/thread-outbox";
Expand Down Expand Up @@ -1754,7 +1755,11 @@ export function NewTaskDraftScreen(props: {
size={size}
/>
)}
label={flow.selectedModelOption?.label ?? "Choose model"}
label={
flow.selectedModelOption
? selectedModelOptionLabel(flow.selectedModelOption)
: "Choose model"
}
maxWidth="100%"
onPress={settingsSheetPresentation.open}
/>
Expand Down
7 changes: 6 additions & 1 deletion apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
buildModelOptions,
groupByProvider,
isModelSelectionUnavailable,
selectedModelOptionLabel,
} from "../../lib/modelOptions";
import { useScaledTextRole } from "../settings/appearance/useScaledTextRole";
import type { RemoteClientConnectionState } from "../../lib/connection";
Expand Down Expand Up @@ -1129,7 +1130,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
size={size}
/>
)}
label={currentModelOption?.label ?? currentModelSelection.model}
label={
currentModelOption
? selectedModelOptionLabel(currentModelOption)
: currentModelSelection.model
}
maxWidth="100%"
onPress={openSettings}
/>
Expand Down
55 changes: 55 additions & 0 deletions apps/mobile/src/lib/modelOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
resolveDefaultableModelSelection,
resolveNewTaskModelSelection,
resolveSelectableModelSelection,
selectedModelOptionLabel,
type ModelOption,
} from "./modelOptions";

Expand Down Expand Up @@ -141,6 +142,60 @@ describe("mobile model options", () => {
]);
});

it("derives an upstream provider subtitle from the slug when the catalog omits one", () => {
const config = {
providers: [
{
instanceId: "opencode_work",
driver: "opencode",
displayName: "OpenCode Work",
enabled: true,
installed: true,
auth: { status: "authenticated" },
models: [
{
slug: "deepseek/deepseek-v4.1-flash",
name: "DeepSeek V4.1 Flash",
isCustom: false,
capabilities: null,
},
{
slug: "opencode-go/deepseek-v4.1-flash",
name: "DeepSeek V4.1 Flash",
isCustom: false,
capabilities: null,
},
],
},
],
} as unknown as ServerConfig;

const options = buildModelOptions(config, null);

// Same label, distinct upstream providers; exact slugs still route.
expect(options.map((option) => option.label)).toEqual([
"DeepSeek V4.1 Flash",
"DeepSeek V4.1 Flash",
]);
expect(options.map((option) => option.subtitle)).toEqual(["DeepSeek", "OpenCode Go"]);
expect(options.map((option) => option.selection.model)).toEqual([
"deepseek/deepseek-v4.1-flash",
"opencode-go/deepseek-v4.1-flash",
]);
});

it("appends the upstream provider to the selected-model trigger only for OpenCode", () => {
const openCode = {
providerDriver: "opencode",
label: "DeepSeek V4.1 Flash",
subtitle: "OpenCode Go",
} as unknown as ModelOption;
expect(selectedModelOptionLabel(openCode)).toBe("DeepSeek V4.1 Flash · OpenCode Go");

const codex = { ...openCode, providerDriver: "codex", subtitle: "OpenAI" };
expect(selectedModelOptionLabel(codex)).toBe("DeepSeek V4.1 Flash");
});

it("does not materialize catalog defaults for missing stored options", () => {
const config = {
providers: [
Expand Down
17 changes: 15 additions & 2 deletions apps/mobile/src/lib/modelOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
import {
buildExplicitProviderOptionSelectionsFromDescriptors,
getProviderOptionDescriptors,
resolveModelProviderLabel,
} from "@t3tools/shared/model";

export type ModelOption = {
Expand All @@ -32,6 +33,16 @@ export type ProviderGroup = {
readonly models: ReadonlyArray<ModelOption>;
};

/**
* Composer trigger label for the selected model. Only OpenCode crowds one
* instance with same-named models from different upstreams, so only it
* appends the upstream provider; other providers keep their plain label.
*/
export function selectedModelOptionLabel(option: ModelOption): string {
const upstream = option.providerDriver === "opencode" ? option.subtitle : "";
return [option.label, upstream].filter(Boolean).join(" · ");
}

function providerDisplayLabel(provider: {
readonly displayName?: string | undefined;
readonly driver: string;
Expand Down Expand Up @@ -179,7 +190,7 @@ export function buildModelOptions(
options.set(key, {
key,
label: model.name,
subtitle: model.subProvider ?? "",
subtitle: resolveModelProviderLabel({ ...model, driverKind: provider.driver }) ?? "",
providerKey: provider.instanceId,
providerLabel,
providerDriver: provider.driver,
Expand Down Expand Up @@ -233,7 +244,9 @@ export function buildModelOptions(
options.set(key, {
key,
label: model?.name ?? fallbackModelSelection.model,
subtitle: model?.subProvider ?? "",
subtitle: model
? (resolveModelProviderLabel({ ...model, driverKind: provider?.driver }) ?? "")
: "",
providerKey: fallbackModelSelection.instanceId,
providerLabel,
providerDriver,
Expand Down
70 changes: 69 additions & 1 deletion apps/server/src/provider/Drivers/OpenCodeDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as TestClock from "effect/testing/TestClock";
import { HttpClient } from "effect/unstable/http";
import { HttpClient, HttpClientResponse } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";

import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
Expand Down Expand Up @@ -115,6 +115,74 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => {
}).pipe(Effect.scoped),
);

it.effect("labels OpenCode 2 models with their provider's name from the provider list", () =>
Effect.gen(function* () {
const directory = process.cwd();
const http = HttpClient.make((request) => {
const path = new URL(request.url).pathname;
const json = (body: unknown) =>
Effect.succeed(
HttpClientResponse.fromWeb(
request,
Response.json(body, {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
if (path === "/api/info") {
return json({ version: "2.0.18", pid: 4242, urls: [], paths: { tmp: "/tmp" } });
}
if (path === "/api/model") {
return json({
location: { directory },
data: [
{
id: "qwen/qwen3-coder",
modelID: "qwen/qwen3-coder",
providerID: "openrouter",
family: "qwen",
name: "Qwen3 Coder",
package: "@opencode/ai/providers/openrouter",
capabilities: { tools: true, input: ["text"], output: ["text"] },
variants: [],
time: { released: 1760659200000 },
cost: [{ input: 0, output: 0, cache: { read: 0, write: 0 } }],
status: "active",
enabled: true,
limit: { context: 200000, input: 160000, output: 32000 },
},
],
});
}
if (path === "/api/provider") {
return json({
location: { directory },
data: [
{
id: "openrouter",
name: "My OpenRouter Proxy",
activation: "enabled",
package: "@opencode/ai/providers/openrouter",
},
],
});
}
return json({});
});
const instance = yield* create(
{ serverUrl: "http://127.0.0.1:4096", serverPassword: "secret" },
http,
);

const snapshot = yield* instance.snapshot.refresh;
const model = snapshot.models.find((entry) => entry.slug === "openrouter/qwen/qwen3-coder");

assert.ok(model);
assert.strictEqual(model.subProvider, "My OpenRouter Proxy");
}).pipe(Effect.scoped),
);

it.effect("answers capability reads without waiting on an unreachable server", () =>
Effect.gen(function* () {
const hang = HttpClient.make(() => Effect.never);
Expand Down
22 changes: 22 additions & 0 deletions apps/server/src/provider/Drivers/OpenCodeDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,27 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
),
),
);
// A 2.x model row carries no provider display name; the provider list
// does. `checkOpenCode2` treats it as optional, so a failed or missing
// lookup only costs the label, never the catalog.
const loadOpenCode2ProviderNames = openCode2Server.withConnection((connection) =>
connection.client.provider.list({ location: { directory: serverConfig.cwd } }).pipe(
Effect.map(
(providers) =>
new Map<string, string>(
providers.data.map((provider): [string, string] => [provider.id, provider.name]),
),
),
Effect.mapError(
(cause) =>
new OpenCodeRuntime.OpenCodeRuntimeError({
operation: "provider.list",
detail: "The OpenCode server could not list its providers.",
cause,
}),
),
),
);
// A 2.x server lists skills and commands per directory, so one server
// answers every workspace. Its event stream says when a directory it had
// not served yet finished scanning.
Expand Down Expand Up @@ -374,6 +395,7 @@ export const OpenCodeDriver: ProviderDriver<OpenCodeSettings, OpenCodeDriverEnv>
serverConfig.cwd,
runtimeProbe.refresh,
loadOpenCode2Models,
loadOpenCode2ProviderNames,
),
usageLimits: readOpenCodeGoUsageLimits({
enabled: effectiveConfig.enabled,
Expand Down
Loading
Loading