diff --git a/.project-docs/30-worklog/tasks/20260821-model-switch-runtime-fix-a83d6c91.md b/.project-docs/30-worklog/tasks/20260821-model-switch-runtime-fix-a83d6c91.md new file mode 100644 index 0000000..400a587 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260821-model-switch-runtime-fix-a83d6c91.md @@ -0,0 +1,118 @@ +# Task: Fix OpenCode session model switching and local proxy token binding + +## Identity + +- Task ID: 20260821-model-switch-runtime-fix-a83d6c91 +- Mode: Feature +- Branch: codex/20260821-model-switch-runtime-fix-a83d6c91-model-switch-runtime-fix +- Worktree: D:\Datas\OthersProjects\makelore-model-switch-runtime-fix-a83d6c91 +- Base commit: 605fef417fe2e7a5d23d4994fbd109cdfb38efcb +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Correct the OpenCode 1.18.9 Session-model request body at the Main-owned + client boundary while retaining Makelore's internal `{ providerID, modelID }` + reference. +- Ensure a fresh runtime is launched with the current per-application Host API + token for the local AI proxy, and treat persistence of that already-active + token as an internal rebind rather than a user model change. +- Preserve runtime-refresh blocking for actual provider/model shape changes, + direct upstream credential changes, pending refreshes, and attached or unknown + runtime generations. +- Add focused regressions for the real wire payload, fresh-runtime local-proxy + token rotation, and attached-runtime fail-closed behavior. + +## Intent And Constraints + +- Page selection and `/models` / `/model` must converge on OpenCode's native + Session model mutation without provider persistence or runtime restart. +- Ordinary execution must not automatically restart, reload, or dispose the + shared runtime; another Session may still be active. +- Keep Host API credentials Main-owned and out of logs and Renderer state. +- Make surgical changes only in the OpenCode client, runtime provider config, + provider/runtime acceptance, and focused tests. Do not alter project Agent + hot-add semantics or canonical `.project-docs` files in feature mode. +- Work only in the claimed isolated worktree and do not create sub-agents. + +## Outcome + +- Corrected the Main-owned OpenCode client boundary so Makelore's internal + `{ providerID, modelID }` reference is serialized as OpenCode 1.18.9's + required `{ model: { providerID, id } }` request body. +- Added the current per-application Host API token to fresh OpenCode runtime + provider construction for the managed local AI proxy. The persisted token is + no longer allowed to override the token already active for that runtime. +- Changed provider import and prompt preflight so persisting the token already + used by an owned fresh runtime does not mark provider/runtime configuration + stale or request a restart. Attached or unknown runtime generations still + fail closed when the persisted token differs. +- Preserved sticky runtime uncertainty when token persistence rejects, times + out, or is aborted; actual provider shape changes, upstream credential + changes, and existing pending refreshes retain their previous behavior. +- Kept partner creation and Agent hot-add semantics unchanged. The fix is + limited to Session model wire compatibility and the managed local-proxy + credential lifecycle. + +## Verification + +- The original focused regression run failed in all four intended areas before + implementation: Session wire payload, runtime provider token selection, + fresh/attached provider import behavior, and prompt preflight token rebind. +- Installed `@opencode-ai/sdk` 1.18.9 declares `ModelRef` as + `{ id: string; providerID: string; variant?: string }` for + `POST /api/session/{sessionID}/model`, matching the corrected wire payload. +- Focused unit suite for the OpenCode client, provider config, provider routes, + and OpenCode routes — 4 files, 171 tests passed. +- `corepack pnpm run typecheck` — passed. +- `corepack pnpm run lint:check` — passed with 0 errors and 6 pre-existing + warnings in unrelated Renderer files. +- `corepack pnpm test` — 177 files, 2074 tests passed. +- `corepack pnpm run build:vite` — passed; only existing dynamic-import and + chunk-size warnings were reported. +- `corepack pnpm exec playwright test tests/e2e/opencode-multichat-runtime.spec.ts` + — 1 Electron E2E passed. +- `git diff --check` — passed; only configured LF-to-CRLF notices were emitted. +- `check_doc_drift.py --task-id 20260821-model-switch-runtime-fix-a83d6c91` + — passed with only the owned task record changed under `.project-docs`. + +## Follow-ups + +- After integration, relaunch the rebuilt desktop application and repeat the + real local model-selection smoke. The already-running installed process does + not contain this source change. + +## Promotion Candidates + +### Session model wire contract and local-proxy runtime binding + +- Target canonical documents: `.project-docs/30-worklog/current-state.md`, + `.project-docs/20-architecture/module-map.md`, + `.project-docs/20-architecture/data-flow.md`, and + `.project-docs/50-evidence/evidence-index.md`. +- Proposal: retain the existing product rule that page selection and + `/models` / `/model` perform native per-Session switching without provider + persistence or runtime restart, and document the implementation boundary: + Makelore maps internal `modelID` to OpenCode's wire field `id`; an owned fresh + runtime receives the current process Host API token when its local-proxy + provider config is built, so later persistence of that same active token is + not a runtime configuration change. Attached/unknown generations and + timeout/partial persistence remain fail-closed. +- Evidence: installed OpenCode SDK 1.18.9 `ModelRef` and switch-model endpoint + types; `electron/opencode/client.ts`, `electron/opencode/provider-config.ts`, + `electron/main/index.ts`, provider/OpenCode route changes, 171 focused unit + tests, 2074 full unit tests, typecheck, lint, production build, and the + existing model-switch Electron E2E. +- Future impact: OpenCode upgrades must recheck the exact Session model wire + schema. Runtime readiness work must distinguish an ephemeral Main-owned proxy + token already injected into an owned fresh generation from a true upstream + provider credential change; otherwise every application launch can recreate + the false manual-restart requirement. +- Semantic conflicts: canonical memory already says Session switching does not + require a restart, while the previously integrated client serialized the + incompatible `modelID` field and treated the per-process proxy token as a + provider credential rotation. This candidate corrects the implementation and + refines the credential boundary without changing the accepted product rule. +- Human confirmation required: no; the user explicitly requested the repair + and the change is covered by regression tests. diff --git a/electron/api/routes/opencode.ts b/electron/api/routes/opencode.ts index 7df79de..06464f8 100644 --- a/electron/api/routes/opencode.ts +++ b/electron/api/routes/opencode.ts @@ -511,6 +511,7 @@ function expectedUserModelProxyConfig( async function rebindLocalProxyHostApiTokenIfNeeded( expectedBaseUrl: string, markPending: () => void, + runtimeAlreadyUsesCurrentHostApiToken: boolean, signal?: AbortSignal, ): Promise { const providerService = getProviderService(); @@ -532,14 +533,31 @@ async function rebindLocalProxyHostApiTokenIfNeeded( return false; } - markPending(); - await providerService.updateAccount( - NIANCODE_USER_MODEL_ACCOUNT_ID, - account, - currentHostApiToken, - ); - signal?.throwIfAborted(); - return true; + if (!runtimeAlreadyUsesCurrentHostApiToken) { + markPending(); + } + const markPendingOnAbort = runtimeAlreadyUsesCurrentHostApiToken + ? () => markPending() + : undefined; + if (markPendingOnAbort) { + signal?.addEventListener('abort', markPendingOnAbort, { once: true }); + } + try { + await providerService.updateAccount( + NIANCODE_USER_MODEL_ACCOUNT_ID, + account, + currentHostApiToken, + ); + signal?.throwIfAborted(); + } catch (error) { + markPending(); + throw error; + } finally { + if (markPendingOnAbort) { + signal?.removeEventListener('abort', markPendingOnAbort); + } + } + return !runtimeAlreadyUsesCurrentHostApiToken; } async function runtimeUsesExpectedUserModelProxy( @@ -607,7 +625,12 @@ async function ensureRuntimeUserModelProxyConfig( try { if ( expectedConfig.usesLocalProxy - && await rebindLocalProxyHostApiTokenIfNeeded(expectedConfig.baseUrl, markPending, signal) + && await rebindLocalProxyHostApiTokenIfNeeded( + expectedConfig.baseUrl, + markPending, + ctx.opencodeManager.getRuntimeGenerationProvenance() === 'fresh', + signal, + ) ) { if (!allowRestart) { markPending(); diff --git a/electron/api/routes/providers.ts b/electron/api/routes/providers.ts index 52a89b8..5194559 100644 --- a/electron/api/routes/providers.ts +++ b/electron/api/routes/providers.ts @@ -361,10 +361,13 @@ export async function importCurrentUserModelConfig( createdAt: existing?.createdAt ?? now, updatedAt: now, }; - const shouldRestartRuntime = importedProviderRuntimeShapeChanged(existing, account) - || await importedProviderApiKeyChanged(providerService, existing, accountApiKey); - signal.throwIfAborted(); const runtimeIsActive = ctx.opencodeManager.getStatus().state !== 'stopped'; + const runtimeAlreadyUsesLocalProxyApiKey = useLocalAiProxy + && ctx.opencodeManager.getRuntimeGenerationProvenance?.() === 'fresh'; + const shouldRestartRuntime = importedProviderRuntimeShapeChanged(existing, account) + || (!runtimeAlreadyUsesLocalProxyApiKey + && await importedProviderApiKeyChanged(providerService, existing, accountApiKey)); + signal.throwIfAborted(); const refreshAlreadyPending = lease.isRefreshPending(); const runtimeRefreshRequired = runtimeIsActive && (shouldRestartRuntime || refreshAlreadyPending); diff --git a/electron/main/index.ts b/electron/main/index.ts index 63ead60..d1584c8 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -108,6 +108,7 @@ const useSecureWorksSquareSessionPersistence = shouldUseSecureWorksSquareSession async function buildMakeloreOpencodeRuntimeConfig() { return await buildOpencodeRuntimeConfigFromNianCodeProviders({ + localProxyApiKey: getHostApiToken() || undefined, mcpServers: { [PLAYWRIGHT_MCP_SERVER_ID]: resolvePlaywrightMcpServer(), }, diff --git a/electron/opencode/client.ts b/electron/opencode/client.ts index 3584f7e..78b7b7b 100644 --- a/electron/opencode/client.ts +++ b/electron/opencode/client.ts @@ -278,7 +278,12 @@ export function createOpencodeClient(options: OpencodeClientOptions) { ): Promise => request(`/api/session/${encodeURIComponent(sessionID)}/model`, { method: 'POST', - body: JSON.stringify({ model }), + body: JSON.stringify({ + model: { + providerID: model.providerID, + id: model.modelID, + }, + }), signal: options?.signal, }), executeSessionCommand: ( diff --git a/electron/opencode/provider-config.ts b/electron/opencode/provider-config.ts index d55fe53..c23f79b 100644 --- a/electron/opencode/provider-config.ts +++ b/electron/opencode/provider-config.ts @@ -23,6 +23,7 @@ const OPENAI_COMPATIBLE_PACKAGE = '@ai-sdk/openai-compatible'; const OPENAI_PACKAGE = '@ai-sdk/openai'; const ANTHROPIC_PACKAGE = '@ai-sdk/anthropic'; const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway'; +const WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE = 'works_square_ai_gateway_proxy'; const OPENCODE_BUILTIN_PROVIDER_IDS = new Set([ 'anthropic', @@ -120,6 +121,7 @@ export interface BuildOpencodeRuntimeConfigOptions { accounts: ProviderAccount[]; defaultAccountId?: string | null; resolveApiKey: ResolveOpencodeProviderApiKey; + localProxyApiKey?: string; mcpServers?: Record; } @@ -303,7 +305,11 @@ export async function buildOpencodeRuntimeConfig( continue; } - const apiKey = await options.resolveApiKey(account, opencodeProviderId); + const localProxyApiKey = account.id === NIANCODE_USER_MODEL_ACCOUNT_ID + && account.metadata?.worksSquareCredentialMode === WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE + ? options.localProxyApiKey + : undefined; + const apiKey = localProxyApiKey ?? await options.resolveApiKey(account, opencodeProviderId); if (!apiKey && accountNeedsApiKey(account)) { continue; } @@ -367,6 +373,7 @@ async function resolveNianCodeApiKey( } export interface BuildOpencodeRuntimeConfigFromNianCodeProvidersOptions { + localProxyApiKey?: string; mcpServers?: Record; } @@ -383,6 +390,7 @@ export async function buildOpencodeRuntimeConfigFromNianCodeProviders( accounts, defaultAccountId, resolveApiKey: resolveNianCodeApiKey, + localProxyApiKey: options.localProxyApiKey, mcpServers: options.mcpServers, }); } diff --git a/tests/unit/opencode-client.test.ts b/tests/unit/opencode-client.test.ts index 995f3c6..ad5deeb 100644 --- a/tests/unit/opencode-client.test.ts +++ b/tests/unit/opencode-client.test.ts @@ -190,7 +190,7 @@ describe('opencode client', () => { body: JSON.stringify({ model: { providerID: 'niancode-user-models', - modelID: 'qwen3.7-plus', + id: 'qwen3.7-plus', }, }), signal: controller.signal, diff --git a/tests/unit/opencode-provider-config.test.ts b/tests/unit/opencode-provider-config.test.ts index cf01bc5..2aebc67 100644 --- a/tests/unit/opencode-provider-config.test.ts +++ b/tests/unit/opencode-provider-config.test.ts @@ -341,6 +341,7 @@ describe('buildOpencodeRuntimeConfig', () => { }); it('routes imported Works Square models through the local AI proxy without gateway-token headers', async () => { + const resolveApiKey = vi.fn().mockResolvedValue('stale-host-api-token'); const result = await buildOpencodeRuntimeConfig({ accounts: [ createAccount({ @@ -358,7 +359,8 @@ describe('buildOpencodeRuntimeConfig', () => { }), ], defaultAccountId: 'niancode-user-models', - resolveApiKey: vi.fn().mockResolvedValue('local-host-api-token'), + resolveApiKey, + localProxyApiKey: 'current-host-api-token', }); expect(result.config.provider['niancode-user-models'].options).toEqual({ @@ -368,8 +370,9 @@ describe('buildOpencodeRuntimeConfig', () => { expect(JSON.stringify(result.config)).not.toContain('X-Works-Square-AI-Token'); expect(JSON.stringify(result.config)).not.toContain('https://open-api.example.com'); expect(result.env).toEqual({ - NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY: 'local-host-api-token', + NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY: 'current-host-api-token', }); + expect(resolveApiKey).not.toHaveBeenCalled(); }); it('strips DeepSeek routing prefixes from imported Works Square runtime models', async () => { diff --git a/tests/unit/opencode-routes.test.ts b/tests/unit/opencode-routes.test.ts index 41feb48..bac88ff 100644 --- a/tests/unit/opencode-routes.test.ts +++ b/tests/unit/opencode-routes.test.ts @@ -3588,6 +3588,7 @@ description: Browser debugging. { opencodeManager: { getStatus: () => runtimeStatus, + getRuntimeGenerationProvenance: () => 'fresh', restart, }, opencodeProjectStore: { @@ -4029,13 +4030,15 @@ description: Browser debugging. updatedAt: '2026-07-06T00:00:00.000Z', }; providerServiceMock.getAccount.mockResolvedValue(existingAccount); - providerServiceMock.getAccountApiKey.mockResolvedValue('old-host-api-token'); + let storedApiKey = 'old-host-api-token'; + providerServiceMock.getAccountApiKey.mockImplementation(async () => storedApiKey); let releaseRebind!: () => void; const rebindGate = new Promise((resolve) => { releaseRebind = resolve; }); - providerServiceMock.updateAccount.mockImplementationOnce(async () => { + providerServiceMock.updateAccount.mockImplementationOnce(async (_accountId, _account, apiKey) => { await rebindGate; + storedApiKey = apiKey; return existingAccount; }); buildConfigSummaryMock.mockResolvedValue({ @@ -4109,18 +4112,13 @@ description: Browser debugging. 'current-host-api-token', ); expect(restart).not.toHaveBeenCalled(); - expect(promptSessionAsync).not.toHaveBeenCalled(); - expect(response.statusCode).toBe(409); - expect(response.json()).toMatchObject({ - code: 'OPENCODE_RUNTIME_CONFIG_PENDING', - promptSent: false, - }); - expect(sameGenerationResponse.statusCode).toBe(409); - expect(promptSessionAsync).not.toHaveBeenCalled(); - providerServiceMock.getAccountApiKey.mockResolvedValue('current-host-api-token'); + expect(promptSessionAsync).toHaveBeenCalledTimes(2); + expect(response.statusCode).toBe(202); + expect(sameGenerationResponse.statusCode).toBe(202); runtimeGeneration = 2; provenance = 'attached'; + storedApiKey = 'old-host-api-token'; const attachedResponse = createResponse(); await handleOpencodeRoutes( createRequest('POST', { text: 'Attached is not authoritative' }), @@ -4129,10 +4127,11 @@ description: Browser debugging. context, ); expect(attachedResponse.statusCode).toBe(409); - expect(promptSessionAsync).not.toHaveBeenCalled(); + expect(promptSessionAsync).toHaveBeenCalledTimes(2); runtimeGeneration = 3; provenance = 'fresh'; + storedApiKey = 'current-host-api-token'; const freshResponse = createResponse(); await handleOpencodeRoutes( createRequest('POST', { text: 'Fresh generation' }), @@ -4141,7 +4140,7 @@ description: Browser debugging. context, ); expect(freshResponse.statusCode).toBe(202); - expect(promptSessionAsync).toHaveBeenCalledOnce(); + expect(promptSessionAsync).toHaveBeenCalledTimes(3); }); it('fails closed when rebinding the local proxy Host API token is rejected', async () => { diff --git a/tests/unit/provider-routes.test.ts b/tests/unit/provider-routes.test.ts index d5a2bfd..9681078 100644 --- a/tests/unit/provider-routes.test.ts +++ b/tests/unit/provider-routes.test.ts @@ -412,68 +412,76 @@ describe('provider host api routes', () => { ); }); - it('restarts a running runtime when the local proxy Host API token changed', async () => { - const existingAccount = createProviderAccount({ - id: 'niancode-user-models', - vendorId: 'custom', - label: 'Makelore Models', - authMode: 'api_key', - baseUrl: 'http://127.0.0.1:13210/api/ai-proxy/v1', - apiProtocol: 'openai-completions', - model: 'gpt-4.1-mini', - fallbackModels: ['gpt-4o-mini'], - enabled: true, - isDefault: true, - metadata: { - customModels: ['gpt-4.1-mini', 'gpt-4o-mini'], - worksSquareCredentialMode: 'works_square_ai_gateway_proxy', - worksSquareOneApiBaseUrl: 'https://one-api.example.com/v1', - }, - }); - providerServiceMock.getAccount.mockResolvedValueOnce(existingAccount); - providerServiceMock.getAccountApiKey.mockResolvedValueOnce('old-host-api-token'); - getHostApiTokenMock.mockReturnValueOnce('new-host-api-token'); - providerServiceMock.updateAccount.mockResolvedValueOnce(existingAccount); - const fetchMock = vi.fn().mockResolvedValueOnce( - new Response(JSON.stringify({ - provider_type: 'openai-compatible', + it.each([ + ['fresh', false], + ['attached', true], + ] as const)( + '%s runtime handles a changed local proxy Host API token without losing freshness guarantees', + async (provenance, runtimeRefreshRequired) => { + const existingAccount = createProviderAccount({ + id: 'niancode-user-models', + vendorId: 'custom', label: 'Makelore Models', - base_url: 'https://one-api.example.com/v1', - api_key: 'fresh-ws-ai-token', - credential_mode: 'works_square_ai_gateway', - api_key_expires_in: 3600, - models: ['gpt-4.1-mini', 'gpt-4o-mini'], - }), { status: 200 }), - ); - vi.stubGlobal('fetch', fetchMock); - const response = createResponse(); - const restart = vi.fn(async () => ({ state: 'running', port: 4096, pid: 4242 })); - - await handleProviderRoutes( - createRequest('POST', { accessToken: 'access-token' }), - response.res, - new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'), - { - opencodeManager: { - getStatus: () => ({ state: 'running', port: 4096 }), - restart, - }, - } as never, - ); - - expect(response.statusCode).toBe(200); - expect(providerServiceMock.updateAccount).toHaveBeenCalledWith( - 'niancode-user-models', - expect.objectContaining({ + authMode: 'api_key', baseUrl: 'http://127.0.0.1:13210/api/ai-proxy/v1', - metadata: expect.objectContaining({ + apiProtocol: 'openai-completions', + model: 'gpt-4.1-mini', + fallbackModels: ['gpt-4o-mini'], + enabled: true, + isDefault: true, + metadata: { + customModels: ['gpt-4.1-mini', 'gpt-4o-mini'], worksSquareCredentialMode: 'works_square_ai_gateway_proxy', + worksSquareOneApiBaseUrl: 'https://one-api.example.com/v1', + }, + }); + providerServiceMock.getAccount.mockResolvedValueOnce(existingAccount); + providerServiceMock.getAccountApiKey.mockResolvedValueOnce('old-host-api-token'); + getHostApiTokenMock.mockReturnValueOnce('new-host-api-token'); + providerServiceMock.updateAccount.mockResolvedValueOnce(existingAccount); + const fetchMock = vi.fn().mockResolvedValueOnce( + new Response(JSON.stringify({ + provider_type: 'openai-compatible', + label: 'Makelore Models', + base_url: 'https://one-api.example.com/v1', + api_key: 'fresh-ws-ai-token', + credential_mode: 'works_square_ai_gateway', + api_key_expires_in: 3600, + models: ['gpt-4.1-mini', 'gpt-4o-mini'], + }), { status: 200 }), + ); + vi.stubGlobal('fetch', fetchMock); + const response = createResponse(); + const restart = vi.fn(async () => ({ state: 'running', port: 4096, pid: 4242 })); + + await handleProviderRoutes( + createRequest('POST', { accessToken: 'access-token' }), + response.res, + new URL('http://127.0.0.1/api/provider-accounts/import-user-model-config'), + { + opencodeManager: { + getStatus: () => ({ state: 'running', port: 4096 }), + getRuntimeGenerationProvenance: () => provenance, + restart, + }, + } as never, + ); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ runtimeRefreshRequired }); + expect(providerServiceMock.updateAccount).toHaveBeenCalledWith( + 'niancode-user-models', + expect.objectContaining({ + baseUrl: 'http://127.0.0.1:13210/api/ai-proxy/v1', + metadata: expect.objectContaining({ + worksSquareCredentialMode: 'works_square_ai_gateway_proxy', + }), }), - }), - 'new-host-api-token', - ); - expect(restart).toHaveBeenCalledOnce(); - }); + 'new-host-api-token', + ); + expect(restart).toHaveBeenCalledTimes(runtimeRefreshRequired ? 1 : 0); + }, + ); it('defers a direct API-key rotation without restarting and reports the runtime refresh requirement', async () => { const existingAccount = createProviderAccount({