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(
|
async function rebindLocalProxyHostApiTokenIfNeeded(
|
||||||
expectedBaseUrl: string,
|
expectedBaseUrl: string,
|
||||||
markPending: () => void,
|
markPending: () => void,
|
||||||
|
runtimeAlreadyUsesCurrentHostApiToken: boolean,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const providerService = getProviderService();
|
const providerService = getProviderService();
|
||||||
@@ -532,14 +533,31 @@ async function rebindLocalProxyHostApiTokenIfNeeded(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
markPending();
|
if (!runtimeAlreadyUsesCurrentHostApiToken) {
|
||||||
await providerService.updateAccount(
|
markPending();
|
||||||
NIANCODE_USER_MODEL_ACCOUNT_ID,
|
}
|
||||||
account,
|
const markPendingOnAbort = runtimeAlreadyUsesCurrentHostApiToken
|
||||||
currentHostApiToken,
|
? () => markPending()
|
||||||
);
|
: undefined;
|
||||||
signal?.throwIfAborted();
|
if (markPendingOnAbort) {
|
||||||
return true;
|
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(
|
async function runtimeUsesExpectedUserModelProxy(
|
||||||
@@ -607,7 +625,12 @@ async function ensureRuntimeUserModelProxyConfig(
|
|||||||
try {
|
try {
|
||||||
if (
|
if (
|
||||||
expectedConfig.usesLocalProxy
|
expectedConfig.usesLocalProxy
|
||||||
&& await rebindLocalProxyHostApiTokenIfNeeded(expectedConfig.baseUrl, markPending, signal)
|
&& await rebindLocalProxyHostApiTokenIfNeeded(
|
||||||
|
expectedConfig.baseUrl,
|
||||||
|
markPending,
|
||||||
|
ctx.opencodeManager.getRuntimeGenerationProvenance() === 'fresh',
|
||||||
|
signal,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
if (!allowRestart) {
|
if (!allowRestart) {
|
||||||
markPending();
|
markPending();
|
||||||
|
|||||||
@@ -361,10 +361,13 @@ export async function importCurrentUserModelConfig(
|
|||||||
createdAt: existing?.createdAt ?? now,
|
createdAt: existing?.createdAt ?? now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
const shouldRestartRuntime = importedProviderRuntimeShapeChanged(existing, account)
|
|
||||||
|| await importedProviderApiKeyChanged(providerService, existing, accountApiKey);
|
|
||||||
signal.throwIfAborted();
|
|
||||||
const runtimeIsActive = ctx.opencodeManager.getStatus().state !== 'stopped';
|
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 refreshAlreadyPending = lease.isRefreshPending();
|
||||||
const runtimeRefreshRequired = runtimeIsActive
|
const runtimeRefreshRequired = runtimeIsActive
|
||||||
&& (shouldRestartRuntime || refreshAlreadyPending);
|
&& (shouldRestartRuntime || refreshAlreadyPending);
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ const useSecureWorksSquareSessionPersistence = shouldUseSecureWorksSquareSession
|
|||||||
|
|
||||||
async function buildMakeloreOpencodeRuntimeConfig() {
|
async function buildMakeloreOpencodeRuntimeConfig() {
|
||||||
return await buildOpencodeRuntimeConfigFromNianCodeProviders({
|
return await buildOpencodeRuntimeConfigFromNianCodeProviders({
|
||||||
|
localProxyApiKey: getHostApiToken() || undefined,
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
[PLAYWRIGHT_MCP_SERVER_ID]: resolvePlaywrightMcpServer(),
|
[PLAYWRIGHT_MCP_SERVER_ID]: resolvePlaywrightMcpServer(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -278,7 +278,12 @@ export function createOpencodeClient(options: OpencodeClientOptions) {
|
|||||||
): Promise<void> =>
|
): Promise<void> =>
|
||||||
request<void>(`/api/session/${encodeURIComponent(sessionID)}/model`, {
|
request<void>(`/api/session/${encodeURIComponent(sessionID)}/model`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ model }),
|
body: JSON.stringify({
|
||||||
|
model: {
|
||||||
|
providerID: model.providerID,
|
||||||
|
id: model.modelID,
|
||||||
|
},
|
||||||
|
}),
|
||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
}),
|
}),
|
||||||
executeSessionCommand: (
|
executeSessionCommand: (
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const OPENAI_COMPATIBLE_PACKAGE = '@ai-sdk/openai-compatible';
|
|||||||
const OPENAI_PACKAGE = '@ai-sdk/openai';
|
const OPENAI_PACKAGE = '@ai-sdk/openai';
|
||||||
const ANTHROPIC_PACKAGE = '@ai-sdk/anthropic';
|
const ANTHROPIC_PACKAGE = '@ai-sdk/anthropic';
|
||||||
const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway';
|
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([
|
const OPENCODE_BUILTIN_PROVIDER_IDS = new Set([
|
||||||
'anthropic',
|
'anthropic',
|
||||||
@@ -120,6 +121,7 @@ export interface BuildOpencodeRuntimeConfigOptions {
|
|||||||
accounts: ProviderAccount[];
|
accounts: ProviderAccount[];
|
||||||
defaultAccountId?: string | null;
|
defaultAccountId?: string | null;
|
||||||
resolveApiKey: ResolveOpencodeProviderApiKey;
|
resolveApiKey: ResolveOpencodeProviderApiKey;
|
||||||
|
localProxyApiKey?: string;
|
||||||
mcpServers?: Record<string, OpencodeMcpServerEntry>;
|
mcpServers?: Record<string, OpencodeMcpServerEntry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +305,11 @@ export async function buildOpencodeRuntimeConfig(
|
|||||||
continue;
|
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)) {
|
if (!apiKey && accountNeedsApiKey(account)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -367,6 +373,7 @@ async function resolveNianCodeApiKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildOpencodeRuntimeConfigFromNianCodeProvidersOptions {
|
export interface BuildOpencodeRuntimeConfigFromNianCodeProvidersOptions {
|
||||||
|
localProxyApiKey?: string;
|
||||||
mcpServers?: Record<string, OpencodeMcpServerEntry>;
|
mcpServers?: Record<string, OpencodeMcpServerEntry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +390,7 @@ export async function buildOpencodeRuntimeConfigFromNianCodeProviders(
|
|||||||
accounts,
|
accounts,
|
||||||
defaultAccountId,
|
defaultAccountId,
|
||||||
resolveApiKey: resolveNianCodeApiKey,
|
resolveApiKey: resolveNianCodeApiKey,
|
||||||
|
localProxyApiKey: options.localProxyApiKey,
|
||||||
mcpServers: options.mcpServers,
|
mcpServers: options.mcpServers,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ describe('opencode client', () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: {
|
model: {
|
||||||
providerID: 'niancode-user-models',
|
providerID: 'niancode-user-models',
|
||||||
modelID: 'qwen3.7-plus',
|
id: 'qwen3.7-plus',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
signal: controller.signal,
|
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 () => {
|
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({
|
const result = await buildOpencodeRuntimeConfig({
|
||||||
accounts: [
|
accounts: [
|
||||||
createAccount({
|
createAccount({
|
||||||
@@ -358,7 +359,8 @@ describe('buildOpencodeRuntimeConfig', () => {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
defaultAccountId: 'niancode-user-models',
|
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({
|
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('X-Works-Square-AI-Token');
|
||||||
expect(JSON.stringify(result.config)).not.toContain('https://open-api.example.com');
|
expect(JSON.stringify(result.config)).not.toContain('https://open-api.example.com');
|
||||||
expect(result.env).toEqual({
|
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 () => {
|
it('strips DeepSeek routing prefixes from imported Works Square runtime models', async () => {
|
||||||
|
|||||||
@@ -3588,6 +3588,7 @@ description: Browser debugging.
|
|||||||
{
|
{
|
||||||
opencodeManager: {
|
opencodeManager: {
|
||||||
getStatus: () => runtimeStatus,
|
getStatus: () => runtimeStatus,
|
||||||
|
getRuntimeGenerationProvenance: () => 'fresh',
|
||||||
restart,
|
restart,
|
||||||
},
|
},
|
||||||
opencodeProjectStore: {
|
opencodeProjectStore: {
|
||||||
@@ -4029,13 +4030,15 @@ description: Browser debugging.
|
|||||||
updatedAt: '2026-07-06T00:00:00.000Z',
|
updatedAt: '2026-07-06T00:00:00.000Z',
|
||||||
};
|
};
|
||||||
providerServiceMock.getAccount.mockResolvedValue(existingAccount);
|
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;
|
let releaseRebind!: () => void;
|
||||||
const rebindGate = new Promise<void>((resolve) => {
|
const rebindGate = new Promise<void>((resolve) => {
|
||||||
releaseRebind = resolve;
|
releaseRebind = resolve;
|
||||||
});
|
});
|
||||||
providerServiceMock.updateAccount.mockImplementationOnce(async () => {
|
providerServiceMock.updateAccount.mockImplementationOnce(async (_accountId, _account, apiKey) => {
|
||||||
await rebindGate;
|
await rebindGate;
|
||||||
|
storedApiKey = apiKey;
|
||||||
return existingAccount;
|
return existingAccount;
|
||||||
});
|
});
|
||||||
buildConfigSummaryMock.mockResolvedValue({
|
buildConfigSummaryMock.mockResolvedValue({
|
||||||
@@ -4109,18 +4112,13 @@ description: Browser debugging.
|
|||||||
'current-host-api-token',
|
'current-host-api-token',
|
||||||
);
|
);
|
||||||
expect(restart).not.toHaveBeenCalled();
|
expect(restart).not.toHaveBeenCalled();
|
||||||
expect(promptSessionAsync).not.toHaveBeenCalled();
|
expect(promptSessionAsync).toHaveBeenCalledTimes(2);
|
||||||
expect(response.statusCode).toBe(409);
|
expect(response.statusCode).toBe(202);
|
||||||
expect(response.json()).toMatchObject({
|
expect(sameGenerationResponse.statusCode).toBe(202);
|
||||||
code: 'OPENCODE_RUNTIME_CONFIG_PENDING',
|
|
||||||
promptSent: false,
|
|
||||||
});
|
|
||||||
expect(sameGenerationResponse.statusCode).toBe(409);
|
|
||||||
expect(promptSessionAsync).not.toHaveBeenCalled();
|
|
||||||
providerServiceMock.getAccountApiKey.mockResolvedValue('current-host-api-token');
|
|
||||||
|
|
||||||
runtimeGeneration = 2;
|
runtimeGeneration = 2;
|
||||||
provenance = 'attached';
|
provenance = 'attached';
|
||||||
|
storedApiKey = 'old-host-api-token';
|
||||||
const attachedResponse = createResponse();
|
const attachedResponse = createResponse();
|
||||||
await handleOpencodeRoutes(
|
await handleOpencodeRoutes(
|
||||||
createRequest('POST', { text: 'Attached is not authoritative' }),
|
createRequest('POST', { text: 'Attached is not authoritative' }),
|
||||||
@@ -4129,10 +4127,11 @@ description: Browser debugging.
|
|||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
expect(attachedResponse.statusCode).toBe(409);
|
expect(attachedResponse.statusCode).toBe(409);
|
||||||
expect(promptSessionAsync).not.toHaveBeenCalled();
|
expect(promptSessionAsync).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
runtimeGeneration = 3;
|
runtimeGeneration = 3;
|
||||||
provenance = 'fresh';
|
provenance = 'fresh';
|
||||||
|
storedApiKey = 'current-host-api-token';
|
||||||
const freshResponse = createResponse();
|
const freshResponse = createResponse();
|
||||||
await handleOpencodeRoutes(
|
await handleOpencodeRoutes(
|
||||||
createRequest('POST', { text: 'Fresh generation' }),
|
createRequest('POST', { text: 'Fresh generation' }),
|
||||||
@@ -4141,7 +4140,7 @@ description: Browser debugging.
|
|||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
expect(freshResponse.statusCode).toBe(202);
|
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 () => {
|
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 () => {
|
it.each([
|
||||||
const existingAccount = createProviderAccount({
|
['fresh', false],
|
||||||
id: 'niancode-user-models',
|
['attached', true],
|
||||||
vendorId: 'custom',
|
] as const)(
|
||||||
label: 'Makelore Models',
|
'%s runtime handles a changed local proxy Host API token without losing freshness guarantees',
|
||||||
authMode: 'api_key',
|
async (provenance, runtimeRefreshRequired) => {
|
||||||
baseUrl: 'http://127.0.0.1:13210/api/ai-proxy/v1',
|
const existingAccount = createProviderAccount({
|
||||||
apiProtocol: 'openai-completions',
|
id: 'niancode-user-models',
|
||||||
model: 'gpt-4.1-mini',
|
vendorId: 'custom',
|
||||||
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',
|
label: 'Makelore Models',
|
||||||
base_url: 'https://one-api.example.com/v1',
|
authMode: 'api_key',
|
||||||
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({
|
|
||||||
baseUrl: 'http://127.0.0.1:13210/api/ai-proxy/v1',
|
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',
|
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',
|
||||||
'new-host-api-token',
|
);
|
||||||
);
|
expect(restart).toHaveBeenCalledTimes(runtimeRefreshRequired ? 1 : 0);
|
||||||
expect(restart).toHaveBeenCalledOnce();
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
it('defers a direct API-key rotation without restarting and reports the runtime refresh requirement', async () => {
|
it('defers a direct API-key rotation without restarting and reports the runtime refresh requirement', async () => {
|
||||||
const existingAccount = createProviderAccount({
|
const existingAccount = createProviderAccount({
|
||||||
|
|||||||
Reference in New Issue
Block a user