blob: db2e49688e697fb4f62d31ff5fb1120ade55ba01 [file]
import {
PROVIDER_DEFAULTS,
connectionEnabledModelIds,
type ConnectionTestErrorClass,
type ConnectionTestResult,
type LlmConnection,
} from '@maka/core';
import { anthropicV1Url, googleApiUrl } from './provider-urls.js';
import { resolveModelRuntime } from './model-runtime.js';
import { claudeSubscriptionHeaders } from './subscription-auth.js';
import { fetchGitHubCopilotModels } from './model-fetcher.js';
import {
CONNECTION_EFFECT_ERROR_BODY_MAX_BYTES,
ConnectionEffectFetchError,
fetchForConnectionEffect,
type ConnectionEffectFetch,
type ConnectionEffectFetchDependency,
type ConnectionEffectFetchOptions,
type ConnectionEffectResponse,
} from './connection-effect-fetch.js';
import {
ConnectionEffectHttpError,
ConnectionEffectInvalidResponseError,
classifyConnectionEffectStatus,
type ConnectionEffectConnection,
type ConnectionEffectError,
type ConnectionTestEffectOutcome,
} from './connection-effect-outcome.js';
const CONNECTION_TEST_TIMEOUT_MS = 15_000;
export interface ConnectionTestOptions extends ConnectionEffectFetchOptions {
readonly timeoutMs?: number;
}
/**
* Prefer an explicit model, then a still-live configured model. Legacy
* connections without a discovered inventory keep the historical
* default/fallback order.
*/
function resolveConnectionTestModel(
connection: ConnectionEffectConnection,
model: string | undefined,
fallbackModels: readonly string[],
): string | undefined {
const explicitModel = model?.trim();
if (explicitModel) return explicitModel;
const hasAuthoritativeInventory =
connection.modelSource === 'fetched' && Array.isArray(connection.models);
const discoveredIds =
connection.models?.map(({ id }) => id.trim()).filter((id) => id.length > 0) ?? [];
const discovered =
hasAuthoritativeInventory || discoveredIds.length > 0 ? new Set(discoveredIds) : undefined;
const candidates = [
...connectionEnabledModelIds(connection),
...fallbackModels,
...discoveredIds,
];
for (const candidate of candidates) {
const id = candidate.trim();
if (!id || (discovered && !discovered.has(id))) continue;
return id;
}
return undefined;
}
export async function testConnection(
connection: LlmConnection,
apiKey: string,
model?: string,
options: ConnectionTestOptions = {},
): Promise<ConnectionTestResult> {
const t0 = Date.now();
const configuredTimeoutMs = options.timeoutMs;
const timeoutMs =
typeof configuredTimeoutMs === 'number' &&
Number.isFinite(configuredTimeoutMs) &&
configuredTimeoutMs > 0
? Math.floor(configuredTimeoutMs)
: CONNECTION_TEST_TIMEOUT_MS;
try {
return await testConnectionStrict(connection, apiKey, model, options.fetch, t0, timeoutMs);
} catch (error) {
return connectionTestFailure(error, t0, true);
}
}
export async function runConnectionTestEffect(
connection: ConnectionEffectConnection,
apiKey: string,
options: ConnectionEffectFetchDependency,
model?: string,
): Promise<ConnectionTestEffectOutcome> {
const t0 = Date.now();
try {
const result = await testConnectionStrict(connection, apiKey, model, options.fetch, t0);
if (result.ok) {
if (!result.modelTested || result.latencyMs === undefined) {
return { ok: false, error: { kind: 'invalid_response' } };
}
return {
ok: true,
modelId: result.modelTested,
latencyMs: result.latencyMs,
};
}
return {
ok: false,
error: classifyConnectionTestResult(result),
...connectionTestMeasurements(result),
};
} catch (error) {
return {
ok: false,
error: classifyConnectionTestError(error),
latencyMs: Date.now() - t0,
};
}
}
async function testConnectionStrict(
connection: ConnectionEffectConnection,
apiKey: string,
model: string | undefined,
fetchFn: ConnectionEffectFetch | undefined,
t0: number,
timeoutMs = CONNECTION_TEST_TIMEOUT_MS,
): Promise<ConnectionTestResult> {
const defaults = PROVIDER_DEFAULTS[connection.providerType];
// Unknown providerType → can't pick an auth path or fallback model. Return a
// clear failure rather than crashing. Mirrors `isFakeBackend`.
if (!defaults) {
return { ok: false, errorMessage: `Unknown provider type "${connection.providerType}"` };
}
const auth = defaults.authKind;
const secret = auth === 'none' ? '' : apiKey;
const testModel = resolveConnectionTestModel(connection, model, defaults.fallbackModels);
if (!testModel) {
return { ok: false, errorMessage: 'No model to test' };
}
if (connection.providerType === 'opencode-free' && !model?.trim()) {
const candidates = [
...new Set([...connectionEnabledModelIds(connection), ...defaults.fallbackModels]),
];
let lastFailure: ConnectionTestResult | undefined;
for (let index = 0; index < candidates.length; index += 1) {
const candidate = candidates[index]!;
const remainingMs = timeoutMs - (Date.now() - t0);
if (remainingMs <= 0) {
return connectionTestFailure(new ConnectionEffectFetchError('timeout'), t0);
}
const remainingCandidates = candidates.length - index;
const attemptTimeoutMs = Math.max(1, Math.floor(remainingMs / remainingCandidates));
try {
const result = await testConnectionModel(
connection,
secret,
candidate,
fetchFn,
t0,
attemptTimeoutMs,
);
if (result.ok) return result;
lastFailure = result;
} catch (error) {
lastFailure = connectionTestFailure(error, t0, true);
}
}
return lastFailure ?? connectionTestFailure(new ConnectionEffectFetchError('timeout'), t0);
}
return await testConnectionModel(connection, secret, testModel, fetchFn, t0, timeoutMs);
}
async function testConnectionModel(
connection: ConnectionEffectConnection,
secret: string,
testModel: string,
fetchFn: ConnectionEffectFetch | undefined,
t0: number,
timeoutMs = CONNECTION_TEST_TIMEOUT_MS,
): Promise<ConnectionTestResult> {
const { adapter, baseUrl, wire } = resolveModelRuntime(connection, testModel);
switch (adapter.kind) {
case 'anthropic':
case 'claude-subscription':
return await probeAnthropic(connection, baseUrl, secret, testModel, t0, fetchFn);
case 'openai':
return wire === 'openai-responses'
? await probeOpenAIResponses(baseUrl, secret, testModel, t0, fetchFn)
: await probeOpenAI(connection, baseUrl, secret, testModel, t0, fetchFn, timeoutMs);
case 'openai-codex':
return await probeOpenAI(connection, baseUrl, secret, testModel, t0, fetchFn, timeoutMs);
case 'openai-compatible':
return wire === 'openai-responses'
? await probeOpenAIResponses(baseUrl, secret, testModel, t0, fetchFn)
: await probeOpenAI(connection, baseUrl, secret, testModel, t0, fetchFn, timeoutMs);
case 'github-copilot':
return await probeGitHubCopilot(baseUrl, secret, testModel, t0, fetchFn);
case 'google':
return await probeGoogle(
baseUrl,
secret,
testModel,
t0,
adapter.normalizeBaseUrl !== false,
fetchFn,
);
case 'cohere':
return await probeCohere(baseUrl, secret, testModel, t0, fetchFn);
case 'unavailable':
throw new Error(`${connection.providerType} is experimental and not wired yet`);
}
}
async function probeGitHubCopilot(
baseUrl: string,
apiKey: string,
model: string,
t0: number,
fetchFn: ConnectionEffectFetch | undefined,
): Promise<ConnectionTestResult> {
const models = await fetchGitHubCopilotModels(baseUrl, apiKey, fetchFn);
if (!models.some(({ id }) => id === model)) {
return {
ok: false,
errorMessage: 'Selected model is not available for this GitHub Copilot account',
};
}
return { ok: true, latencyMs: Date.now() - t0, modelTested: model };
}
async function probeOpenAIResponses(
baseUrl: string,
apiKey: string,
model: string,
t0: number,
fetchFn: ConnectionEffectFetch | undefined,
): Promise<ConnectionTestResult> {
const r = await fetchForConnectionEffect(fetchFn, `${stripTrailing(baseUrl)}/responses`, {
method: 'POST',
headers: {
authorization: `Bearer ${apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify({
model,
store: false,
max_output_tokens: 16,
input: [{ role: 'user', content: 'Hi' }],
}),
timeoutMs: CONNECTION_TEST_TIMEOUT_MS,
});
if (!r.ok) return httpFailure(r, t0);
await r.cancel();
return { ok: true, latencyMs: Date.now() - t0, modelTested: model };
}
async function probeCohere(
baseUrl: string,
apiKey: string,
model: string,
t0: number,
fetchFn: ConnectionEffectFetch | undefined,
): Promise<ConnectionTestResult> {
const r = await fetchForConnectionEffect(fetchFn, `${stripTrailing(baseUrl)}/chat`, {
method: 'POST',
headers: {
authorization: `Bearer ${apiKey}`,
'content-type': 'application/json',
},
body: JSON.stringify({
model,
max_tokens: 16,
messages: [{ role: 'user', content: 'Hi' }],
}),
timeoutMs: CONNECTION_TEST_TIMEOUT_MS,
});
if (!r.ok) return httpFailure(r, t0);
await r.cancel();
return { ok: true, latencyMs: Date.now() - t0, modelTested: model };
}
async function probeAnthropic(
connection: Pick<ConnectionEffectConnection, 'providerType'>,
baseUrl: string,
secret: string,
model: string,
t0: number,
fetchFn: ConnectionEffectFetch | undefined,
): Promise<ConnectionTestResult> {
const headers: Record<string, string> =
connection.providerType === 'claude-subscription'
? {
...claudeSubscriptionHeaders(),
Authorization: `Bearer ${secret}`,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
}
: {
'x-api-key': secret,
'anthropic-version': '2023-06-01',
'content-type': 'application/json',
};
if (connection.providerType === 'claude-subscription') {
// Claude Subscription credentials are account-scoped OAuth tokens.
// The real send path has to use the Claude Code cloak shape; a
// separate `/api/oauth/profile` probe can fail with "Invalid
// request format" even when the stored login is usable. Treat the
// presence of a resolved main-process OAuth token as the connection
// test and let send-path failures surface during an actual turn.
return { ok: true, latencyMs: Date.now() - t0, modelTested: model };
}
const r = await fetchForConnectionEffect(fetchFn, anthropicV1Url(baseUrl, '/messages'), {
method: 'POST',
headers,
body: JSON.stringify({
model,
max_tokens: 16,
messages: [{ role: 'user', content: 'Hi' }],
}),
timeoutMs: CONNECTION_TEST_TIMEOUT_MS,
});
if (!r.ok) return httpFailure(r, t0);
await r.cancel();
return { ok: true, latencyMs: Date.now() - t0, modelTested: model };
}
async function probeOpenAI(
connection: Pick<ConnectionEffectConnection, 'providerType'>,
baseUrl: string,
apiKey: string,
model: string,
t0: number,
fetchFn: ConnectionEffectFetch | undefined,
timeoutMs = CONNECTION_TEST_TIMEOUT_MS,
): Promise<ConnectionTestResult> {
if (connection.providerType === 'openai-codex') {
// Codex Subscription credentials are ChatGPT account-scoped OAuth
// tokens. A live `/responses` probe is not a stable readiness test:
// the backend can hold or reject small synthetic requests even when
// the stored login is valid and the real send path has enough context.
// Mirror Claude OAuth and treat a resolved main-process OAuth token as
// the explicit connection test; actual turn failures still surface in
// chat with the provider error class.
return { ok: true, latencyMs: Date.now() - t0, modelTested: model };
}
const r = await fetchForConnectionEffect(fetchFn, `${stripTrailing(baseUrl)}/chat/completions`, {
method: 'POST',
headers: {
...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
'content-type': 'application/json',
},
body: JSON.stringify({
model,
max_tokens: 16,
messages: [{ role: 'user', content: 'Hi' }],
}),
timeoutMs,
});
if (!r.ok) return httpFailure(r, t0);
if (connection.providerType === 'opencode-free') {
const body = await r.readJson<unknown>();
if (!isOpenAIChatCompletion(body)) {
return {
ok: false,
errorMessage: 'OpenCode Free returned no valid chat completion',
errorClass: 'provider_unavailable',
latencyMs: Date.now() - t0,
modelTested: model,
};
}
return { ok: true, latencyMs: Date.now() - t0, modelTested: model };
}
await r.cancel();
return { ok: true, latencyMs: Date.now() - t0, modelTested: model };
}
function isOpenAIChatCompletion(value: unknown): boolean {
if (!value || typeof value !== 'object') return false;
const choices = (value as { choices?: unknown }).choices;
return (
Array.isArray(choices) &&
choices.some((choice) => {
if (!choice || typeof choice !== 'object') return false;
const message = (choice as { message?: unknown }).message;
if (!message || typeof message !== 'object') return false;
const completion = message as {
content?: unknown;
reasoning?: unknown;
reasoning_content?: unknown;
tool_calls?: unknown;
};
return (
typeof completion.content === 'string' ||
typeof completion.reasoning === 'string' ||
typeof completion.reasoning_content === 'string' ||
(Array.isArray(completion.tool_calls) && completion.tool_calls.length > 0)
);
})
);
}
async function probeGoogle(
baseUrl: string,
apiKey: string,
model: string,
t0: number,
normalizeBaseUrl: boolean,
fetchFn: ConnectionEffectFetch | undefined,
): Promise<ConnectionTestResult> {
const url = normalizeBaseUrl
? googleApiUrl(baseUrl, `/models/${encodeURIComponent(model)}:generateContent`, apiKey)
: `${stripTrailing(baseUrl)}/models/${encodeURIComponent(model)}:generateContent`;
const r = await fetchForConnectionEffect(fetchFn, url, {
method: 'POST',
headers: {
...(normalizeBaseUrl ? {} : { 'x-goog-api-key': apiKey }),
'content-type': 'application/json',
},
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: 'Hi' }] }],
generationConfig: { maxOutputTokens: 16 },
}),
timeoutMs: CONNECTION_TEST_TIMEOUT_MS,
});
if (!r.ok) return httpFailure(r, t0);
await r.cancel();
return { ok: true, latencyMs: Date.now() - t0, modelTested: model };
}
async function httpFailure(r: ConnectionEffectResponse, t0: number): Promise<ConnectionTestResult> {
const statusCode = r.status;
if (statusCode === 429) {
await r.cancel();
return {
ok: false,
errorMessage:
'OAuth 已登录,但当前账号或 provider 正在 rate limit。请稍后重试,或先切换到其它可用模型。',
statusCode,
errorClass: 'provider_unavailable',
latencyMs: Date.now() - t0,
};
}
const errorBody = await r.readText(CONNECTION_EFFECT_ERROR_BODY_MAX_BYTES);
return {
ok: false,
errorMessage: `${statusCode} ${errorBody.slice(0, 200)}`,
statusCode,
errorClass: classifyHttpStatus(statusCode),
latencyMs: Date.now() - t0,
};
}
function stripTrailing(u: string): string {
return u.replace(/\/+$/, '');
}
function classifyHttpStatus(statusCode: number): ConnectionTestResult['errorClass'] {
if (statusCode === 401 || statusCode === 403) return 'auth';
if (statusCode >= 500) return 'provider_unavailable';
return 'unknown';
}
function connectionTestFailure(
error: unknown,
t0: number,
preserveLegacyTimeoutClassification = false,
): ConnectionTestResult {
const message = error instanceof Error ? error.message : String(error);
return {
ok: false,
errorMessage: message,
errorClass:
(error instanceof ConnectionEffectFetchError && error.kind === 'timeout') ||
(preserveLegacyTimeoutClassification && message.toLowerCase().includes('timeout'))
? 'timeout'
: 'network',
latencyMs: Date.now() - t0,
};
}
function classifyConnectionTestError(error: unknown): ConnectionEffectError {
if (error instanceof ConnectionEffectFetchError) return { kind: error.kind };
if (error instanceof ConnectionEffectHttpError) {
return classifyConnectionEffectStatus(error.status);
}
if (error instanceof ConnectionEffectInvalidResponseError || error instanceof SyntaxError) {
return { kind: 'invalid_response' };
}
return { kind: 'unknown' };
}
function classifyConnectionTestResult(result: ConnectionTestResult): ConnectionEffectError {
if (result.statusCode !== undefined) {
const statusError = classifyConnectionEffectStatus(result.statusCode);
if (statusError.kind !== 'unknown') return statusError;
}
return {
kind: connectionTestErrorKind(result.errorClass),
...(result.statusCode === undefined ? {} : { statusCode: result.statusCode }),
};
}
function connectionTestMeasurements(
result: ConnectionTestResult,
): Pick<Extract<ConnectionTestEffectOutcome, { readonly ok: false }>, 'modelId' | 'latencyMs'> {
return {
...(result.modelTested === undefined ? {} : { modelId: result.modelTested }),
...(result.latencyMs === undefined ? {} : { latencyMs: result.latencyMs }),
};
}
function connectionTestErrorKind(
errorClass: ConnectionTestErrorClass | undefined,
): ConnectionEffectError['kind'] {
switch (errorClass) {
case 'auth':
case 'timeout':
case 'provider_unavailable':
case 'network':
return errorClass;
case 'unknown':
case undefined:
return 'unknown';
}
}