From 214ede2a1299349dac01acff6f593af399bddf42 Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Thu, 27 Aug 2026 00:00:06 +0900 Subject: [PATCH] project model ids per client --- src/app-server.mjs | 101 +++++++++++++++++++++++++++++-------- src/backend/both.mjs | 47 ++++++++++++++--- test/app-server.test.mjs | 8 ++- test/both-adapter.test.mjs | 97 ++++++++++++++++++++++++++++++++++- 4 files changed, 220 insertions(+), 33 deletions(-) diff --git a/src/app-server.mjs b/src/app-server.mjs index 6d330d4..db63d4f 100644 --- a/src/app-server.mjs +++ b/src/app-server.mjs @@ -1037,15 +1037,15 @@ export class CodexAppServer { case "feedback/upload": return this.#feedbackUpload(); case "config/value/write": case "config/batchWrite": - case "config/write": return this.#configWrite(params); + case "config/write": return this.#configWrite(peer, params); case "remoteControl/enable": return {}; - case "config/read": return this.#configRead(params); + case "config/read": return this.#configRead(peer, params); case "features/list": return this.#featuresList(); case "thread/start": return this.#threadStart(peer, params); case "thread/resume": return this.#threadResume(peer, params); case "thread/fork": return this.#threadFork(peer, params); case "thread/inject_items": return this.#threadInjectItems(params); - case "thread/list": return this.#threadList(params); + case "thread/list": return this.#threadList(peer, params); case "thread/read": return this.#threadRead(peer, params); case "thread/turns/list": return this.#threadTurnsList(params); case "thread/items/list": return this.#threadItemsList(params); @@ -1579,12 +1579,15 @@ export class CodexAppServer { return { trackingId: id("feedback") }; } - async #configWrite(params = {}) { + async #configWrite(peer, params = {}) { const config = await this.configPromise; const edits = params.edits || (params.keyPath ? [{ keyPath: params.keyPath, value: params.value }] : []); for (const edit of edits) { if (edit.keyPath === "model" && edit.value) { - config.defaultModel = edit.value; + const backend = await this.backendFactoryPromise; + config.defaultModel = clientNeedsCanonicalModelIds(peer) && typeof backend?.modelForPersistentState === "function" + ? await backend.modelForPersistentState(edit.value) + : edit.value; } if (edit.keyPath === "model_reasoning_effort" && edit.value) { config.defaultReasoningEffort = edit.value; @@ -1676,14 +1679,18 @@ export class CodexAppServer { } catch {} return null; } - async #configRead(params) { + async #configRead(peer, params) { const config = await this.configPromise; const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex"); const codexTomlPath = path.join(codexHome, "config.toml"); const codexToml = (await readTomlFile(codexTomlPath)) || {}; const configuredModel = config.defaultModel || codexToml.model || "gpt-5.6-luna"; - const model = splitTaggedModel(configuredModel).id || configuredModel; + const canonicalModelIds = clientNeedsCanonicalModelIds(peer); + const backend = await this.backendFactoryPromise; + const model = typeof backend?.clientModelForState === "function" + ? await backend.clientModelForState(null, configuredModel, { canonical: canonicalModelIds }) + : (canonicalModelIds ? splitTaggedModel(configuredModel).id : configuredModel); const approvalPolicy = config.defaultApprovalPolicy || codexToml.approval_policy || "never"; const reasoningEffort = config.defaultReasoningEffort || codexToml.model_reasoning_effort || "high"; const base = defaultCodexConfig(model, approvalPolicy, reasoningEffort); @@ -1692,7 +1699,6 @@ export class CodexAppServer { merged.model = model; merged.approval_policy = approvalPolicy; merged.model_reasoning_effort = reasoningEffort; - const backend = await this.backendFactoryPromise; if (backend?.modelProviderId) { merged.model_providers = { ...merged.model_providers, @@ -1765,7 +1771,12 @@ export class CodexAppServer { await this.#persistSession(thread.id, session); peer.subscriptions.add(thread.id); const effectiveParams = { ...params, model: requestedModel, approvalPolicy: params.approvalPolicy || config.defaultApprovalPolicy, reasoningEffort: requestedEffort }; - const response = threadStartResponse(thread, effectiveParams, model); + const projectedThread = await this.#projectThreadModel(peer, thread); + const response = threadStartResponse( + projectedThread, + { ...effectiveParams, model: projectedThread.model }, + normalizeModelParam(projectedThread.model, projectedThread.modelProvider), + ); return { result: response, after: () => this.#broadcast(thread.id, "thread/started", { thread }), @@ -1830,10 +1841,11 @@ export class CodexAppServer { peer.subscriptions.add(threadId); await this.#seedExternalProjection(threadId); await this.#ensureExternalControlSubscription(threadId); + const projectedThread = await this.#projectThreadModel(peer, thread); const response = threadStartResponse( - thread, - { ...params, model: requestedModel || thread.model }, - normalizeModelParam(requestedModel || thread.model, thread.modelProvider), + projectedThread, + { ...params, model: projectedThread.model }, + normalizeModelParam(projectedThread.model, projectedThread.modelProvider), ); return { result: response, @@ -2025,8 +2037,13 @@ export class CodexAppServer { : await backend.openSession({ cwd: effectiveCwd, model: resolvedModel, reasoningEffort: params.reasoningEffort }); this.sessions.set(fork.id, session); await this.#persistSession(fork.id, session); + const projectedFork = await this.#projectThreadModel(peer, fork); return { - result: threadStartResponse(fork, params, normalizeThreadModel(requestedModel, resolvedModel, source.modelProvider)), + result: threadStartResponse( + projectedFork, + { ...params, model: projectedFork.model }, + normalizeModelParam(projectedFork.model, projectedFork.modelProvider), + ), after: () => this.#broadcast(fork.id, "thread/started", { thread: fork }), }; } @@ -2053,13 +2070,15 @@ export class CodexAppServer { return { status: "unsubscribed" }; } - async #threadList(params) { - return this.store.listThreads({ + async #threadList(peer, params) { + const page = await this.store.listThreads({ cursor: params.cursor, limit: params.limit || params.pageSize, archived: Boolean(params.archived), cwd: params.cwd, }); + page.data = await Promise.all(page.data.map((thread) => this.#projectThreadModel(peer, thread))); + return page; } async #threadRead(peer, params) { @@ -2080,7 +2099,7 @@ export class CodexAppServer { await this.#ensureExternalControlSubscription(threadId); const includeTurns = params.includeTurns !== false; if (!includeTurns) thread.turns = []; - return { thread }; + return { thread: await this.#projectThreadModel(peer, thread) }; } async #threadTurnsList(params = {}) { @@ -2131,17 +2150,17 @@ export class CodexAppServer { const thread = await this.store.renameThread(threadId, params.name ?? null); if (!thread) throw new RpcError(ERROR.NOT_FOUND, `Thread not found: ${threadId}`); return { - result: { thread }, + result: { thread: await this.#projectThreadModel(peer, thread) }, after: () => this.#broadcast(threadId, "thread/name/updated", { threadId, name: thread.name }), }; } - async #threadArchive(_peer, params, archived) { + async #threadArchive(peer, params, archived) { const threadId = requireString(params.threadId || params.id, "threadId"); const thread = await this.store.archiveThread(threadId, archived); if (!thread) throw new RpcError(ERROR.NOT_FOUND, `Thread not found: ${threadId}`); return { - result: archived ? {} : { thread }, + result: archived ? {} : { thread: await this.#projectThreadModel(peer, thread) }, after: () => this.#broadcast(threadId, archived ? "thread/archived" : "thread/unarchived", { threadId }), }; } @@ -2165,7 +2184,7 @@ export class CodexAppServer { for (const peer of this.peers) peer.subscriptions.delete(threadId); return {}; } - async #threadRollback(_peer, params = {}) { + async #threadRollback(peer, params = {}) { const threadId = requireString(params.threadId || params.id, "threadId"); const numTurns = Math.max(1, Number(params.numTurns || 1)); const active = this.activeTurns.get(threadId); @@ -2180,7 +2199,7 @@ export class CodexAppServer { const thread = await this.store.rollbackThread(threadId, numTurns); if (!thread) throw new RpcError(ERROR.NOT_FOUND, `Thread not found: ${threadId}`); - return { thread }; + return { thread: await this.#projectThreadModel(peer, thread) }; } async #threadCompact(peer, params) { @@ -3206,6 +3225,43 @@ export class CodexAppServer { return result?.answer ?? result?.value ?? null; } + async #projectThreadModel(peer, thread) { + if (!thread) return thread; + const backend = await this.backendFactoryPromise; + if (typeof backend?.clientModelForState !== "function") return thread; + const record = await this.store.getRecord(thread.id).catch(() => null); + const raw = thread.modelSelector || thread.model; + const model = await backend.clientModelForState(record?.backend, raw, { + canonical: clientNeedsCanonicalModelIds(peer), + }); + if (!model || (thread.model === model && thread.modelSelector === model)) return thread; + return { ...thread, model, modelSelector: model }; + } + + async #projectNotificationForPeer(peer, threadId, method, params) { + if (method === "thread/started" && params?.thread) { + return { ...params, thread: await this.#projectThreadModel(peer, params.thread) }; + } + if (method !== "thread/settings/updated" || !params?.threadSettings?.model) return params; + const thread = await this.store.getThread(threadId).catch(() => null); + const projected = await this.#projectThreadModel(peer, thread); + if (!projected?.model) return params; + const collaborationMode = params.threadSettings.collaborationMode; + return { + ...params, + threadSettings: { + ...params.threadSettings, + model: projected.model, + ...(collaborationMode?.settings ? { + collaborationMode: { + ...collaborationMode, + settings: { ...collaborationMode.settings, model: projected.model }, + }, + } : {}), + }, + }; + } + async #openSession(thread, options = {}) { if (this.sessions.has(thread.id)) return this.sessions.get(thread.id); const backend = await this.backendFactoryPromise; @@ -3258,7 +3314,8 @@ export class CodexAppServer { for (const peer of this.peers) { if (!peer.initialized || peer.disabledNotifications.has(method)) continue; if (!peer.subscriptions.has(threadId) && !isGlobalOrTurnLifecycle) continue; - tasks.push(Promise.resolve(peer.client.notify(method, params))); + const peerParams = await this.#projectNotificationForPeer(peer, threadId, method, params); + tasks.push(Promise.resolve(peer.client.notify(method, peerParams))); } await Promise.allSettled(tasks); } diff --git a/src/backend/both.mjs b/src/backend/both.mjs index 926c48a..c81e3c7 100644 --- a/src/backend/both.mjs +++ b/src/backend/both.mjs @@ -1,22 +1,29 @@ const TAGS = new Set(["omp", "prime-agent"]); export function taggedModelId(modelId, tag) { - return `${modelId}-via-${tag}`; + return `${splitTaggedModel(modelId).id}-via-${tag}`; } export function splitTaggedModel(value) { - const raw = typeof value === "string" + let id = String(typeof value === "string" ? value - : value?.selector || value?.id || value?.model || ""; - const match = /^(.*?)(?:-via-|@)(omp|prime-agent)$/.exec(String(raw)); - return match ? { tag: match[2], id: match[1] } : { tag: null, id: String(raw) }; + : value?.selector || value?.id || value?.model || ""); + let tag = null; + for (;;) { + const match = /^(.*?)(?:-via-|@)(omp|prime-agent)$/.exec(id); + if (!match) break; + tag ||= match[2]; + id = match[1]; + } + return { tag, id }; } export function tagForPersistentState(state) { + if (TAGS.has(state?.backendTag)) return state.backendTag; const kind = state?.kind || state?.backendKind; if (kind === "prime-rpc" || kind === "prime") return "prime-agent"; if (["sdk", "rpc", "omp"].includes(kind)) return "omp"; - return TAGS.has(state?.backendTag) ? state.backendTag : null; + return null; } function underlyingSelector(row) { @@ -148,8 +155,32 @@ export class BothBackendFactory { tagModel(tag, model) { const raw = typeof model === "string" ? model : model?.selector || model?.id || model?.model || ""; - const clean = String(raw).includes("/") ? String(raw).slice(String(raw).indexOf("/") + 1) : splitTaggedModel(raw).id; - return taggedModelId(clean, tag); + return taggedModelId(canonicalModelId(raw), tag); + } + + async clientModelForState(state, model, { canonical = false } = {}) { + const raw = typeof model === "string" ? model : model?.selector || model?.id || model?.model || ""; + const parsed = splitTaggedModel(raw); + const id = canonicalModelId(parsed.id); + if (canonical) return id; + if (!raw) return raw; + if (!this.catalog) await this.listModels(); + const tag = tagForPersistentState(state) || parsed.tag || this.selections.get(id)?.tag || "omp"; + for (const [key, selector] of this.childModels) { + if (!key.startsWith(`${tag}:`)) continue; + const childId = key.slice(tag.length + 1); + if (selector === parsed.id || childId === id) return taggedModelId(childId, tag); + } + return taggedModelId(id, tag); + } + + async modelForPersistentState(model) { + const parsed = splitTaggedModel(model); + const id = canonicalModelId(parsed.id); + if (parsed.tag) return taggedModelId(id, parsed.tag); + if (!this.catalog) await this.listModels(); + const selected = this.selections.get(id); + return taggedModelId(id, selected?.tag || "omp"); } async modelForChild(tag, model) { diff --git a/test/app-server.test.mjs b/test/app-server.test.mjs index ce7622b..b950926 100644 --- a/test/app-server.test.mjs +++ b/test/app-server.test.mjs @@ -279,14 +279,18 @@ test("initialization and model discovery", () => withHarness({}, async (h) => { assert.deepEqual(await h.request("remoteControl/enable", { enabled: true }), {}); })); -test("config/read keeps backend routing tags out of new-thread picker state", () => { +test("config/read keeps backend routing tags out of iOS new-thread picker state", () => { const backendFactory = new MockBackendFactory(); backendFactory.modelProviderId = "omp-codex-bridge"; return withHarness({ backendFactory, config: { defaultModel: "gpt-5.6-luna-via-prime-agent" }, }, async (h) => { - await h.initialize(); + await h.request("initialize", { + clientInfo: { name: "codex_chatgpt_ios_remote", version: "1" }, + capabilities: {}, + }); + await h.notify("initialized"); const { config } = await h.request("config/read", { cwd: h.temp }); assert.equal(config.model, "gpt-5.6-luna"); assert.equal(config.model_provider, "omp-codex-bridge"); diff --git a/test/both-adapter.test.mjs b/test/both-adapter.test.mjs index abb6291..eebcf3c 100644 --- a/test/both-adapter.test.mjs +++ b/test/both-adapter.test.mjs @@ -81,6 +81,11 @@ function harness(server) { test("both backend exposes agent-labelled models and routes the selected row", async () => { assert.deepEqual(splitTaggedModel("gpt-5.6-luna-via-prime-agent"), { tag: "prime-agent", id: "gpt-5.6-luna" }); assert.equal(taggedModelId("gpt-5.6-luna", "omp"), "gpt-5.6-luna-via-omp"); + assert.deepEqual( + splitTaggedModel("gpt-5.6-luna@prime-agent-via-prime-agent-via-prime-agent"), + { tag: "prime-agent", id: "gpt-5.6-luna" }, + ); + assert.equal(taggedModelId("gpt-5.6-luna-via-omp-via-omp", "omp"), "gpt-5.6-luna-via-omp"); const temp = await fs.mkdtemp(path.join(os.tmpdir(), "both-backend-")); const calls = []; @@ -103,6 +108,7 @@ test("both backend exposes agent-labelled models and routes the selected row", a await h.request("initialize", { clientInfo: { name: "both-test" } }); await server.handle(h.peer, { method: "initialized", params: {} }); const configResponse = await h.request("config/read", {}); + assert.equal(configResponse.config.model, "gpt-5.6-sol-via-omp"); assert.equal(configResponse.config.model_provider, "omp-codex-bridge"); assert.equal(configResponse.config.model_providers["omp-codex-bridge"].wire_api, "responses"); const catalog = await h.request("model/list", { includeHidden: true }); @@ -132,7 +138,7 @@ test("both backend exposes agent-labelled models and routes the selected row", a assert.equal(completed.params.turn.items.find((item) => item.type === "agentMessage").text, "prime-rpc"); const read = await h.request("thread/read", { threadId: thread.id, includeTurns: true }); assert.equal(read.thread.model, "gpt-5.6-luna-via-prime-agent"); - assert.equal(read.thread.modelSelector, null); + assert.equal(read.thread.modelSelector, "gpt-5.6-luna-via-prime-agent"); await assert.rejects( h.request("thread/settings/update", { threadId: thread.id, model: "gpt-5.6-luna-via-omp" }), /Cannot switch an existing Prime Agent thread to OMP/ @@ -186,6 +192,95 @@ test("iOS new-thread catalogs use selectable canonical ids without losing backen }); assert.equal(calls[0].kind, "prime-rpc"); assert.equal(calls[0].options.model, "openai-codex/gpt-5.6-luna"); + await h.request("config/batchWrite", { + edits: [{ keyPath: "model", value: "gpt-5.6-sol" }], + }); + assert.equal(config.defaultModel, "gpt-5.6-sol-via-omp"); + } finally { + await server.close(); + await fs.rm(temp, { recursive: true, force: true }); + } +}); + +test("the same iOS-created thread matches each client's model catalog", async () => { + const temp = await fs.mkdtemp(path.join(os.tmpdir(), "both-cross-client-models-")); + const calls = []; + const factory = new BothBackendFactory({ + omp: new FakeFactory("rpc", calls), + prime: new FakeFactory("prime-rpc", calls), + }); + const store = new StateStore(path.join(temp, "state.json")); + const config = new BridgeConfig({ + defaultModel: "gpt-5.6-sol", + modelCatalog: { + include: ["gpt-5.6-*"], + backendOrder: ["omp", "prime-agent"], + order: ["gpt-*"], + }, + }); + const server = new CodexAppServer({ backendFactory: factory, config, store, cwd: temp }); + const ios = harness(server); + const desktop = harness(server); + try { + await ios.request("initialize", { clientInfo: { name: "codex_chatgpt_ios_remote", version: "1" } }); + await server.handle(ios.peer, { method: "initialized", params: {} }); + await desktop.request("initialize", { clientInfo: { name: "codex_chatgpt_desktop", version: "1" } }); + await server.handle(desktop.peer, { method: "initialized", params: {} }); + + const [iosConfig, desktopConfig, iosCatalog, desktopCatalog] = await Promise.all([ + ios.request("config/read", {}), + desktop.request("config/read", {}), + ios.request("model/list", {}), + desktop.request("model/list", {}), + ]); + assert.equal(iosConfig.config.model, "gpt-5.6-sol"); + assert.equal(desktopConfig.config.model, "gpt-5.6-sol-via-omp"); + assert.ok(iosCatalog.data.some((row) => row.id === "gpt-5.6-sol")); + assert.ok(desktopCatalog.data.some((row) => row.id === "gpt-5.6-sol-via-omp")); + + const started = await ios.request("thread/start", { cwd: temp, model: "gpt-5.6-sol" }); + assert.equal(started.thread.model, "gpt-5.6-sol"); + assert.equal(calls[0].kind, "rpc"); + const threadId = started.thread.id; + const stored = await store.getThread(threadId); + assert.equal(stored.model, "gpt-5.6-sol"); + + const [iosStarted, desktopStarted] = await Promise.all([ + ios.waitFor((message) => message.method === "thread/started" && message.params.thread.id === threadId), + desktop.waitFor((message) => message.method === "thread/started" && message.params.thread.id === threadId), + ]); + assert.equal(iosStarted.params.thread.model, "gpt-5.6-sol"); + assert.equal(desktopStarted.params.thread.model, "gpt-5.6-sol-via-omp"); + const [iosRead, desktopRead, iosList, desktopList] = await Promise.all([ + ios.request("thread/read", { threadId, includeTurns: false }), + desktop.request("thread/read", { threadId, includeTurns: false }), + ios.request("thread/list", {}), + desktop.request("thread/list", {}), + ]); + assert.equal(iosRead.thread.model, "gpt-5.6-sol"); + assert.equal(desktopRead.thread.model, "gpt-5.6-sol-via-omp"); + assert.equal(iosList.data.find((thread) => thread.id === threadId).model, "gpt-5.6-sol"); + assert.equal(desktopList.data.find((thread) => thread.id === threadId).model, "gpt-5.6-sol-via-omp"); + + const { turn } = await desktop.request("turn/start", { + threadId, + input: [{ type: "text", text: "project model change" }], + }); + await desktop.waitFor((message) => message.method === "turn/completed" && message.params.turn.id === turn.id); + const iosSettings = ios.messages.findLast((message) => message.method === "thread/settings/updated"); + const desktopSettings = desktop.messages.findLast((message) => message.method === "thread/settings/updated"); + assert.equal(iosSettings.params.threadSettings.model, "gpt-5.6-luna"); + assert.equal(desktopSettings.params.threadSettings.model, "gpt-5.6-luna-via-omp"); + assert.equal(iosSettings.params.threadSettings.collaborationMode.settings.model, "gpt-5.6-luna"); + assert.equal(desktopSettings.params.threadSettings.collaborationMode.settings.model, "gpt-5.6-luna-via-omp"); + + const renamed = await desktop.request("thread/name/set", { threadId, name: "cross-client" }); + assert.equal(renamed.thread.model, "gpt-5.6-luna-via-omp"); + await desktop.request("thread/archive", { threadId }); + const unarchived = await desktop.request("thread/unarchive", { threadId }); + assert.equal(unarchived.thread.model, "gpt-5.6-luna-via-omp"); + const rolledBack = await desktop.request("thread/rollback", { threadId, numTurns: 1 }); + assert.equal(rolledBack.thread.model, "gpt-5.6-luna-via-omp"); } finally { await server.close(); await fs.rm(temp, { recursive: true, force: true }); -- knotmirror 0.0.1