From f7c79b80c41a8ceab723b19ab1b0f64c2ca690e4 Mon Sep 17 00:00:00 2001 From: Moe Adham Date: Thu, 10 Sep 2026 13:53:41 +0900 Subject: [PATCH 1/2] Fix personal Codex subscription authentication handoff --- src/harness/codex-harness.ts | 14 ++++++++++++++ test/codex-harness.test.ts | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/harness/codex-harness.ts b/src/harness/codex-harness.ts index 09dd6ade1..2784ca901 100644 --- a/src/harness/codex-harness.ts +++ b/src/harness/codex-harness.ts @@ -13,6 +13,7 @@ import { asError, swallow } from "../util/errors.ts"; import { countTokens } from "../util/tokens.ts"; import { CodexAppServer, CodexRpcError, redactCodexDiagnostics } from "./codex-app-server.ts"; import { codexAuthFileForEnv, readCodexOAuthAuthFile } from "./codex-auth.ts"; +import { codexOAuthJwtAccountId } from "./codex-auth-file.ts"; import { childCodexAuthFromDerived, childCodexOAuthAuth, @@ -808,6 +809,19 @@ export function createCodexHarness(opts: CodexHarnessOptions = {}): Harness { } finally { if (startTimer) clearTimeout(startTimer); } + await awaitSetup( + server.request( + "account/login/start", + { + type: "chatgptAuthTokens", + accessToken: turn.codexAuth.accessToken, + chatgptAccountId: codexOAuthJwtAccountId(userAuth), + }, + (value): value is { type: "chatgptAuthTokens" } => + Boolean(value && typeof value === "object" && "type" in value && value.type === "chatgptAuthTokens"), + AbortSignal.timeout(opts.appServerStartTimeoutMs ?? CODEX_START_TIMEOUT_MS), + ), + ); rt = ephemeral; } catch (error) { if (!ephemeral) rmSync(jail, { recursive: true, force: true }); diff --git a/test/codex-harness.test.ts b/test/codex-harness.test.ts index ed7ebeb08..0be540407 100644 --- a/test/codex-harness.test.ts +++ b/test/codex-harness.test.ts @@ -405,9 +405,21 @@ rl.on("line", (line) => { const msg = JSON.parse(line); if (msg.method === "initialize") return send({ id: msg.id, result: {} }); if (msg.method === "initialized") return; + if (msg.method === "account/login/start") { + const auth = JSON.parse(fs.readFileSync(authPath, "utf8")); + if (msg.params.type !== "chatgptAuthTokens" || msg.params.accessToken !== auth.tokens.access_token || + msg.params.chatgptAccountId !== auth.tokens.account_id || "refreshToken" in msg.params) { + return send({ id: msg.id, error: { code: -1, message: "incorrect external account login" } }); + } + fs.writeFileSync(path.join(process.env.CODEX_HOME, "external-login"), "ready"); + return send({ id: msg.id, result: { type: "chatgptAuthTokens" } }); + } if (msg.method === "thread/start") return send({ id: msg.id, result: { thread: { id: "thread-" + process.pid } } }); if (msg.method === "turn/start") { const auth = JSON.parse(fs.readFileSync(authPath, "utf8")); + if (auth.tokens.account_id.startsWith("acct-") && !fs.existsSync(path.join(process.env.CODEX_HOME, "external-login"))) { + return send({ id: msg.id, error: { code: -1, message: "external account login missing" } }); + } const reply = String(auth.tokens.account_id ?? "none") + ":" + String(Boolean(auth.tokens.refresh_token)); send({ id: msg.id, result: { turn: { id: "turn-" + process.pid, status: "inProgress", items: [] } } }); return setTimeout(() => send({ method: "turn/completed", params: { threadId: "thread-" + process.pid, turn: { id: "turn-" + process.pid, status: "completed", items: [{ type: "agentMessage", text: reply, phase: "final_answer" }] } } }), ${delayMs}); From 8ae4a0e706c3785dfaec61136568b7c2c9b172a6 Mon Sep 17 00:00:00 2001 From: Moe Adham Date: Thu, 10 Sep 2026 14:08:29 +0900 Subject: [PATCH 2/2] Enable model selection for personal Codex subscriptions --- package-lock.json | 56 ++++++++-------- package.json | 2 +- plugins/web-ui/src/composer.ts | 3 +- plugins/web-ui/test/composer-source.test.ts | 2 +- src/api/app-turn.ts | 27 +++++++- src/api/routes/surface.ts | 9 ++- src/api/runtime-config.ts | 69 ++++++++++++++------ src/core/individual-auth-routing.ts | 1 + test/subscription-model-turn.test.ts | 72 +++++++++++++++++++++ test/subscription-runtime-config.test.ts | 67 +++++++++++++++++++ test/user-model-auth-injection.test.ts | 6 ++ 11 files changed, 258 insertions(+), 56 deletions(-) create mode 100644 test/subscription-model-turn.test.ts create mode 100644 test/subscription-runtime-config.test.ts diff --git a/package-lock.json b/package-lock.json index 6760a0a13..55ab73d29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@earendil-works/pi-ai": "0.82.0", "@earendil-works/pi-coding-agent": "https://github.com/yc-software/pi/releases/download/qm-pi-coding-agent-0.82.0-security.3/earendil-works-pi-coding-agent-0.82.0-qm-security.3.tgz", "@fly/sprites": "0.0.1", - "@openai/codex": "0.144.5", + "@openai/codex": "0.153.4", "@opencode-ai/plugin": "1.17.18", "@opencode-ai/sdk": "1.17.18", "@slack/bolt": "^4.7.3", @@ -3653,9 +3653,9 @@ } }, "node_modules/@openai/codex": { - "version": "0.144.5", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.5.tgz", - "integrity": "sha512-jjB+K+OMv572mKhS+2QuLxWXDJNdpwbPenf+V+8bdq7wg4Scqt3cn6WEekD8wPqDVZqck0HSX17K9rD9kbDJQA==", + "version": "0.153.4", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4.tgz", + "integrity": "sha512-wbHDmit7S/YvBGVX1DQmk13xtWblZ2cApeJ/pB7xDZ10Cna+DZc5ij7f0F4OxdsXN4FW1oLT48OpogUI1+8Y2w==", "license": "Apache-2.0", "bin": { "codex": "bin/codex.js" @@ -3664,19 +3664,19 @@ "node": ">=16" }, "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.144.5-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.144.5-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.144.5-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.144.5-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.144.5-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.144.5-win32-x64" + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.153.4-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.153.4-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.153.4-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.153.4-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.153.4-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.153.4-win32-x64" } }, "node_modules/@openai/codex-darwin-arm64": { "name": "@openai/codex", - "version": "0.144.5-darwin-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.5-darwin-arm64.tgz", - "integrity": "sha512-zcT6NfBCqLFt+BReNSETTZW6v6PdbH0dzNtm9j7l7mDGqwPbKZDGJdnpkBao2389I0ZacyIKgSZoI0vez1d4Dw==", + "version": "0.153.4-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-darwin-arm64.tgz", + "integrity": "sha512-B1qhN3fa1ay0R0wGziXqgwSkB5icpYChNKHhtBHff/0UtSTC7z+l8aTtvMlGjH3E8HEvY3+njIJelM9CAAoVWg==", "cpu": [ "arm64" ], @@ -3691,9 +3691,9 @@ }, "node_modules/@openai/codex-darwin-x64": { "name": "@openai/codex", - "version": "0.144.5-darwin-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.5-darwin-x64.tgz", - "integrity": "sha512-//Mo0m1MwaoT6psu5xsmofXpKx4/0irIkeq10xJvk59+886EG355ibjA+ZmlRcKhE3bLjsKD7p81nTbAdRL/bw==", + "version": "0.153.4-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-darwin-x64.tgz", + "integrity": "sha512-vnSbbPzfoDZmmyzsxswsDDXQ06IVFBzkQU7/hroB3ji93Ok2utcsq8Psfk2tjF5r9mEx8RWFJhzuTGHG26/NDA==", "cpu": [ "x64" ], @@ -3708,9 +3708,9 @@ }, "node_modules/@openai/codex-linux-arm64": { "name": "@openai/codex", - "version": "0.144.5-linux-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.5-linux-arm64.tgz", - "integrity": "sha512-zAHggxVwR2TBxKmybXY7ZMiB0G8DMonY2YPdwNNjwXcf+LOIqNGgswwNCDMbP/HEe6r8j+R9ZX/yYoo8f+n/RQ==", + "version": "0.153.4-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-linux-arm64.tgz", + "integrity": "sha512-QKdjYLYV4hXIuUQDP3P6F4NXuWFoKo9WUoV4nAREIx55kiUyi8UsYdsVobkeXir5n/maEQgYMCKLHVma4rNPiw==", "cpu": [ "arm64" ], @@ -3725,9 +3725,9 @@ }, "node_modules/@openai/codex-linux-x64": { "name": "@openai/codex", - "version": "0.144.5-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.5-linux-x64.tgz", - "integrity": "sha512-FalLJlBQGFdK8Gc3kj9sa/ekNdgkHhUawLaKkvy5CtB18JaP2YxtTP/Pe1pD2iBiq8mMUliRnafpF6AdBdQMbg==", + "version": "0.153.4-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-linux-x64.tgz", + "integrity": "sha512-x1EcwBlY3AObM1VTUHNM2AzAJQsyreGdagpF+qFiYi/Oa30VBktvvG0C6tLtCzqW6hjZNWkGZQWmeVk7MuJKWg==", "cpu": [ "x64" ], @@ -3742,9 +3742,9 @@ }, "node_modules/@openai/codex-win32-arm64": { "name": "@openai/codex", - "version": "0.144.5-win32-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.5-win32-arm64.tgz", - "integrity": "sha512-0Pj7iqjEOEvPQPO3kFfCy9vGX4BTu76ChFFZHr2eNNIfVc3FOENAv/X98u4L+iIUtDOK9DbqmfUudW3DPapshg==", + "version": "0.153.4-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-win32-arm64.tgz", + "integrity": "sha512-/FBh42976ltF1kxDoPQBg1Q6+hwChRU5/sm5dfeC8kFVQMvOCGoGeY5d8rRZGVJE8XojlXo74VQb0sHowcfgBw==", "cpu": [ "arm64" ], @@ -3759,9 +3759,9 @@ }, "node_modules/@openai/codex-win32-x64": { "name": "@openai/codex", - "version": "0.144.5-win32-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.5-win32-x64.tgz", - "integrity": "sha512-DnsSTlnnzleTxvLwIGnBitKInscxn2I7qASqosS8Fv+qysBygd+ZiBn/SQsRCgQ28PAlsNzmd3Gf3ZTecolAmg==", + "version": "0.153.4-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-win32-x64.tgz", + "integrity": "sha512-lMkB43kJZH0VFr+hoXc11qqR7QtQIbkr07ALgj4urKL1osNyUyuy1iXd3Vzz2iCYvBUCSw7I0l/W1cEPGx9euQ==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index a5ec15fab..32346bb9b 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "@earendil-works/pi-ai": "0.82.0", "@earendil-works/pi-coding-agent": "https://github.com/yc-software/pi/releases/download/qm-pi-coding-agent-0.82.0-security.3/earendil-works-pi-coding-agent-0.82.0-qm-security.3.tgz", "@fly/sprites": "0.0.1", - "@openai/codex": "0.144.5", + "@openai/codex": "0.153.4", "@opencode-ai/plugin": "1.17.18", "@opencode-ai/sdk": "1.17.18", "@slack/bolt": "^4.7.3", diff --git a/plugins/web-ui/src/composer.ts b/plugins/web-ui/src/composer.ts index 9a346637b..d74afece6 100644 --- a/plugins/web-ui/src/composer.ts +++ b/plugins/web-ui/src/composer.ts @@ -456,7 +456,6 @@ export function createComposerSurface(ctx: ConvCtx): ComposerSurface { } const compact = Boolean(ctx.pane) || isPhone(); - const showRuntimeControls = !appState.me?.individualModelAuth; const runtimeControls = compact ? settingsControl(agent, selectedModel, inputBlocked) : html` @@ -653,7 +652,7 @@ export function createComposerSurface(ctx: ConvCtx): ComposerSurface { ` } -
${showRuntimeControls ? runtimeControls : nothing} ${sendControls(agent)}
+
${runtimeControls} ${sendControls(agent)}
${composerNotice} diff --git a/plugins/web-ui/test/composer-source.test.ts b/plugins/web-ui/test/composer-source.test.ts index 625cf42b6..911248377 100644 --- a/plugins/web-ui/test/composer-source.test.ts +++ b/plugins/web-ui/test/composer-source.test.ts @@ -18,7 +18,7 @@ test("composer-right keeps its control order: make default, use org default, mod composer.indexOf("return html`", composer.indexOf("const runtimeControls =")), ); const rendered = composer.slice(composer.indexOf('class="composer-right"')); - assert.match(rendered, /showRuntimeControls \? runtimeControls : nothing/); + assert.match(rendered, /\$\{runtimeControls\}/); const right = runtime + rendered; const makeDefault = right.indexOf("Make default"); const orgDefault = right.indexOf("Use org default"); diff --git a/src/api/app-turn.ts b/src/api/app-turn.ts index 829fbf8d4..535954711 100644 --- a/src/api/app-turn.ts +++ b/src/api/app-turn.ts @@ -25,6 +25,7 @@ import { swallow, swallowAs } from "../util/errors.ts"; import { sleep } from "../util/async.ts"; import { GENERIC_FAILURE_CLAUSE } from "../../plugins/chassis/src/failure-copy.ts"; +import { runtimeConfigBody } from "./runtime-config.ts"; import type { App, AppDeps } from "./app-types.ts"; import { STALE_LEASE_GRACE_MS } from "./app-types.ts"; @@ -145,6 +146,8 @@ export function createTurnMethods( } const individualAuth = !!deps.userModelCredentials && (await deps.config.getIndividualModelAuthDurable()); + let selectedHarness = individualAuth ? undefined : req.harness; + let selectedModel = individualAuth ? undefined : req.model; if (req.surface === "web") { const threadRef = req.conversation.threadRef; const existing = await deps.sessions.getByThread(threadRef); @@ -163,7 +166,25 @@ export function createTurnMethods( harnessId: fallbackHarness, modelId: defaultModelForHarness(fallbackHarness), }; - if (!individualAuth) { + if (individualAuth) { + const available = await runtimeConfigBody( + { deps: { ...deps, baseModelDefault: runtimeFallback.modelId } }, + targetScope, + actor.id, + ); + selectedHarness = req.harness ?? available.effective.harnessId; + selectedModel = req.model ?? available.effective.modelId; + if (!available.modelsByHarness[selectedHarness]?.includes(selectedModel)) + return { + status: "refused", + reason: "that model isn't available for your connected AI account or isn't approved", + }; + const invalidModelOption = validateWebTurnModelOptions( + { ...req, model: selectedModel }, + available.modelsByHarness[selectedHarness] ?? [], + ); + if (invalidModelOption) return { status: "refused", reason: invalidModelOption }; + } else { const configuredKeys = deps.providerKeys ?? deps.modelProviders ?? { anthropic: false, openai: false, openrouter: false }; const managedKeys = deps.modelCredentials ? await deps.modelCredentials.availability() : configuredKeys; @@ -268,8 +289,8 @@ export function createTurnMethods( ...(req.detectOpener ? { detectOpener: req.detectOpener } : {}), ...(req.attachments?.length ? { attachments: req.attachments } : {}), ...(req.inboundNotes?.length ? { inboundNotes: req.inboundNotes } : {}), - ...(!individualAuth && req.harness ? { harness: req.harness } : {}), - ...(!individualAuth && req.model ? { model: req.model } : {}), + ...(selectedHarness ? { harness: selectedHarness } : {}), + ...(selectedModel ? { model: selectedModel } : {}), ...turnModelOptions(req), ...(req.readOnly ? { readOnly: true } : {}), ...(req.skipMemory ? { skipMemory: true } : {}), diff --git a/src/api/routes/surface.ts b/src/api/routes/surface.ts index 7ab77322c..cc4264884 100644 --- a/src/api/routes/surface.ts +++ b/src/api/routes/surface.ts @@ -1125,7 +1125,7 @@ async function getRuntimeConfig(ctx: ApiCtx): Promise { const target = await runtimeTarget(ctx); if (!target) return sendJson(ctx.res, 403, { error: "forbidden" }); await ctx.deps.refreshModels?.(); - return sendJson(ctx.res, 200, await runtimeConfigBody(ctx, target.scope)); + return sendJson(ctx.res, 200, await runtimeConfigBody(ctx, target.scope, target.actorId)); } async function putRuntimeConfig(ctx: ApiCtx): Promise { @@ -1163,6 +1163,11 @@ async function putRuntimeConfig(ctx: ApiCtx): Promise { if (typeof modelId !== "string" || !modelSupportedByHarness(modelId, harnessId)) return sendJson(ctx.res, 400, { error: "model_not_supported" }); if (!(await webuiModelEnabled(ctx, modelId))) return sendJson(ctx.res, 400, { error: "model_not_enabled" }); + if (ctx.deps.userModelCredentials && (await config.getIndividualModelAuthDurable())) { + const available = await runtimeConfigBody(ctx, target.scope, target.actorId); + if (!available.modelsByHarness[harnessId]?.includes(modelId)) + return sendJson(ctx.res, 400, { error: "model_not_available_for_account" }); + } const effortLevel = ctx.body.effortLevel ?? "auto"; if (typeof effortLevel !== "string" || !(THINKING_LEVELS as readonly string[]).includes(effortLevel)) return sendJson(ctx.res, 400, { error: "effort_not_supported" }); @@ -1177,7 +1182,7 @@ async function putRuntimeConfig(ctx: ApiCtx): Promise { resource: "runtime-config", scopeLabel: target.scope, }); - return sendJson(ctx.res, 200, await runtimeConfigBody(ctx, target.scope)); + return sendJson(ctx.res, 200, await runtimeConfigBody(ctx, target.scope, target.actorId)); } async function getChannelHeaderPin(ctx: ApiCtx): Promise { diff --git a/src/api/runtime-config.ts b/src/api/runtime-config.ts index 8cf677629..f8d94f65b 100644 --- a/src/api/runtime-config.ts +++ b/src/api/runtime-config.ts @@ -1,6 +1,8 @@ -import type { ServerDeps } from "./deps.ts"; +import type { ScopedConfigStore } from "../resolution/config-store.ts"; +import type { ModelCredentialStore } from "../model/model-credential-store.ts"; +import type { UserModelCredentialStore } from "../model/user-model-credential-store.ts"; import type { ScopeId } from "../types.ts"; -import { orgScope } from "./routes/shared.ts"; +import { orgScope } from "../config.ts"; import { defaultModelForHarness, isHarnessId, @@ -15,30 +17,32 @@ import { thinkingLevelsForHarness, harnessSupportsFastMode, type HarnessId, + type ModelProviderAvailability, } from "../model/pi-models.ts"; import { builtInModelCatalog, selectableCatalogForHarness, selectableModelCatalog } from "../model/model-catalog.ts"; +import { resolveIndividualAuthRouting } from "../core/individual-auth-routing.ts"; import type { RuntimeChoice } from "../harness/harness.ts"; -export type RuntimeDeps = Pick< - ServerDeps, - | "config" - | "harnessId" - | "baseModelDefault" - | "providerKeys" - | "modelCredentials" - | "modelCredentialFetch" - | "refreshModels" ->; +export interface RuntimeDeps { + config?: ScopedConfigStore; + harnessId?: string; + baseModelDefault?: string; + providerKeys?: ModelProviderAvailability; + modelCredentials?: ModelCredentialStore; + modelCredentialFetch?: typeof fetch; + refreshModels?: () => Promise; + userModelCredentials?: UserModelCredentialStore; +} export function runtimeFallback(ctx: { deps: RuntimeDeps }): { harnessId: HarnessId; modelId: string } { const harnessId = isHarnessId(ctx.deps.harnessId) ? ctx.deps.harnessId : "pi"; return { harnessId, modelId: ctx.deps.baseModelDefault ?? defaultModelForHarness(harnessId) }; } -export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: ScopeId) { +export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: ScopeId, principalId?: string) { const config = ctx.deps.config!; const fallback = runtimeFallback(ctx); - const org = orgScope(ctx.deps); + const org = orgScope(); const approvedHarnesses = ((await config.getApprovedHarnessesDurable()) ?? [fallback.harnessId]).filter(isHarnessId); const configuredKeys = ctx.deps.providerKeys ?? ALL_PROVIDERS_AVAILABLE; const managedKeys = ctx.deps.modelCredentials ? await ctx.deps.modelCredentials.availability() : configuredKeys; @@ -87,7 +91,23 @@ export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: Scope } else if (legacyModel) { scopeOverride = { harnessId: fallback.harnessId, modelId: legacyModel, orgRevision: 0 }; } - const effective = scopeOverride ?? orgDefault; + const individualAuth = Boolean( + principalId && ctx.deps.userModelCredentials && (await config.getIndividualModelAuthDurable()), + ); + const [anthCred, oaiCred] = individualAuth + ? await Promise.all([ + ctx.deps.userModelCredentials!.get(principalId!, "anthropic"), + ctx.deps.userModelCredentials!.get(principalId!, "openai"), + ]) + : [null, null]; + let effective = scopeOverride ?? orgDefault; + if (individualAuth) { + const requested = scopeOverride ?? orgDefault; + const routing = + resolveIndividualAuthRouting(anthCred, oaiCred, requested.modelId, requested.harnessId) ?? + (scopeOverride ? null : resolveIndividualAuthRouting(anthCred, oaiCred, undefined, fallback.harnessId)); + if (routing?.model) effective = { ...requested, harnessId: routing.harness, modelId: routing.model }; + } const selected = [orgDefault, scopeOverride, effective].filter((choice) => choice !== null); const allowlist = await config.getWebuiModelsDurable(org); const modelsByHarness = Object.fromEntries( @@ -100,6 +120,7 @@ export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: Scope .map((model) => model.id); for (const choice of selected) { if ( + (!individualAuth || allowlist == null) && allowlist?.length !== 0 && choice.harnessId === harnessId && modelSupportedByHarness(choice.modelId, harnessId) && @@ -107,7 +128,15 @@ export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: Scope ) ids.push(choice.modelId); } - return [harnessId, serviceableModelIds(ids, providersFor(harnessId))]; + return [ + harnessId, + individualAuth + ? ids.filter((modelId) => { + const routing = resolveIndividualAuthRouting(anthCred, oaiCred, modelId, harnessId); + return routing?.harness === harnessId && routing.model === modelId; + }) + : serviceableModelIds(ids, providersFor(harnessId)), + ]; }), ); const advertisedModelIds = new Set(Object.values(modelsByHarness).flat()); @@ -119,7 +148,9 @@ export async function runtimeConfigBody(ctx: { deps: RuntimeDeps }, scope: Scope ); return { scopeId: scope, - approvedHarnesses, + approvedHarnesses: individualAuth + ? approvedHarnesses.filter((id) => modelsByHarness[id]?.length) + : approvedHarnesses, modelsByHarness, modelCatalog, orgDefault, @@ -155,10 +186,10 @@ export function validateRuntimeChoice(choice: RuntimeChoice): string | null { export async function webuiModelEnabled(ctx: { deps: RuntimeDeps }, modelId: string): Promise { modelId = modelId.replace(/^codex\//, ""); const config = ctx.deps.config!; - const picker = await config.getWebuiModelsDurable(orgScope(ctx.deps)); + const picker = await config.getWebuiModelsDurable(orgScope()); if (picker == null || picker.includes(modelId)) return true; if (picker.length === 0) return false; - const org = orgScope(ctx.deps); + const org = orgScope(); const stored = await config.getRuntimeSelectionDurable(org); const orgModel = stored?.modelId ?? (await config.getBaseModelOwnDurable(org)) ?? runtimeFallback(ctx).modelId; return modelId === orgModel; diff --git a/src/core/individual-auth-routing.ts b/src/core/individual-auth-routing.ts index f5f0e9d61..f72462875 100644 --- a/src/core/individual-auth-routing.ts +++ b/src/core/individual-auth-routing.ts @@ -31,6 +31,7 @@ export function resolveIndividualAuthRouting( const pick = ((): { provider: "anthropic" | "openai"; cred: UserModelCredential } | null => { if (requestedProvider === "anthropic" && anthCred) return { provider: "anthropic", cred: anthCred }; if (requestedProvider === "openai" && oaiCred) return { provider: "openai", cred: oaiCred }; + if (requestedProvider) return null; if (anthCred) return { provider: "anthropic", cred: anthCred }; if (oaiCred) return { provider: "openai", cred: oaiCred }; return null; diff --git a/test/subscription-model-turn.test.ts b/test/subscription-model-turn.test.ts new file mode 100644 index 000000000..ddcf85075 --- /dev/null +++ b/test/subscription-model-turn.test.ts @@ -0,0 +1,72 @@ +import "./support/auto-fake-sprites.ts"; +import assert from "node:assert/strict"; +import { test, mock } from "node:test"; +import { createMockHarness } from "../src/harness/mock-harness.ts"; +import type { HarnessTurnInput } from "../src/harness/harness.ts"; +import { testConfig } from "./support/test-config.ts"; + +const codex = await import("../src/harness/codex-harness.ts"); +const seen: HarnessTurnInput[] = []; +mock.module("../src/harness/codex-harness.ts", { + namedExports: { + ...codex, + createCodexHarness: () => { + const harness = createMockHarness(); + return { + ...harness, + id: "codex", + turns: { + ...harness.turns, + runTurn: async (turn: HarnessTurnInput) => { + seen.push(turn); + return { reply: "subscription model selected", modelCalls: 1 }; + }, + }, + }; + }, + }, +}); +const { buildApp } = await import("../src/wiring.ts"); + +test("web Astra selection reaches Codex with personal OAuth through app and orchestrator", async () => { + const built = buildApp(testConfig({ harness: "mock", seedSkills: false })); + try { + built.config.setIndividualModelAuth(true); + built.config.setApprovedHarnesses(["mock", "codex", "claude"]); + await built.config.flushScope("org:default-org"); + await built.config.setRuntimeSelectionLatest("org:default-org", { + harnessId: "claude", + modelId: "claude-sonnet-5", + }); + await built.userModelCredentials.setOAuth("internal:alice", "openai", { + accessToken: "test-access", + idToken: "test-id", + accountId: "test-account", + expiresAt: Date.now() + 3600000, + }); + const request = { + surface: "web", + liveActor: true, + actor: { externalId: "internal:alice" }, + conversation: { kind: "dm" as const, threadRef: "subscription-astra-test" }, + text: "Test the selected model", + harness: "codex", + model: "gpt-6-astra", + skipMemory: true, + }; + const result = await built.app.turn(request); + assert.equal(result.status, "ok", JSON.stringify(result)); + assert.equal(seen.length, 1); + assert.equal(seen[0]?.runtime?.modelId, "gpt-6-astra"); + assert.equal(seen[0]?.codexAuth?.accountId, "test-account"); + await built.userModelCredentials.delete("internal:alice", "openai"); + assert.equal( + (await built.app.turn({ ...request, conversation: { ...request.conversation, threadRef: "disconnected-test" } })) + .status, + "refused", + ); + assert.equal(seen.length, 1); + } finally { + await built.runtime.stop(); + } +}); diff --git a/test/subscription-runtime-config.test.ts b/test/subscription-runtime-config.test.ts new file mode 100644 index 000000000..bda37cd57 --- /dev/null +++ b/test/subscription-runtime-config.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { runtimeConfigBody } from "../src/api/runtime-config.ts"; +import { createMemoryConfigStore } from "../src/resolution/config-store.ts"; +import { createMemoryMap } from "../src/persistence/durable-map.ts"; +import { createKeychain } from "../src/credentials/keychain.ts"; +import { deriveConnectorKey } from "../src/connectors/connector-client-store.ts"; +import { createUserModelCredentialStore } from "../src/model/user-model-credential-store.ts"; + +async function setup() { + const config = createMemoryConfigStore("default-org"); + config.setApprovedHarnesses(["claude", "codex"]); + config.setIndividualModelAuth(true); + await config.flushScope("org:default-org"); + const userModelCredentials = createUserModelCredentialStore({ + keychain: createKeychain({ + creds: createMemoryMap(), + grants: createMemoryMap(), + asks: createMemoryMap(), + key: deriveConnectorKey("subscription-runtime-config-test-key"), + }), + }); + await userModelCredentials.setOAuth("alice", "openai", { accessToken: "test-access", idToken: "test-id" }); + const ctx = { + deps: { + config, + userModelCredentials, + harnessId: "claude", + providerKeys: { anthropic: false, openai: false, openrouter: false }, + }, + }; + return { config, userModelCredentials, ctx }; +} + +test("Codex subscription advertises Astra without deployment API keys and preserves a saved selection", async () => { + const { config, ctx } = await setup(); + const initial = await runtimeConfigBody(ctx, "personal:alice", "alice"); + assert.deepEqual(initial.approvedHarnesses, ["codex"]); + assert.ok(initial.modelsByHarness.codex?.includes("gpt-6-astra")); + assert.ok(initial.modelsByHarness.codex?.includes("gpt-5.6-sol")); + assert.deepEqual(initial.effective, { harnessId: "codex", modelId: "gpt-5.6-sol" }); + await config.setRuntimeSelectionLatest("personal:alice", { harnessId: "codex", modelId: "gpt-6-astra" }); + assert.equal((await runtimeConfigBody(ctx, "personal:alice", "alice")).effective.modelId, "gpt-6-astra"); + const other = await runtimeConfigBody(ctx, "personal:bob", "bob"); + assert.deepEqual(other.approvedHarnesses, []); + assert.deepEqual(other.modelsByHarness.codex, []); +}); + +test("subscription models obey the current org allowlist even when a disallowed model was saved", async () => { + const { config, ctx } = await setup(); + await config.setRuntimeSelectionLatest("personal:alice", { harnessId: "codex", modelId: "gpt-6-astra" }); + config.setWebuiModels("org:default-org", ["gpt-5.6-sol"]); + await config.flushScope("org:default-org"); + const result = await runtimeConfigBody(ctx, "personal:alice", "alice"); + assert.deepEqual(result.modelsByHarness.codex, ["gpt-5.6-sol"]); + assert.ok(result.unavailableReason); + config.setApprovedHarnesses(["claude"]); + await config.flushScope("org:default-org"); + assert.deepEqual((await runtimeConfigBody(ctx, "personal:alice", "alice")).approvedHarnesses, []); +}); + +test("disconnecting ChatGPT removes its models even when deployment keys are available", async () => { + const { ctx, userModelCredentials } = await setup(); + await userModelCredentials.delete("alice", "openai"); + ctx.deps.providerKeys = { anthropic: true, openai: true, openrouter: true }; + assert.deepEqual((await runtimeConfigBody(ctx, "personal:alice", "alice")).approvedHarnesses, []); +}); diff --git a/test/user-model-auth-injection.test.ts b/test/user-model-auth-injection.test.ts index 375018af0..c3d23f76e 100644 --- a/test/user-model-auth-injection.test.ts +++ b/test/user-model-auth-injection.test.ts @@ -242,3 +242,9 @@ test("individual OAuth routing preserves exact supported runtime selections", () assert.equal(resolveIndividualAuthRouting(null, oauth("openai"), "codex/gpt-5.5", "pi")?.model, "codex/gpt-5.5"); assert.equal(resolveIndividualAuthRouting(null, apikey("openai", "test"), "codex/gpt-5.5", "pi"), null); }); + +test("routing: Astra uses the connected Codex subscription and cannot fall back to another provider", () => { + const result = resolveIndividualAuthRouting(null, oauth("openai"), "gpt-6-astra", "codex"); + assert.deepEqual(result, { kind: "oauth", provider: "openai", harness: "codex", model: "gpt-6-astra" }); + assert.equal(resolveIndividualAuthRouting(oauth("anthropic"), null, "gpt-6-astra", "codex"), null); +});