import { homedir } from "node:os"; import { join, win32 } from "node:path"; 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, ExportModel, ManagedContribution, OpencodeLaunchEnv } from "./contracts"; import { OPENCODE_PROVIDER_ID } from "./constants"; import { normalizeExportModels, singleFragment } from "./model-metadata"; /** * T3 Code's data directory: `T3CODE_HOME` when set, otherwise `~/.t3` * (t3code `apps/server/src/os-jank.ts` `resolveBaseDir`). The desktop app and * the `t3` server share it. A relative override is refused because opencodex * and T3 Code would anchor it to different working directories or Windows drives. */ export function t3codeHomeDir(env: OpencodeLaunchEnv = process.env, home: string = homedir(), platform: NodeJS.Platform = process.platform): string { const override = env.T3CODE_HOME?.trim(); if (!override) return join(home, ".t3"); if (platform !== "win32") return absoluteClientPath(override, home, "T3CODE_HOME", platform); const expanded = override === "~" ? home : override.startsWith("~/") || override.startsWith("~\\") ? win32.join(home, override.slice(2)) : override; // isAbsolute also accepts \rooted paths, which still depend on the current drive. if (!/^[A-Za-z]:[\\/]/.test(expanded) && !/^[\\/]{2}[^\\/]+[\\/][^\\/]+(?:[\\/]|$)/.test(expanded)) { throw new ClientPathError( `T3CODE_HOME must be a fully qualified drive or UNC path, or start with ~; "${override}" depends on the working directory or drive, ` + "so opencodex and T3 Code would disagree about which file it names.", ); } return expanded; } /** * Server settings live in `/userdata/settings.json`; T3 Code watches the file and * reloads it, so a written provider instance appears without a restart. */ export function t3codeConfigPath(env: OpencodeLaunchEnv = process.env, home: string = homedir()): string { return join(t3codeHomeDir(env, home), "userdata", "settings.json"); } /** One T3 Code `ProviderInstanceEnvironmentVariable` (t3code `packages/contracts/src/providerInstance.ts`). */ export interface T3CodeEnvironmentVariable { name: string; value: string; 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[] }; } export interface T3CodeGeneratedConfig { providerInstances: Record; } /** * One `claudeAgent` provider instance that runs the user's own `claude` binary against * the proxy's Messages surface. * * The environment matches a proxy-mode `ocx claude` launch on a loopback bind: * the root origin (Claude Code appends `/v1/messages`) and the non-secret marker as * `ANTHROPIC_AUTH_TOKEN`. `ANTHROPIC_API_KEY` is explicitly empty so a stored Anthropic * key cannot outrank the marker, which is T3 Code's documented router recipe. * `CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST` stops a `settings.json` env block in the * user's Claude config from re-pointing this instance away from the proxy. * * Every variable is `sensitive: false` on purpose: T3 Code moves sensitive values into * its secret store and blanks them in settings.json, which would turn our own block into * a foreign edit. None of these values is a credential. * * Models are `customModels` entries keyed by the proxy selector T3 Code sends verbatim * as the Claude model id. The Codex driver needs no block: it lists models through * `codex app-server`, which already reads the routed catalog opencodex writes into * `CODEX_HOME`. */ function t3codeDocument(ctx: ExportContext): T3CodeGeneratedConfig { const origin = new URL(ctx.baseUrl).origin; const models = normalizeExportModels(ctx.models); const variable = (name: string, value: string): T3CodeEnvironmentVariable => ({ name, value, sensitive: false }); return { providerInstances: { [OPENCODE_PROVIDER_ID]: { driver: "claudeAgent", displayName: "opencodex", accentColor: T3CODE_ACCENT_COLOR, environment: [ variable("ANTHROPIC_BASE_URL", origin), variable("ANTHROPIC_AUTH_TOKEN", PROXY_MARKER), variable("ANTHROPIC_API_KEY", ""), variable("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST", "1"), ], config: { 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)) { throw new Error("T3 Code's placeholder credential requires a loopback-only listener."); } return t3codeDocument(ctx); } export function summarizeT3Code(document: unknown): { modelCount: number; modelsWithoutLimits: number } { const models = (document as T3CodeGeneratedConfig | undefined) ?.providerInstances?.[OPENCODE_PROVIDER_ID]?.config?.customModels ?? []; return { modelCount: models.length, modelsWithoutLimits: 0 }; } /** * Refuses apply and refresh while the Messages surface is closed, explicitly or through * the inherited `claudeCode.enabled`: every turn the instance sends would get a 403. * Disable and Undo stay available. */ export function t3codeApplyRefusal(ctx: ExportContext): string | null { if (resolveApiSurfaceSettings(ctx.config ?? {}).messages.enabled) return null; return "T3 Code needs the Messages API; turn on Messages on the API page (apiSurfaces.messages.enabled) first."; } export function buildT3CodeContribution(ctx: ExportContext): ManagedContribution { // The writer gates admission before applying. Classification and disable must // still work after the operator changes the proxy to a non-loopback bind. const doc = t3codeDocument(ctx); return singleFragment("t3code", ["providerInstances", OPENCODE_PROVIDER_ID], doc.providerInstances[OPENCODE_PROVIDER_ID]); }