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

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