fix(opencode): keep model switching runtime-hot
This commit is contained in:
@@ -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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -108,6 +108,7 @@ const useSecureWorksSquareSessionPersistence = shouldUseSecureWorksSquareSession
|
||||
|
||||
async function buildMakeloreOpencodeRuntimeConfig() {
|
||||
return await buildOpencodeRuntimeConfigFromNianCodeProviders({
|
||||
localProxyApiKey: getHostApiToken() || undefined,
|
||||
mcpServers: {
|
||||
[PLAYWRIGHT_MCP_SERVER_ID]: resolvePlaywrightMcpServer(),
|
||||
},
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user