blob: 5ddc7eddd375e03baab4011b8a4060962b71edc5 [file]
import {
PROVIDER_DEFAULTS,
effectiveBaseUrl,
providerAuthSupportsApiKey,
type LlmConnection,
type ModelInfo,
} from '@maka/core/llm-connections';
import { generalizedErrorMessage } from '@maka/core/redaction';
import {
CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION,
CONNECTION_MODEL_ID_MAX_LENGTH,
} from '@maka/core/runtime-policy';
import { proxiedFetch } from './bots/proxied-fetch.js';
import { anthropicV1Url, googleApiUrl } from './provider-urls.js';
import { claudeSubscriptionHeaders, openAiCodexHeaders } from './subscription-auth.js';
import {
GITHUB_COPILOT_API_VERSION,
GITHUB_COPILOT_COMPAT_HEADERS,
} from './subscription-credentials.js';
const MODEL_FETCH_TIMEOUT_MS = 10_000;
const CLOUDFLARE_MODEL_PAGE_SIZE = 50;
const CLOUDFLARE_MODEL_MAX_REQUEST_PAGES =
Math.ceil(CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION / CLOUDFLARE_MODEL_PAGE_SIZE) + 1;
type RawProviderModel = {
id?: string;
name?: string;
display_name?: string;
type?: string;
tags?: string[];
input_modalities?: string[];
output_modalities?: string[];
capabilities?: { reasoning?: boolean };
supports_image_in?: boolean;
supports_reasoning?: boolean;
context_length?: number;
context_window?: number;
max_tokens?: number;
providers?: Array<{
status?: string;
supports_tools?: boolean;
}>;
};
type RawFireworksModel = {
name?: string;
displayName?: string;
contextLength?: number;
supportsImageInput?: boolean;
supportsTools?: boolean;
};
type RawCohereModel = {
name?: string;
is_deprecated?: boolean;
endpoints?: string[];
context_length?: number;
};
type RawCloudflareModel = {
name?: unknown;
};
type RawGitHubCopilotModel = {
id?: string;
name?: string;
model_picker_enabled?: boolean;
supported_endpoints?: string[];
policy?: { state?: string };
capabilities?: {
limits?: {
max_context_window_tokens?: number;
max_prompt_tokens?: number;
max_output_tokens?: number;
vision?: { supported_media_types?: string[] };
};
supports?: {
adaptive_thinking?: boolean;
max_thinking_budget?: number;
min_thinking_budget?: number;
reasoning_effort?: string[];
tool_calls?: boolean;
vision?: boolean;
};
};
};
type FireworksModelDiscovery = Extract<
(typeof PROVIDER_DEFAULTS)[keyof typeof PROVIDER_DEFAULTS]['modelDiscovery'],
{ kind: 'fireworks' }
>;
export async function fetchProviderModels(
connection: LlmConnection,
apiKey: string,
): Promise<ModelInfo[]> {
try {
return normalizeDiscoveredModels(await fetchProviderModelsStrict(connection, apiKey));
} catch (error) {
// Preserve status-bearing discovery errors so the sync layer can classify
// auth/protocol/network failures; only wrap unknown errors for display.
if (
error instanceof OpenAiCodexDiscoveryError ||
error instanceof ProviderModelDiscoveryHttpError
) {
throw error;
}
throw new Error(generalizedErrorMessage(error, 'Failed to fetch provider models'));
}
}
async function fetchProviderModelsStrict(
connection: LlmConnection,
apiKey: string,
): Promise<ModelInfo[]> {
const baseUrl = effectiveBaseUrl(connection);
const definition = PROVIDER_DEFAULTS[connection.providerType];
// Unknown providerType → no discovery path. Throw a clear error (caught and
// generalized by the caller) rather than crashing on `.modelDiscovery`.
// Mirrors `isFakeBackend` in @maka/core/connection-readiness.ts.
if (!definition) {
throw new Error(`Unknown provider type "${connection.providerType}"`);
}
const discovery = definition.modelDiscovery;
if (discovery.kind === 'fallback') {
return definition.fallbackModels.map((id) => ({ id }));
}
if (discovery.kind === 'ollama') {
const r = await proxiedFetch(`${ollamaRoot(baseUrl)}/api/tags`, {
timeoutMs: MODEL_FETCH_TIMEOUT_MS,
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = (await r.json()) as { models?: Array<{ name?: string }> };
return (data.models ?? []).flatMap((model) => (model.name ? [{ id: model.name }] : []));
}
if (discovery.kind === 'fireworks') {
return fetchFireworksModels(baseUrl, apiKey, discovery);
}
if (discovery.kind === 'cohere') {
return fetchCohereModels(baseUrl, apiKey);
}
if (discovery.kind === 'cloudflare') {
return fetchCloudflareModels(baseUrl, apiKey);
}
if (discovery.auth === 'github-copilot') {
return fetchGitHubCopilotModels(baseUrl, apiKey);
}
if (discovery.auth === 'openai-codex') {
return fetchOpenAiCodexModels(baseUrl, apiKey);
}
switch (definition.protocol) {
case 'anthropic': {
const r = await proxiedFetch(anthropicV1Url(baseUrl, '/models'), {
headers: anthropicModelHeaders(
discovery.auth === 'claude-subscription' ? discovery.auth : undefined,
apiKey,
),
timeoutMs: MODEL_FETCH_TIMEOUT_MS,
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = (await r.json()) as { data?: RawProviderModel[] };
const models = (data.data ?? [])
.map(toModelInfo)
.filter((model): model is ModelInfo => model !== null);
return filterDiscoveredModels(models, discovery.filter, definition.fallbackModels);
}
case 'openai': {
const r = await proxiedFetch(modelListUrl(baseUrl, discovery.path, discovery.query), {
headers: {
'content-type': 'application/json',
...(apiKey &&
(discovery.auth === 'oauth-bearer' ||
(discovery.auth !== 'none' && providerAuthSupportsApiKey(connection.providerType)))
? { authorization: `Bearer ${apiKey}` }
: {}),
},
timeoutMs: MODEL_FETCH_TIMEOUT_MS,
});
if (!r.ok) {
if (connection.providerType === 'xai-oauth') {
throw new ProviderModelDiscoveryHttpError(r.status);
}
throw new Error(`HTTP ${r.status}`);
}
const data = (await r.json()) as { data?: RawProviderModel[] } | RawProviderModel[];
const rawModels =
discovery.responseShape === 'array-or-data'
? Array.isArray(data)
? data
: (data.data ?? [])
: Array.isArray(data)
? []
: (data.data ?? []);
const models = rawModels
.filter((model) => discovery.filter !== 'language-models' || model.type === 'language')
.map(toModelInfo)
.filter((model): model is ModelInfo => model !== null);
return filterDiscoveredModels(models, discovery.filter, definition.fallbackModels);
}
case 'google': {
const r = await proxiedFetch(googleApiUrl(baseUrl, '/models', apiKey), {
timeoutMs: MODEL_FETCH_TIMEOUT_MS,
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = (await r.json()) as { models?: Array<{ name?: string }> };
return (data.models ?? []).flatMap((model) => {
const id = model.name?.split('/').pop();
return id ? [{ id }] : [];
});
}
case 'cohere':
throw new Error('Cohere requires native model discovery');
}
}
async function fetchCloudflareModels(baseUrl: string, apiKey: string): Promise<ModelInfo[]> {
const models: ModelInfo[] = [];
let page = 1;
let rawModelCount = 0;
while (page <= CLOUDFLARE_MODEL_MAX_REQUEST_PAGES) {
const url = cloudflareModelsUrl(baseUrl, page);
const response = await proxiedFetch(url, {
headers: { authorization: `Bearer ${apiKey}` },
timeoutMs: MODEL_FETCH_TIMEOUT_MS,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = (await response.json()) as {
success?: unknown;
result?: unknown;
};
if (data.success !== true || !Array.isArray(data.result)) {
throw new Error('Invalid Cloudflare models response');
}
rawModelCount += data.result.length;
if (rawModelCount > CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION) {
throw new Error('Provider returned too many models');
}
models.push(
...data.result.flatMap((raw) => {
const model = raw as RawCloudflareModel;
return typeof model.name === 'string' ? [{ id: model.name }] : [];
}),
);
if (data.result.length === 0) return models;
page += 1;
}
throw new Error('Provider returned too many models');
}
function cloudflareModelsUrl(baseUrl: string, page: number): string {
const url = new URL(baseUrl);
if (!/\/ai\/v1\/?$/.test(url.pathname)) {
throw new Error('Cloudflare Workers AI base URL must end with /ai/v1');
}
url.pathname = url.pathname.replace(/\/ai\/v1\/?$/, '/ai/models/search');
url.search = new URLSearchParams({
page: String(page),
per_page: String(CLOUDFLARE_MODEL_PAGE_SIZE),
task: 'Text Generation',
}).toString();
return url.toString();
}
function normalizeDiscoveredModels(models: ModelInfo[]): ModelInfo[] {
const unique = new Map<string, ModelInfo>();
for (const model of models) {
if (typeof model?.id !== 'string') continue;
const id = model.id.trim();
if (
!id ||
id.length > CONNECTION_MODEL_ID_MAX_LENGTH ||
/[\u0000-\u001f\u007f]/.test(id) ||
unique.has(id)
) {
continue;
}
unique.set(id, { ...model, id });
if (unique.size > CONNECTION_CATALOG_MAX_MODELS_PER_CONNECTION) {
throw new Error('Provider returned too many models');
}
}
return [...unique.values()];
}
export async function fetchGitHubCopilotModels(
baseUrl: string,
accessToken: string,
fetchFn?: typeof fetch,
): Promise<ModelInfo[]> {
const response = await (fetchFn ?? proxiedFetch)(`${stripTrailing(baseUrl)}/models`, {
headers: {
Authorization: `Bearer ${accessToken}`,
...GITHUB_COPILOT_COMPAT_HEADERS,
'Openai-Intent': 'conversation-edits',
'X-GitHub-Api-Version': GITHUB_COPILOT_API_VERSION,
},
...(fetchFn
? { signal: AbortSignal.timeout(MODEL_FETCH_TIMEOUT_MS) }
: { timeoutMs: MODEL_FETCH_TIMEOUT_MS }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = (await response.json()) as { data?: RawGitHubCopilotModel[] };
if (!Array.isArray(payload.data)) throw new Error('Invalid GitHub Copilot models response');
return payload.data.flatMap(toGitHubCopilotModelInfo);
}
type RawOpenAiCodexModel = {
slug?: unknown;
visibility?: unknown;
priority?: unknown;
context_window?: unknown;
};
/**
* Discovery error carrying the HTTP status, so callers (syncOpenAiCodexConnection)
* can classify auth failures (401/403) vs protocol errors (4xx) vs transient
* network failures without string-matching the message.
*/
export class OpenAiCodexDiscoveryError extends Error {
constructor(public readonly status: number) {
super(`HTTP ${status}`);
this.name = 'OpenAiCodexDiscoveryError';
}
}
/** Structured status for standard provider `/models` endpoints. */
export class ProviderModelDiscoveryHttpError extends Error {
constructor(public readonly status: number) {
super(`HTTP ${status}`);
this.name = 'ProviderModelDiscoveryHttpError';
}
}
/**
* Discover models from the ChatGPT/Codex OAuth backend
* (`chatgpt.com/backend-api/codex/models`). Unlike the public OpenAI API
* `/v1/models`, this endpoint reports the slugs the signed-in ChatGPT account
* can actually use over the Codex backend, including OAuth-only slugs such
* as `gpt-5.3-codex-spark`. Entries with `visibility: hide|hidden` are
* dropped; the rest are sorted by `priority` (ascending) to match the
* ChatGPT/Codex picker order.
*/
export async function fetchOpenAiCodexModels(
baseUrl: string,
accessToken: string,
fetchFn?: typeof fetch,
): Promise<ModelInfo[]> {
const response = await (fetchFn ?? proxiedFetch)(
`${stripTrailing(baseUrl)}/models?client_version=1.0.0`,
{
headers: {
Authorization: `Bearer ${accessToken}`,
...openAiCodexHeaders(accessToken),
'content-type': 'application/json',
},
...(fetchFn
? { signal: AbortSignal.timeout(MODEL_FETCH_TIMEOUT_MS) }
: { timeoutMs: MODEL_FETCH_TIMEOUT_MS }),
},
);
if (!response.ok) throw new OpenAiCodexDiscoveryError(response.status);
const payload = (await response.json()) as { models?: RawOpenAiCodexModel[] };
if (!Array.isArray(payload?.models)) throw new Error('Invalid OpenAI Codex models response');
const visible = payload.models.filter((model) => {
if (!model || typeof model.slug !== 'string' || !model.slug.trim()) return false;
const visibility =
typeof model.visibility === 'string' ? model.visibility.trim().toLowerCase() : '';
return visibility !== 'hide' && visibility !== 'hidden';
});
visible.sort((a, b) => priorityOfOpenAiCodexModel(a) - priorityOfOpenAiCodexModel(b));
return visible.map((model) => {
const entry: ModelInfo = { id: (model.slug as string).trim() };
const contextWindow = contextWindowOfOpenAiCodexModel(model);
if (contextWindow !== undefined) entry.contextWindow = contextWindow;
return entry;
});
}
function priorityOfOpenAiCodexModel(model: RawOpenAiCodexModel): number {
return typeof model.priority === 'number' && Number.isFinite(model.priority)
? model.priority
: 10_000;
}
function contextWindowOfOpenAiCodexModel(model: RawOpenAiCodexModel): number | undefined {
return typeof model.context_window === 'number' &&
Number.isFinite(model.context_window) &&
model.context_window > 0
? model.context_window
: undefined;
}
function toGitHubCopilotModelInfo(model: RawGitHubCopilotModel): ModelInfo[] {
if (
!model.id ||
model.model_picker_enabled !== true ||
model.policy?.state === 'disabled' ||
model.capabilities?.supports?.tool_calls !== true
)
return [];
const endpoints = model.supported_endpoints ?? [];
const apiProtocol = endpoints.includes('/v1/messages')
? ('anthropic-messages' as const)
: endpoints.includes('/responses')
? ('openai-responses' as const)
: endpoints.includes('/chat/completions')
? ('openai-chat' as const)
: null;
if (!apiProtocol) return [];
const limits = model.capabilities.limits;
const supports = model.capabilities.supports;
const reasoning =
supports.adaptive_thinking === true ||
(supports.reasoning_effort?.length ?? 0) > 0 ||
supports.max_thinking_budget !== undefined ||
supports.min_thinking_budget !== undefined;
const vision =
supports.vision === true ||
limits?.vision?.supported_media_types?.some((type) => type.startsWith('image/')) === true;
const contextWindow = limits?.max_context_window_tokens ?? limits?.max_prompt_tokens;
return [
{
id: model.id,
...(model.name ? { displayName: model.name } : {}),
...(typeof contextWindow === 'number' ? { contextWindow } : {}),
...(typeof limits?.max_output_tokens === 'number'
? { maxOutputTokens: limits.max_output_tokens }
: {}),
apiProtocol,
capabilities: { vision, reasoning, functionCalling: true },
},
];
}
async function fetchCohereModels(baseUrl: string, apiKey: string): Promise<ModelInfo[]> {
const root = stripTrailing(baseUrl).replace(/\/v2$/, '');
const models: ModelInfo[] = [];
let pageToken: string | undefined;
do {
const query = new URLSearchParams({ endpoint: 'chat', page_size: '1000' });
if (pageToken) query.set('page_token', pageToken);
const response = await proxiedFetch(`${root}/v1/models?${query.toString()}`, {
headers: { authorization: `Bearer ${apiKey}` },
timeoutMs: MODEL_FETCH_TIMEOUT_MS,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = (await response.json()) as {
models?: RawCohereModel[];
next_page_token?: string;
};
models.push(
...(data.models ?? []).flatMap((model) => {
if (!model.name || model.is_deprecated === true || !model.endpoints?.includes('chat'))
return [];
return [
{
id: model.name,
...(typeof model.context_length === 'number'
? { contextWindow: model.context_length }
: {}),
},
];
}),
);
pageToken = data.next_page_token || undefined;
} while (pageToken);
return models;
}
function filterDiscoveredModels(
models: ModelInfo[],
filter: 'fallback-models' | 'language-models' | 'tool-capable' | undefined,
fallbackModels: readonly string[],
): ModelInfo[] {
if (filter === 'tool-capable') {
return models.filter((model) => model.capabilities?.functionCalling === true);
}
if (filter !== 'fallback-models') return models;
const supported = new Set(fallbackModels);
return models.filter((model) => supported.has(model.id));
}
function modelListUrl(
baseUrl: string,
path: string | undefined,
query: Readonly<Record<string, string>> | undefined,
): string {
const url = path
? new URL(path, `${stripTrailing(baseUrl)}/`).toString()
: `${stripTrailing(baseUrl)}/models`;
const search = query ? new URLSearchParams(query).toString() : '';
return search ? `${url}?${search}` : url;
}
async function fetchFireworksModels(
baseUrl: string,
apiKey: string,
discovery: FireworksModelDiscovery,
): Promise<ModelInfo[]> {
const root = stripTrailing(baseUrl).replace(/\/inference\/v1$/, '');
const headers = {
'content-type': 'application/json',
authorization: `Bearer ${apiKey}`,
};
const fetchPages = async <T>(
path: string,
query: Readonly<Record<string, string>>,
itemKey: 'accounts' | 'models',
): Promise<T[]> => {
const items: T[] = [];
let pageToken: string | undefined;
do {
const search = new URLSearchParams(query);
if (pageToken) search.set('pageToken', pageToken);
const response = await proxiedFetch(`${root}${path}?${search.toString()}`, {
headers,
timeoutMs: MODEL_FETCH_TIMEOUT_MS,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = (await response.json()) as {
accounts?: T[];
models?: T[];
nextPageToken?: string;
};
items.push(...(data[itemKey] ?? []));
pageToken = data.nextPageToken || undefined;
} while (pageToken);
return items;
};
const accounts = await fetchPages<{ name?: string }>(
discovery.accountsPath,
{ pageSize: '200' },
'accounts',
);
const accountNames = [
...accounts.flatMap((account) =>
account.name && /^accounts\/[^/]+$/.test(account.name) ? [account.name] : [],
),
discovery.publicAccount,
].filter((name, index, names) => names.indexOf(name) === index);
const modelLists = await Promise.all(
accountNames.map((accountName) =>
fetchPages<RawFireworksModel>(`/v1/${accountName}/models`, discovery.query, 'models'),
),
);
return modelLists.flat().flatMap((model) => {
if (!model.name) return [];
const capabilities: NonNullable<ModelInfo['capabilities']> = {};
if (typeof model.supportsImageInput === 'boolean')
capabilities.vision = model.supportsImageInput;
if (typeof model.supportsTools === 'boolean')
capabilities.functionCalling = model.supportsTools;
return [
{
id: model.name,
...(model.displayName ? { displayName: model.displayName } : {}),
...(typeof model.contextLength === 'number' ? { contextWindow: model.contextLength } : {}),
...(Object.keys(capabilities).length ? { capabilities } : {}),
},
];
});
}
function toModelInfo(model: RawProviderModel): ModelInfo | null {
if (!model.id) return null;
const contextWindow = model.context_length ?? model.context_window;
const capabilities: NonNullable<ModelInfo['capabilities']> = {};
if (model.input_modalities?.includes('image')) capabilities.vision = true;
if (typeof model.capabilities?.reasoning === 'boolean')
capabilities.reasoning = model.capabilities.reasoning;
if (typeof model.supports_image_in === 'boolean') capabilities.vision = model.supports_image_in;
if (typeof model.supports_reasoning === 'boolean')
capabilities.reasoning = model.supports_reasoning;
if (model.tags?.includes('vision')) capabilities.vision = true;
if (model.tags?.includes('reasoning')) capabilities.reasoning = true;
if (model.tags?.includes('tool-use')) capabilities.functionCalling = true;
if (model.providers) {
capabilities.functionCalling = model.providers.some(
(provider) => provider.status === 'live' && provider.supports_tools === true,
);
}
return {
id: model.id,
...(model.display_name || model.name ? { displayName: model.display_name ?? model.name } : {}),
...(typeof contextWindow === 'number' ? { contextWindow } : {}),
...(typeof model.max_tokens === 'number' ? { maxOutputTokens: model.max_tokens } : {}),
...(Object.keys(capabilities).length ? { capabilities } : {}),
};
}
function anthropicModelHeaders(
auth: 'claude-subscription' | undefined,
apiKey: string,
): Record<string, string> {
if (auth === 'claude-subscription') {
return {
...claudeSubscriptionHeaders(),
Authorization: `Bearer ${apiKey}`,
'anthropic-version': '2023-06-01',
};
}
return {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
};
}
function stripTrailing(u: string): string {
return u.replace(/\/+$/, '');
}
function ollamaRoot(baseUrl: string): string {
return stripTrailing(baseUrl).replace(/\/v1$/, '');
}