fix(opencode): keep model switching runtime-hot

This commit is contained in:
2026-08-21 01:26:20 +08:00
parent 605fef417f
commit cce772299a
10 changed files with 256 additions and 88 deletions

View File

@@ -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.

View File

@@ -511,6 +511,7 @@ function expectedUserModelProxyConfig(
async function rebindLocalProxyHostApiTokenIfNeeded(
expectedBaseUrl: string,
markPending: () => void,
runtimeAlreadyUsesCurrentHostApiToken: boolean,
signal?: AbortSignal,
): Promise<boolean> {
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();

View File

@@ -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);

View File

@@ -108,6 +108,7 @@ const useSecureWorksSquareSessionPersistence = shouldUseSecureWorksSquareSession
async function buildMakeloreOpencodeRuntimeConfig() {
return await buildOpencodeRuntimeConfigFromNianCodeProviders({
localProxyApiKey: getHostApiToken() || undefined,
mcpServers: {
[PLAYWRIGHT_MCP_SERVER_ID]: resolvePlaywrightMcpServer(),
},

View File

@@ -278,7 +278,12 @@ export function createOpencodeClient(options: OpencodeClientOptions) {
): Promise<void> =>
request<void>(`/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: (

View File

@@ -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<string, OpencodeMcpServerEntry>;
}
@@ -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<string, OpencodeMcpServerEntry>;
}
@@ -383,6 +390,7 @@ export async function buildOpencodeRuntimeConfigFromNianCodeProviders(
accounts,
defaultAccountId,
resolveApiKey: resolveNianCodeApiKey,
localProxyApiKey: options.localProxyApiKey,
mcpServers: options.mcpServers,
});
}

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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<void>((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 () => {

View File

@@ -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({