fix: isolate concurrent OpenCode chat runs

This commit is contained in:
brother7 committed 2026-08-17 22:03:37 +08:00
1 parent 7e8d9e3811
commit 65040730ec
29 files changed
+5207 -427

No files matched your search

+115 -56
View File
@@ -22,6 +22,10 @@ import {
NIANCODE_USER_MODEL_ACCOUNT_LABEL,
normalizeImportedUserModelId,
} from '../../../shared/user-model-config';
import {
withRuntimeAcceptanceTimeout,
withRuntimeConfigCoordinator,
} from '../../opencode/runtime-config-readiness';
const legacyProviderRoutesWarned = new Set<string>();
@@ -238,13 +242,22 @@ function localAiProxyBaseUrl(): string {
}
async function fetchCurrentUserModelConfig(accessToken: string): Promise<ImportedUserModelConfig> {
const response = await proxyAwareFetch(createWorksUrl('/api/auth/me/model-config').toString(), {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
const { response, payload } = await withRuntimeAcceptanceTimeout(async (signal) => {
const response = await proxyAwareFetch(
createWorksUrl('/api/auth/me/model-config').toString(),
{
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`,
},
signal,
},
);
signal.throwIfAborted();
const payload = await readResponsePayload(response);
signal.throwIfAborted();
return { response, payload };
});
const payload = await readResponsePayload(response);
if (!response.ok) {
throw new WorksSquareModelConfigError(
response.status >= 400 && response.status < 500 ? response.status : 502,
@@ -310,60 +323,98 @@ async function importedProviderApiKeyChanged(
export async function importCurrentUserModelConfig(
ctx: HostApiContext,
accessToken: string,
): Promise<{ account: ProviderAccount; importedModels: string[] }> {
options: { runtimeRefresh?: 'apply' | 'defer' } = {},
): Promise<{
account: ProviderAccount;
importedModels: string[];
runtimeRefreshRequired: boolean;
}> {
const providerService = getProviderService();
const modelConfig = await fetchCurrentUserModelConfig(accessToken);
const useLocalAiProxy = modelConfig.credentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE;
if (useLocalAiProxy) {
seedWorksSquareAIGatewayCredential({
accessToken: modelConfig.apiKey,
expiresIn: modelConfig.apiKeyExpiresIn,
oneApiBaseUrl: modelConfig.baseUrl,
});
}
const nowMs = Date.now();
const now = new Date(nowMs).toISOString();
const existing = await providerService.getAccount(NIANCODE_USER_MODEL_ACCOUNT_ID);
const accountBaseUrl = useLocalAiProxy ? localAiProxyBaseUrl() : modelConfig.baseUrl;
const accountApiKey = useLocalAiProxy ? getHostApiToken() : modelConfig.apiKey;
const orderedModels = orderImportedModelsForAccount(existing, modelConfig.models);
const account: ProviderAccount = {
id: NIANCODE_USER_MODEL_ACCOUNT_ID,
vendorId: 'custom',
label: modelConfig.label,
authMode: 'api_key',
baseUrl: accountBaseUrl,
apiProtocol: 'openai-completions',
headers: useLocalAiProxy ? undefined : importedUserModelHeaders(modelConfig),
model: orderedModels[0],
fallbackModels: orderedModels.slice(1),
fallbackAccountIds: existing?.fallbackAccountIds,
enabled: true,
isDefault: true,
metadata: importedUserModelMetadata(existing, modelConfig, nowMs, useLocalAiProxy),
createdAt: existing?.createdAt ?? now,
updatedAt: now,
};
const shouldRestartRuntime = importedProviderRuntimeShapeChanged(existing, account)
|| (useLocalAiProxy && await importedProviderApiKeyChanged(providerService, existing, accountApiKey));
return await withRuntimeConfigCoordinator(ctx.opencodeManager, async (lease) => {
let operationMarkedPending = false;
try {
return await withRuntimeAcceptanceTimeout(async (signal) => {
signal.throwIfAborted();
const useLocalAiProxy = modelConfig.credentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE;
const nowMs = Date.now();
const now = new Date(nowMs).toISOString();
const existing = await providerService.getAccount(NIANCODE_USER_MODEL_ACCOUNT_ID);
signal.throwIfAborted();
const accountBaseUrl = useLocalAiProxy ? localAiProxyBaseUrl() : modelConfig.baseUrl;
const accountApiKey = useLocalAiProxy ? getHostApiToken() : modelConfig.apiKey;
const orderedModels = orderImportedModelsForAccount(existing, modelConfig.models);
const account: ProviderAccount = {
id: NIANCODE_USER_MODEL_ACCOUNT_ID,
vendorId: 'custom',
label: modelConfig.label,
authMode: 'api_key',
baseUrl: accountBaseUrl,
apiProtocol: 'openai-completions',
headers: useLocalAiProxy ? undefined : importedUserModelHeaders(modelConfig),
model: orderedModels[0],
fallbackModels: orderedModels.slice(1),
fallbackAccountIds: existing?.fallbackAccountIds,
enabled: true,
isDefault: true,
metadata: importedUserModelMetadata(existing, modelConfig, nowMs, useLocalAiProxy),
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 refreshAlreadyPending = lease.isRefreshPending();
const runtimeRefreshRequired = runtimeIsActive
&& (shouldRestartRuntime || refreshAlreadyPending);
const armStoppedApplyForNextFresh = !runtimeIsActive
&& refreshAlreadyPending
&& options.runtimeRefresh !== 'defer';
if (runtimeRefreshRequired) {
lease.markRefreshPending();
operationMarkedPending = true;
}
const savedAccount = existing
? await providerService.updateAccount(
NIANCODE_USER_MODEL_ACCOUNT_ID,
account,
accountApiKey,
)
: await providerService.createAccount(account, accountApiKey);
signal.throwIfAborted();
if (useLocalAiProxy) {
seedWorksSquareAIGatewayCredential({
accessToken: modelConfig.apiKey,
expiresIn: modelConfig.apiKeyExpiresIn,
oneApiBaseUrl: modelConfig.baseUrl,
});
}
signal.throwIfAborted();
const savedAccount = existing
? await providerService.updateAccount(
NIANCODE_USER_MODEL_ACCOUNT_ID,
account,
accountApiKey,
)
: await providerService.createAccount(account, accountApiKey);
signal.throwIfAborted();
await providerService.setDefaultAccount(NIANCODE_USER_MODEL_ACCOUNT_ID);
if (shouldRestartRuntime) {
await refreshRunningRuntimeAfterProviderChange(ctx);
}
await providerService.setDefaultAccount(NIANCODE_USER_MODEL_ACCOUNT_ID);
signal.throwIfAborted();
if (armStoppedApplyForNextFresh) {
lease.markRefreshPending();
}
if (runtimeRefreshRequired && options.runtimeRefresh !== 'defer') {
await refreshRunningRuntimeAfterProviderChange(ctx);
signal.throwIfAborted();
}
return {
account: savedAccount,
importedModels: modelConfig.models,
};
return {
account: savedAccount,
importedModels: modelConfig.models,
runtimeRefreshRequired,
};
});
} catch (error) {
if (operationMarkedPending) lease.retainRefreshPending();
throw error;
}
});
}
export async function handleProviderRoutes(
@@ -467,11 +518,19 @@ export async function handleProviderRoutes(
}
if (url.pathname === '/api/provider-accounts/import-user-model-config' && req.method === 'POST') {
let runtimeRefresh: 'apply' | 'defer' = 'apply';
try {
const body = await parseJsonBody<{ accessToken?: unknown }>(req);
const body = await parseJsonBody<{ accessToken?: unknown; runtimeRefresh?: unknown }>(req);
if (body.runtimeRefresh !== undefined) {
if (body.runtimeRefresh !== 'apply' && body.runtimeRefresh !== 'defer') {
throw new Error('Invalid runtimeRefresh mode');
}
runtimeRefresh = body.runtimeRefresh;
}
const result = await importCurrentUserModelConfig(
ctx,
readRequiredString(body.accessToken, 'accessToken'),
{ runtimeRefresh },
);
sendJson(res, 200, { success: true, ...result });
} catch (error) {