fix(opencode): keep model switching runtime-hot
This commit is contained in:
@@ -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