821 lines
30 KiB
JavaScript
821 lines
30 KiB
JavaScript
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { randomUUID } from 'node:crypto';
|
|
import { readAuthFile, writeAuthFile } from '../opencode/auth.js';
|
|
import { readConfig, readConfigLayers, isPlainObject } from '../opencode/shared.js';
|
|
import { getCatalogProvider } from './catalog.js';
|
|
import { getAuthEntryForProvider } from './resolve.js';
|
|
import { getRuntimeProvider } from './runtime-providers.js';
|
|
|
|
// Direct, non-streaming text generation against the provider APIs, replicating
|
|
// how OpenCode authenticates each of them (see the plugin auth loaders in the
|
|
// opencode repo). auth.json credentials never leave this process.
|
|
|
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
const COPILOT_MODELS_TIMEOUT_MS = 5_000;
|
|
// Generous default: thinking models that can't be switched off (DeepSeek,
|
|
// Qwen, …) spend part of this budget on reasoning before the actual answer.
|
|
const DEFAULT_MAX_OUTPUT_TOKENS = 4_000;
|
|
|
|
const USER_AGENT = 'opencode/1.0 openchamber';
|
|
|
|
const mergeHeadersCaseInsensitive = (base, overrides) => {
|
|
const merged = { ...base };
|
|
for (const [name, value] of Object.entries(overrides || {})) {
|
|
const existingName = Object.keys(merged).find((key) => key.toLowerCase() === name.toLowerCase());
|
|
if (existingName) {
|
|
delete merged[existingName];
|
|
}
|
|
merged[name] = value;
|
|
}
|
|
return merged;
|
|
};
|
|
|
|
const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
|
const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
|
|
|
|
const httpError = async (response, provider) => {
|
|
const body = await response.text().catch(() => '');
|
|
const snippet = body ? `: ${body.slice(0, 300)}` : '';
|
|
// Callers need the status to tell "this provider rejected the request shape"
|
|
// (retryable with a different shape) from "this provider is down".
|
|
return Object.assign(new Error(`${provider} request failed with ${response.status}${snippet}`), {
|
|
status: response.status,
|
|
provider,
|
|
});
|
|
};
|
|
|
|
// Callers own two independent reasons to stop: their own abort signal (user
|
|
// navigated away, request cancelled) and a per-call deadline. Long-running
|
|
// callers such as the diff walkthrough need a deadline well past the default.
|
|
const requestSignal = (timeoutMs, signal) => {
|
|
const deadline = AbortSignal.timeout(Number(timeoutMs) > 0 ? Number(timeoutMs) : REQUEST_TIMEOUT_MS);
|
|
return signal ? AbortSignal.any([deadline, signal]) : deadline;
|
|
};
|
|
|
|
const STRUCTURED_OUTPUT_NAME = 'response';
|
|
|
|
// Google's schema dialect is OpenAPI-flavored and rejects JSON Schema keywords
|
|
// it does not know, so unsupported keys are dropped rather than passed through.
|
|
const GOOGLE_UNSUPPORTED_SCHEMA_KEYS = new Set([
|
|
'$schema',
|
|
'additionalProperties',
|
|
'definitions',
|
|
'$defs',
|
|
'$ref',
|
|
'strict',
|
|
]);
|
|
|
|
const toGoogleSchema = (schema) => {
|
|
if (Array.isArray(schema)) return schema.map(toGoogleSchema);
|
|
if (!schema || typeof schema !== 'object') return schema;
|
|
const result = {};
|
|
for (const [key, value] of Object.entries(schema)) {
|
|
if (GOOGLE_UNSUPPORTED_SCHEMA_KEYS.has(key)) continue;
|
|
result[key] = toGoogleSchema(value);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OpenAI OAuth (ChatGPT plan / codex) token refresh — single-flight, with the
|
|
// refreshed token written back to auth.json exactly like OpenCode does.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let openaiRefreshPromise = null;
|
|
|
|
const decodeJwtClaims = (token) => {
|
|
try {
|
|
const payload = token.split('.')[1];
|
|
return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const extractChatgptAccountId = (accessToken) => {
|
|
const claims = decodeJwtClaims(accessToken);
|
|
const auth = claims?.['https://api.openai.com/auth'];
|
|
const value = auth?.chatgpt_account_id;
|
|
return typeof value === 'string' && value ? value : null;
|
|
};
|
|
|
|
const refreshOpenaiOauth = async (entry) => {
|
|
if (!openaiRefreshPromise) {
|
|
openaiRefreshPromise = (async () => {
|
|
const response = await fetch(CODEX_TOKEN_URL, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
grant_type: 'refresh_token',
|
|
refresh_token: entry.refresh,
|
|
client_id: CODEX_CLIENT_ID,
|
|
}),
|
|
signal: AbortSignal.timeout(30_000),
|
|
});
|
|
if (!response.ok) {
|
|
throw await httpError(response, 'OpenAI token refresh');
|
|
}
|
|
const payload = await response.json();
|
|
const access = typeof payload?.access_token === 'string' ? payload.access_token : '';
|
|
if (!access) {
|
|
throw new Error('OpenAI token refresh returned no access token');
|
|
}
|
|
const refreshed = {
|
|
...entry,
|
|
type: 'oauth',
|
|
access,
|
|
refresh: typeof payload?.refresh_token === 'string' && payload.refresh_token
|
|
? payload.refresh_token
|
|
: entry.refresh,
|
|
expires: Date.now() + (Number(payload?.expires_in) > 0 ? Number(payload.expires_in) : 3600) * 1000,
|
|
};
|
|
const auth = readAuthFile();
|
|
auth.openai = refreshed;
|
|
writeAuthFile(auth);
|
|
return refreshed;
|
|
})().finally(() => {
|
|
openaiRefreshPromise = null;
|
|
});
|
|
}
|
|
return openaiRefreshPromise;
|
|
};
|
|
|
|
const ensureFreshOpenaiOauth = async (entry) => {
|
|
if (entry.access && Number(entry.expires) > Date.now()) {
|
|
return entry;
|
|
}
|
|
if (!entry.refresh) {
|
|
throw new Error('OpenAI OAuth entry has no refresh token');
|
|
}
|
|
return refreshOpenaiOauth(entry);
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Wire formats
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel, extraBody, responseSchema, timeoutMs, signal }) => {
|
|
const trimmedBase = baseURL.replace(/\/+$/, '');
|
|
console.log('[small-model:diagnostic] request', {
|
|
provider: providerLabel,
|
|
model: modelID,
|
|
maxOutputTokens,
|
|
thinkingDisabled: extraBody?.thinking?.type === 'disabled',
|
|
promptChars: prompt.length,
|
|
systemChars: system?.length ?? 0,
|
|
inputChars: prompt.length + (system?.length ?? 0),
|
|
});
|
|
const response = await fetch(`${trimmedBase}/chat/completions`, {
|
|
method: 'POST',
|
|
headers: mergeHeadersCaseInsensitive({
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
}, headers),
|
|
body: JSON.stringify({
|
|
model: modelID,
|
|
messages: [
|
|
...(system ? [{ role: 'system', content: system }] : []),
|
|
{ role: 'user', content: prompt },
|
|
],
|
|
max_tokens: maxOutputTokens,
|
|
stream: false,
|
|
...(responseSchema
|
|
? {
|
|
response_format: {
|
|
type: 'json_schema',
|
|
json_schema: { name: STRUCTURED_OUTPUT_NAME, strict: true, schema: responseSchema },
|
|
},
|
|
}
|
|
: {}),
|
|
...(extraBody || {}),
|
|
}),
|
|
signal: requestSignal(timeoutMs, signal),
|
|
});
|
|
console.log('[small-model:diagnostic] response', {
|
|
provider: providerLabel,
|
|
model: modelID,
|
|
httpStatus: response.status,
|
|
ok: response.ok,
|
|
});
|
|
if (!response.ok) {
|
|
throw await httpError(response, providerLabel);
|
|
}
|
|
const payload = await response.json();
|
|
const message = payload?.choices?.[0]?.message;
|
|
console.log('[small-model:diagnostic] completion', {
|
|
provider: providerLabel,
|
|
model: modelID,
|
|
finishReason: payload?.choices?.[0]?.finish_reason ?? null,
|
|
contentType: Array.isArray(message?.content) ? 'parts' : typeof message?.content,
|
|
contentChars: typeof message?.content === 'string'
|
|
? message.content.length
|
|
: Array.isArray(message?.content)
|
|
? message.content.reduce((total, part) => total + (typeof part?.text === 'string' ? part.text.length : 0), 0)
|
|
: 0,
|
|
reasoningChars: typeof message?.reasoning_content === 'string' ? message.reasoning_content.length : 0,
|
|
});
|
|
|
|
// Providers disagree on the content shape: plain string, an array of
|
|
// typed parts, or (thinking models) an empty content with the budget spent
|
|
// on reasoning_content.
|
|
let text = '';
|
|
if (typeof message?.content === 'string') {
|
|
text = message.content;
|
|
} else if (Array.isArray(message?.content)) {
|
|
text = message.content
|
|
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
|
|
.join('');
|
|
}
|
|
const finishReason = payload?.choices?.[0]?.finish_reason;
|
|
if (!text.trim() && (finishReason === 'length' || (typeof message?.reasoning_content === 'string' && message.reasoning_content.trim()))) {
|
|
// The model produced only reasoning, or was cut off before answering. This
|
|
// is a budget problem, not a transport problem, and callers can act on it.
|
|
throw Object.assign(
|
|
new Error(
|
|
`${providerLabel} spent the output budget on reasoning and returned no answer`
|
|
+ (finishReason ? ` (finish_reason: ${finishReason})` : ''),
|
|
),
|
|
{ code: 'output-exhausted', provider: providerLabel },
|
|
);
|
|
}
|
|
if (!text.trim()) {
|
|
throw new Error(`${providerLabel} returned no message content`);
|
|
}
|
|
return text;
|
|
};
|
|
|
|
const callOpenaiResponses = async ({ baseURL, headers, modelID, prompt, system, maxOutputTokens, providerLabel, responseSchema, timeoutMs, signal }) => {
|
|
const trimmedBase = baseURL.replace(/\/+$/, '');
|
|
const response = await fetch(`${trimmedBase}/responses`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
...headers,
|
|
},
|
|
body: JSON.stringify({
|
|
model: modelID,
|
|
...(system ? { instructions: system } : {}),
|
|
input: [{
|
|
role: 'user',
|
|
content: [{ type: 'input_text', text: prompt }],
|
|
}],
|
|
max_output_tokens: maxOutputTokens,
|
|
...(responseSchema
|
|
? {
|
|
text: {
|
|
format: {
|
|
type: 'json_schema',
|
|
name: STRUCTURED_OUTPUT_NAME,
|
|
strict: true,
|
|
schema: responseSchema,
|
|
},
|
|
},
|
|
}
|
|
: {}),
|
|
stream: false,
|
|
store: false,
|
|
}),
|
|
signal: requestSignal(timeoutMs, signal),
|
|
});
|
|
if (!response.ok) {
|
|
throw await httpError(response, providerLabel);
|
|
}
|
|
const payload = await response.json();
|
|
const text = typeof payload?.output_text === 'string'
|
|
? payload.output_text
|
|
: Array.isArray(payload?.output)
|
|
? payload.output
|
|
.flatMap((item) => (Array.isArray(item?.content) ? item.content : []))
|
|
.map((part) => (part?.type === 'output_text' && typeof part.text === 'string' ? part.text : ''))
|
|
.join('')
|
|
: '';
|
|
if (!text.trim()) {
|
|
throw new Error(`${providerLabel} returned no text output`);
|
|
}
|
|
return text;
|
|
};
|
|
|
|
const callMessages = async ({ url, headers, modelID, prompt, system, maxOutputTokens, providerLabel, responseSchema, timeoutMs, signal }) => {
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
...headers,
|
|
},
|
|
body: JSON.stringify({
|
|
model: modelID,
|
|
max_tokens: maxOutputTokens,
|
|
...(system ? { system } : {}),
|
|
messages: [{ role: 'user', content: prompt }],
|
|
// The messages API has no response_format; a forced single-tool call is
|
|
// the supported way to get schema-shaped output.
|
|
...(responseSchema
|
|
? {
|
|
tools: [{
|
|
name: STRUCTURED_OUTPUT_NAME,
|
|
description: 'Return the answer in the required structure.',
|
|
input_schema: responseSchema,
|
|
}],
|
|
tool_choice: { type: 'tool', name: STRUCTURED_OUTPUT_NAME },
|
|
}
|
|
: {}),
|
|
}),
|
|
signal: requestSignal(timeoutMs, signal),
|
|
});
|
|
if (!response.ok) {
|
|
throw await httpError(response, providerLabel);
|
|
}
|
|
const payload = await response.json();
|
|
|
|
if (responseSchema) {
|
|
const toolUse = (payload?.content || []).find(
|
|
(part) => part?.type === 'tool_use' && part.name === STRUCTURED_OUTPUT_NAME,
|
|
);
|
|
if (!toolUse || typeof toolUse.input !== 'object' || toolUse.input === null) {
|
|
throw new Error(`${providerLabel} returned no structured output`);
|
|
}
|
|
return JSON.stringify(toolUse.input);
|
|
}
|
|
|
|
const text = (payload?.content || [])
|
|
.filter((part) => part?.type === 'text' && typeof part.text === 'string')
|
|
.map((part) => part.text)
|
|
.join('');
|
|
if (!text) {
|
|
throw new Error(`${providerLabel} returned no text content`);
|
|
}
|
|
return text;
|
|
};
|
|
|
|
const callAnthropic = async ({ apiKey, baseURL, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) => callMessages({
|
|
// Matches @ai-sdk/anthropic: baseURL is the full API prefix (commonly
|
|
// already ending in /v1), so it gets /messages appended as-is rather than
|
|
// having /v1/messages appended, which would double up a configured /v1.
|
|
url: `${(baseURL || 'https://api.anthropic.com/v1').replace(/\/+$/, '')}/messages`,
|
|
headers: {
|
|
'x-api-key': apiKey,
|
|
'anthropic-version': '2023-06-01',
|
|
},
|
|
modelID,
|
|
prompt,
|
|
system,
|
|
maxOutputTokens,
|
|
providerLabel: 'Anthropic',
|
|
responseSchema,
|
|
timeoutMs,
|
|
signal,
|
|
});
|
|
|
|
const getCopilotEndpoint = async ({ baseURL, headers, modelID }) => {
|
|
const trimmedBase = baseURL.replace(/\/+$/, '');
|
|
const response = await fetch(`${trimmedBase}/models`, {
|
|
headers: {
|
|
Accept: 'application/json',
|
|
...headers,
|
|
},
|
|
signal: AbortSignal.timeout(COPILOT_MODELS_TIMEOUT_MS),
|
|
});
|
|
if (!response.ok) {
|
|
throw await httpError(response, 'GitHub Copilot models');
|
|
}
|
|
|
|
let payload;
|
|
try {
|
|
payload = await response.json();
|
|
} catch {
|
|
throw new Error('GitHub Copilot models returned invalid JSON');
|
|
}
|
|
if (!Array.isArray(payload?.data)) {
|
|
throw new Error('GitHub Copilot models returned an invalid model list');
|
|
}
|
|
|
|
const model = payload.data.find((item) => item && typeof item === 'object' && item.id === modelID);
|
|
if (!model) {
|
|
throw new Error(`GitHub Copilot model "${modelID}" was not returned by /models`);
|
|
}
|
|
if (model.supported_endpoints === undefined) {
|
|
return 'chat';
|
|
}
|
|
if (!Array.isArray(model.supported_endpoints)) {
|
|
throw new Error(`GitHub Copilot model "${modelID}" returned invalid endpoint metadata`);
|
|
}
|
|
if (model.supported_endpoints.includes('/v1/messages')) {
|
|
return 'messages';
|
|
}
|
|
if (model.supported_endpoints.includes('/responses')) {
|
|
return 'responses';
|
|
}
|
|
if (model.supported_endpoints.includes('/chat/completions')) {
|
|
return 'chat';
|
|
}
|
|
throw new Error(`GitHub Copilot model "${modelID}" has no supported text endpoint`);
|
|
};
|
|
|
|
const callGoogle = async ({ apiKey, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) => {
|
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelID)}:generateContent`;
|
|
const lowerModelID = modelID.toLowerCase();
|
|
const thinkingConfig = lowerModelID.startsWith('gemini-3')
|
|
? { thinkingLevel: lowerModelID.includes('flash') ? 'minimal' : 'low' }
|
|
: lowerModelID.startsWith('gemini-2') ? { thinkingBudget: 0 } : null;
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json',
|
|
'x-goog-api-key': apiKey,
|
|
},
|
|
body: JSON.stringify({
|
|
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
|
...(system && { systemInstruction: { parts: [{ text: system }] } }),
|
|
generationConfig: {
|
|
maxOutputTokens,
|
|
...(thinkingConfig && { thinkingConfig }),
|
|
...(responseSchema && { responseMimeType: 'application/json', responseSchema: toGoogleSchema(responseSchema) }),
|
|
},
|
|
}),
|
|
signal: requestSignal(timeoutMs, signal),
|
|
});
|
|
if (!response.ok) {
|
|
throw await httpError(response, 'Google');
|
|
}
|
|
const payload = await response.json();
|
|
const text = (payload?.candidates?.[0]?.content?.parts || [])
|
|
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
|
|
.join('');
|
|
if (!text) {
|
|
throw new Error('Google returned no text content');
|
|
}
|
|
return text;
|
|
};
|
|
|
|
// ChatGPT-plan traffic goes to the codex backend, which only speaks the
|
|
// streaming Responses API — collect the output_text deltas from the SSE body.
|
|
const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, system, timeoutMs, signal }) => {
|
|
const response = await fetch(CODEX_RESPONSES_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'text/event-stream',
|
|
Authorization: `Bearer ${accessToken}`,
|
|
...(accountId ? { 'ChatGPT-Account-Id': accountId } : {}),
|
|
originator: 'opencode',
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
body: JSON.stringify({
|
|
model: modelID,
|
|
...(system ? { instructions: system } : {}),
|
|
input: [
|
|
{
|
|
type: 'message',
|
|
role: 'user',
|
|
content: [{ type: 'input_text', text: prompt }],
|
|
},
|
|
],
|
|
// The codex backend rejects max_output_tokens (OpenCode forces it to
|
|
// undefined for this provider too).
|
|
stream: true,
|
|
store: false,
|
|
}),
|
|
signal: requestSignal(timeoutMs, signal),
|
|
});
|
|
if (!response.ok) {
|
|
throw await httpError(response, 'OpenAI (ChatGPT plan)');
|
|
}
|
|
|
|
const raw = await response.text();
|
|
let text = '';
|
|
let completedText = '';
|
|
for (const line of raw.split('\n')) {
|
|
if (!line.startsWith('data:')) continue;
|
|
const data = line.slice(5).trim();
|
|
if (!data || data === '[DONE]') continue;
|
|
let event;
|
|
try {
|
|
event = JSON.parse(data);
|
|
} catch {
|
|
continue;
|
|
}
|
|
if (event?.type === 'response.output_text.delta' && typeof event.delta === 'string') {
|
|
text += event.delta;
|
|
}
|
|
if (event?.type === 'response.output_text.done' && typeof event.text === 'string') {
|
|
completedText = event.text;
|
|
}
|
|
if (event?.type === 'response.failed' || event?.type === 'error') {
|
|
const message = event?.response?.error?.message || event?.message || 'response failed';
|
|
throw new Error(`OpenAI (ChatGPT plan) stream error: ${message}`);
|
|
}
|
|
}
|
|
const result = completedText || text;
|
|
if (!result) {
|
|
throw new Error('OpenAI (ChatGPT plan) returned no text output');
|
|
}
|
|
return result;
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Custom provider configuration support
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const resolveConfigValue = (value, workingDirectory, providerID, headerName = null) => {
|
|
const envMatch = value.match(/^\{env:([^}]+)\}$/i);
|
|
if (envMatch) {
|
|
return process.env[envMatch[1].trim()]?.trim() || null;
|
|
}
|
|
|
|
const fileMatch = value.match(/^\{file:(.+)\}$/i);
|
|
if (!fileMatch) return value;
|
|
|
|
const configuredPath = fileMatch[1].trim();
|
|
let resolvedPath;
|
|
if (configuredPath === '~' || configuredPath.startsWith('~/') || configuredPath.startsWith('~\\')) {
|
|
resolvedPath = path.join(os.homedir(), configuredPath.slice(2));
|
|
} else if (path.isAbsolute(configuredPath)) {
|
|
resolvedPath = configuredPath;
|
|
} else {
|
|
const layers = readConfigLayers(workingDirectory);
|
|
const source = [
|
|
{ config: layers.customConfig, filePath: layers.paths.customPath },
|
|
{ config: layers.projectConfig, filePath: layers.paths.projectPath },
|
|
{ config: layers.userConfig, filePath: layers.paths.userPath },
|
|
].find(({ config }) => {
|
|
const options = config?.provider?.[providerID]?.options;
|
|
return headerName
|
|
? options?.headers?.[headerName] === value
|
|
: options?.apiKey === value;
|
|
});
|
|
resolvedPath = path.resolve(source?.filePath ? path.dirname(source.filePath) : workingDirectory || process.cwd(), configuredPath);
|
|
}
|
|
|
|
try {
|
|
const key = fs.readFileSync(resolvedPath, 'utf8').trim();
|
|
if (!key) throw new Error('empty file');
|
|
return key;
|
|
} catch {
|
|
throw new Error(`Failed to resolve configured ${headerName ? `header "${headerName}"` : 'apiKey'} file for provider "${providerID}"`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* `options.headers` from the provider config, with the same `{env:…}`/`{file:…}`
|
|
* substitutions the API key gets.
|
|
*
|
|
* OpenCode sends these on every request, so dropping them here would have the
|
|
* small model authenticating differently from the request path against the same
|
|
* URL. Gateways fronted by an API-management layer reject a bearer-only request
|
|
* outright, because the header is the credential rather than a supplement to it.
|
|
*/
|
|
const readConfiguredHeaders = (providerCfg, workingDirectory, providerID) => {
|
|
const configured = providerCfg?.options?.headers;
|
|
if (!isPlainObject(configured)) return null;
|
|
const headers = {};
|
|
for (const [name, value] of Object.entries(configured)) {
|
|
// Config headers are strings; a malformed entry is skipped rather than
|
|
// stringified into a header the gateway would reject.
|
|
if (String(value) !== value) continue;
|
|
const resolved = resolveConfigValue(value.trim(), workingDirectory, providerID, name);
|
|
if (resolved) headers[name] = resolved;
|
|
}
|
|
return Object.keys(headers).length ? headers : null;
|
|
};
|
|
|
|
const readProviderConfig = (workingDirectory, providerID) => {
|
|
try {
|
|
const config = readConfig(workingDirectory);
|
|
const providerCfg = config?.provider?.[providerID];
|
|
if (!providerCfg || typeof providerCfg !== 'object') return null;
|
|
const baseURL = typeof providerCfg?.options?.baseURL === 'string' ? providerCfg.options.baseURL.trim() : null;
|
|
const rawApiKey = typeof providerCfg?.options?.apiKey === 'string' ? providerCfg.options.apiKey.trim() : null;
|
|
const apiKey = rawApiKey ? resolveConfigValue(rawApiKey, workingDirectory, providerID) : null;
|
|
return {
|
|
baseURL,
|
|
headers: readConfiguredHeaders(providerCfg, workingDirectory, providerID),
|
|
// Shape the config-supplied key as a regular api-key auth entry so it
|
|
// can win the precedence check below and flow through the dispatch's
|
|
// `entry.type === 'api' ? entry.key : ...` branch unchanged.
|
|
auth: apiKey ? { type: 'api', key: apiKey } : null,
|
|
};
|
|
} catch {
|
|
// Provider config is non-essential — continue with catalog-only resolution.
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const getRuntimeModel = (runtimeProvider, modelID) => runtimeProvider?.models?.get(modelID) ?? null;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Dispatch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Providers reached through a dedicated wire format below: a token exchange,
|
|
* an OAuth refresh, or a non-bearer header. OpenCode's runtime
|
|
* `options.apiKey` is not the value those branches need — the ChatGPT-plan
|
|
* `openai` login is the clearest case, where the runtime key is an OAuth
|
|
* access token that api.openai.com answers with 401 — so the runtime
|
|
* credential never stands in for them, and the runtime listing skips them
|
|
* because the auth.json scan already covers them.
|
|
*/
|
|
export const DEDICATED_WIRE_FORMAT_PROVIDERS = new Set(['github-copilot', 'copilot', 'openai', 'anthropic', 'google']);
|
|
|
|
/**
|
|
* The runtime credential shaped as an auth entry, or `null` when the provider
|
|
* owns its credential handling or OpenCode reports nothing usable.
|
|
*/
|
|
const runtimeCredential = (providerID, runtime) => (
|
|
!DEDICATED_WIRE_FORMAT_PROVIDERS.has(providerID) && runtime?.apiKey
|
|
? { type: 'api', key: runtime.apiKey }
|
|
: null
|
|
);
|
|
|
|
/**
|
|
* Same credential resolution the request path uses: config
|
|
* `provider.<id>.options.apiKey` wins, then the runtime credential OpenCode
|
|
* resolved for a plugin provider, then the auth.json entry.
|
|
* Callers that need to refuse before spending a request (walkthrough readiness)
|
|
* must use this rather than inventing a second rule.
|
|
*/
|
|
export async function resolveProviderLogin({ auth, workingDirectory, providerID }) {
|
|
const providerConfig = readProviderConfig(workingDirectory, providerID);
|
|
return providerConfig?.auth
|
|
|| runtimeCredential(providerID, await getRuntimeProvider(providerID))
|
|
|| getAuthEntryForProvider(auth, providerID)
|
|
|| null;
|
|
}
|
|
|
|
export async function callSmallModel({ auth, catalog, workingDirectory, sessionID, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) {
|
|
const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
|
|
const providerConfig = readProviderConfig(workingDirectory, providerID);
|
|
const runtimeProvider = await getRuntimeProvider(providerID);
|
|
const runtimeModel = getRuntimeModel(runtimeProvider, modelID);
|
|
// Match OpenCode's resolveSDK precedence: config `provider.<id>.options`
|
|
// wins, then what OpenCode itself resolved at runtime (the only place a
|
|
// plugin's credential exists), and the auth.json entry last.
|
|
const entry = providerConfig?.auth
|
|
|| runtimeCredential(providerID, runtimeProvider)
|
|
|| getAuthEntryForProvider(auth, providerID);
|
|
if (!entry) {
|
|
// Structured so the walkthrough (and any other caller) can show a blocker
|
|
// instead of a raw 500 banner with this developer-oriented sentence.
|
|
throw Object.assign(new Error(`No OpenCode login found for provider "${providerID}"`), {
|
|
statusCode: 401,
|
|
code: 'no-provider-login',
|
|
providerID,
|
|
});
|
|
}
|
|
|
|
if (providerID === 'github-copilot') {
|
|
// OpenCode uses the stored device-OAuth token directly as the bearer —
|
|
// access === refresh, no exchange, no expiry.
|
|
const token = entry.refresh || entry.access || entry.key;
|
|
if (!token) {
|
|
throw new Error('GitHub Copilot login has no token');
|
|
}
|
|
const baseURL = entry.enterpriseUrl
|
|
? `https://copilot-api.${String(entry.enterpriseUrl).replace(/^https?:\/\//, '').replace(/\/+$/, '')}`
|
|
: 'https://api.githubcopilot.com';
|
|
const authHeaders = {
|
|
Authorization: `Bearer ${token}`,
|
|
'User-Agent': USER_AGENT,
|
|
'X-GitHub-Api-Version': '2026-06-01',
|
|
};
|
|
const headers = {
|
|
...authHeaders,
|
|
'Openai-Intent': 'conversation-edits',
|
|
'x-initiator': 'agent',
|
|
};
|
|
const endpoint = await getCopilotEndpoint({
|
|
baseURL,
|
|
headers: authHeaders,
|
|
modelID,
|
|
});
|
|
const request = {
|
|
baseURL,
|
|
headers,
|
|
modelID,
|
|
prompt,
|
|
system,
|
|
maxOutputTokens: tokens,
|
|
providerLabel: 'GitHub Copilot',
|
|
responseSchema,
|
|
timeoutMs,
|
|
signal,
|
|
};
|
|
if (endpoint === 'messages') {
|
|
return callMessages({
|
|
...request,
|
|
url: `${baseURL.replace(/\/+$/, '')}/v1/messages`,
|
|
headers: {
|
|
...headers,
|
|
'anthropic-version': '2023-06-01',
|
|
},
|
|
});
|
|
}
|
|
if (endpoint === 'responses') {
|
|
return callOpenaiResponses(request);
|
|
}
|
|
return callOpenaiCompatible(request);
|
|
}
|
|
|
|
if (providerID === 'openai' && entry.type === 'oauth') {
|
|
// The codex backend speaks only the streaming Responses API and rejects
|
|
// the structured-output fields, so a schema request fails loudly here
|
|
// instead of silently returning free-form prose.
|
|
if (responseSchema) {
|
|
throw Object.assign(
|
|
new Error('The ChatGPT-plan OpenAI login does not support structured output — choose another small model'),
|
|
{ code: 'structured-output-unsupported' },
|
|
);
|
|
}
|
|
const fresh = await ensureFreshOpenaiOauth(entry);
|
|
return callCodexResponses({
|
|
accessToken: fresh.access,
|
|
accountId: fresh.accountId || extractChatgptAccountId(fresh.access),
|
|
modelID,
|
|
prompt,
|
|
system,
|
|
timeoutMs,
|
|
signal,
|
|
});
|
|
}
|
|
|
|
const apiKey = entry.type === 'api' ? entry.key
|
|
: entry.type === 'wellknown' ? entry.token
|
|
: entry.access;
|
|
if (!apiKey) {
|
|
throw new Error(`OpenCode login for "${providerID}" has no usable credential`);
|
|
}
|
|
|
|
if (providerID === 'anthropic') {
|
|
return callAnthropic({ apiKey, baseURL: providerConfig?.baseURL, modelID, prompt, system, maxOutputTokens: tokens, responseSchema, timeoutMs, signal });
|
|
}
|
|
if (providerID === 'google') {
|
|
return callGoogle({ apiKey, modelID, prompt, system, maxOutputTokens: tokens, responseSchema, timeoutMs, signal });
|
|
}
|
|
|
|
// Everything else: OpenAI-compatible chat completions against the catalog's
|
|
// base URL for that provider (openai itself included). When a custom provider
|
|
// is not in the catalog (e.g. a user-configured OpenAI-compatible proxy),
|
|
// fall back to its baseURL from the OpenCode provider config, then to the
|
|
// selected model's endpoint OpenCode resolved at runtime, then to the
|
|
// provider-level runtime endpoint. For a plugin provider, the runtime listing
|
|
// is the only place those endpoints exist, and several are local proxies the
|
|
// plugin itself runs. The openai provider also respects
|
|
// provider.openai.options.baseURL — OpenCode itself uses the same config for
|
|
// all providers including openai.
|
|
const provider = getCatalogProvider(catalog, providerID);
|
|
const providerConfigUrl = providerConfig?.baseURL;
|
|
const defaultOpenaiUrl = 'https://api.openai.com/v1';
|
|
const baseURL = typeof providerConfigUrl === 'string' && providerConfigUrl
|
|
? providerConfigUrl
|
|
: providerID === 'openai'
|
|
? defaultOpenaiUrl
|
|
: runtimeModel?.api?.url
|
|
?? runtimeProvider?.baseURL
|
|
?? (typeof provider?.api === 'string' && provider.api
|
|
? provider.api
|
|
: null);
|
|
if (!baseURL) {
|
|
throw new Error(`Provider "${providerID}" has no known API base URL`);
|
|
}
|
|
|
|
// Thinking models burn the output budget on reasoning and leave content
|
|
// empty — disable thinking where a wire-format switch exists (mirrors
|
|
// OpenCode's smallOptions/variants special cases). There is NO universal
|
|
// parameter: unknown body fields 400 on some providers, so this stays an
|
|
// explicit allowlist. Models without a switch (DeepSeek, Qwen, Kimi, …)
|
|
// just get the generous output budget.
|
|
const lowerModel = modelID.toLowerCase();
|
|
const supportsThinkingToggle = providerID.includes('zai')
|
|
|| providerID.includes('zhipu')
|
|
|| lowerModel.includes('glm')
|
|
|| lowerModel.includes('minimax-m3');
|
|
const extraBody = supportsThinkingToggle ? { thinking: { type: 'disabled' } } : undefined;
|
|
|
|
return callOpenaiCompatible({
|
|
baseURL,
|
|
// Configured headers last: a gateway that authenticates on its own header
|
|
// must be able to override the bearer default rather than sit beside it.
|
|
headers: mergeHeadersCaseInsensitive(
|
|
mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers),
|
|
providerID.startsWith('opencode')
|
|
? { 'x-opencode-session': typeof sessionID === 'string' && sessionID.trim() ? sessionID.trim() : randomUUID() }
|
|
: null,
|
|
),
|
|
modelID,
|
|
prompt,
|
|
system,
|
|
maxOutputTokens: tokens,
|
|
providerLabel: provider?.name || providerID,
|
|
extraBody,
|
|
responseSchema,
|
|
timeoutMs,
|
|
signal,
|
|
});
|
|
}
|