From d2ea5314c3a4d300d9422e23d7a6bdcbfbfd33b4 Mon Sep 17 00:00:00 2001 From: Mushikingh <164845020+mushikingh@users.noreply.github.com> Date: Sun, 1 Nov 2026 20:17:31 +0100 Subject: [PATCH] fix(t3code): declare model efforts and tint the provider badge T3 Code rejects any option a custom model does not declare, so starting a thread or subagent on an opencodex model with an effort failed with "Unknown option effort; supported options: none". Export each model's effort ladder as capabilities, limited to the values Claude Code's --effort accepts. T3 Code picks the icon from the driver and takes no custom one, so set an accent color to tell the opencodex badge from Claude's. --- .../src/content/docs/guides/integrations.md | 2 +- src/clients/config-export/t3code.ts | 49 +++++++++++++++++-- structure/clients/t3code.md | 2 +- tests/clients/t3code-client.test.ts | 24 +++++++++ 4 files changed, 70 insertions(+), 7 deletions(-) diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 3d427ac444a..98c18d503a4 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -173,7 +173,7 @@ runs your own `claude` binary with `ANTHROPIC_BASE_URL` set to the proxy, the no `opencodex-proxy` marker as `ANTHROPIC_AUTH_TOKEN`, an explicitly empty `ANTHROPIC_API_KEY`, and `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1` so a `settings.json` env block in your Claude config cannot re-point it. Each routed model is a custom model keyed by its opencodex selector -(`provider/model`), which T3 Code sends verbatim. It appears in T3 Code's model picker under +(`provider/model`), which T3 Code sends verbatim. A model with an effort ladder declares it, so T3 Code offers its effort control and can start a subagent on it at a chosen effort. It appears in T3 Code's model picker under **opencodex**. T3 Code reloads the file on its own, so no restart is needed; catalog changes and `ocx sync` refresh the instance. diff --git a/src/clients/config-export/t3code.ts b/src/clients/config-export/t3code.ts index ee3f5008c16..50493456ebf 100644 --- a/src/clients/config-export/t3code.ts +++ b/src/clients/config-export/t3code.ts @@ -4,7 +4,7 @@ import { PROXY_MARKER } from "../../claude/auth-detect"; import { isLoopbackHostname, shouldInjectApiAuthHeader } from "../../codex/loopback-target"; import { resolveApiSurfaceSettings } from "../../protocols/settings"; import { absoluteClientPath, ClientPathError } from "../client-path"; -import type { ExportContext, ManagedContribution, OpencodeLaunchEnv } from "./contracts"; +import type { ExportContext, ExportModel, ManagedContribution, OpencodeLaunchEnv } from "./contracts"; import { OPENCODE_PROVIDER_ID } from "./constants"; import { normalizeExportModels, singleFragment } from "./model-metadata"; @@ -45,16 +45,28 @@ export interface T3CodeEnvironmentVariable { sensitive: false; } +/** T3 Code's `ModelCapabilities`: the options a thread or subagent may select for this model. */ +export interface T3CodeModelCapabilities { + optionDescriptors: { + id: "effort"; + label: string; + type: "select"; + options: { id: string; label: string; isDefault?: true }[]; + }[]; +} + /** One T3 Code `CustomModelEntry`: the slug is sent verbatim as the Claude model id. */ export interface T3CodeCustomModel { slug: string; name: string; + capabilities?: T3CodeModelCapabilities; } /** The `providerInstances.opencodex` value opencodex owns in T3 Code's settings.json. */ export interface T3CodeProviderInstance { driver: "claudeAgent"; displayName: string; + accentColor: string; environment: T3CodeEnvironmentVariable[]; config: { customModels: T3CodeCustomModel[] }; } @@ -92,6 +104,7 @@ function t3codeDocument(ctx: ExportContext): T3CodeGeneratedConfig { [OPENCODE_PROVIDER_ID]: { driver: "claudeAgent", displayName: "opencodex", + accentColor: T3CODE_ACCENT_COLOR, environment: [ variable("ANTHROPIC_BASE_URL", origin), variable("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER), @@ -99,16 +112,42 @@ function t3codeDocument(ctx: ExportContext): T3CodeGeneratedConfig { variable("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "1"), ], config: { - customModels: models.map(model => ({ - slug: model.namespaced, - name: model.displayName || model.namespaced, - })), + customModels: models.map(model => { + const capabilities = t3codeCapabilities(model); + return { + slug: model.namespaced, + name: model.displayName || model.namespaced, + ...(capabilities ? { capabilities } : {}), + }; + }), }, }, }, }; } +/** Tints the instance's badge. T3 Code picks the icon from the driver, so this is all that sets it apart from Claude. */ +const T3CODE_ACCENT_COLOR = "#a6a6a6"; + +/** The efforts Claude Code's `--effort` accepts, labelled the way T3 Code labels its built-in models. */ +const T3CODE_EFFORT_LABELS: Readonly> = { + low: "Low", medium: "Medium", high: "High", xhigh: "Extra High", max: "Max", +}; + +/** + * T3 Code rejects any option a custom model does not declare, so a model exported without + * this cannot be started with an effort at all. A model with no usable effort declares nothing. + */ +function t3codeCapabilities(model: ExportModel): T3CodeModelCapabilities | undefined { + if (model.supportsReasoningEffort === false) return undefined; + const options = (model.reasoningEfforts ?? []).flatMap(id => { + const label = T3CODE_EFFORT_LABELS[id]; + if (!label) return []; + return [{ id, label, ...(id === model.defaultReasoningEffort ? { isDefault: true as const } : {}) }]; + }); + return options.length ? { optionDescriptors: [{ id: "effort", label: "Reasoning", type: "select", options }] } : undefined; +} + /** Refuses any listener that needs an admission header: T3 Code would send only the marker. */ export function buildT3CodeClientConfig(ctx: ExportContext): T3CodeGeneratedConfig { if (shouldInjectApiAuthHeader(ctx.config) || !isLoopbackHostname(new URL(ctx.baseUrl).hostname)) { diff --git a/structure/clients/t3code.md b/structure/clients/t3code.md index 380e739e6dc..1bd95101e87 100644 --- a/structure/clients/t3code.md +++ b/structure/clients/t3code.md @@ -19,7 +19,7 @@ instance with `driver: "claudeAgent"`. Its `environment` carries the proxy origi `ANTHROPIC_API_KEY`, and `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1`. That is the proxy-mode `ocx claude` environment on a loopback bind. Every variable is written `sensitive: false`, because T3 Code moves sensitive values into its secret store and blanks them in the file. None of -these values is a credential. `config.customModels` holds one `{ slug, name }` entry per +these values is a credential. `accentColor` tints the instance's badge, because T3 Code picks the icon from the driver and takes no custom one. `config.customModels` holds one `{ slug, name, capabilities? }` entry per normalized export model, Fast rows included. The slug is the proxy selector, which T3 Code passes verbatim as the Claude model id and the Messages surface resolves like any other selector. diff --git a/tests/clients/t3code-client.test.ts b/tests/clients/t3code-client.test.ts index e928acccd2e..49add0e20c1 100644 --- a/tests/clients/t3code-client.test.ts +++ b/tests/clients/t3code-client.test.ts @@ -64,6 +64,30 @@ describe("T3 Code client integration", () => { ]); }); + test("a model declares the efforts Claude Code accepts, so T3 Code can start it with one", () => { + const modelsWith = (efforts: Partial<(typeof context.models)[number]>) => buildT3CodeClientConfig({ + ...context, + models: [{ ...context.models[0]!, ...efforts }], + }).providerInstances.opencodex; + const instance = modelsWith({ reasoningEfforts: ["none", "low", "medium", "xhigh", "ultra"], defaultReasoningEffort: "medium" }); + expect(instance.accentColor).toMatch(/^#[0-9a-f]{6}$/); + expect(instance.config.customModels[0]!.capabilities).toEqual({ + optionDescriptors: [{ + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "xhigh", label: "Extra High" }, + ], + }], + }); + // Declaring an empty control would offer a choice that does not exist. + expect(modelsWith({ reasoningEfforts: ["none"] }).config.customModels[0]).not.toHaveProperty("capabilities"); + expect(modelsWith({ reasoningEfforts: ["low"], supportsReasoningEffort: false }).config.customModels[0]).not.toHaveProperty("capabilities"); + }); + test("refuses any listener that would need an admission header", () => { expect(() => buildT3CodeClientConfig({ ...context, config: { ...config, hostname: "0.0.0.0" } })).toThrow("loopback"); expect(() => buildT3CodeClientConfig({ ...context, baseUrl: "http://192.168.1.4:10100/v1" })).toThrow("loopback");