Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
0e1026221This file is pinned to the selected release. Download the original to open it in your design tool.
/**
* A saved API as a place to run a harness: an OpenAI-compatible API (OpenRouter, or a Custom API) lists
* its chat models, and a person can move a pane's agent onto one of them.
*
* The move is a grid move in everything but where the endpoint comes from. The same per-engine launch
* contracts run it (`gridLaunch.ts`) — OpenRouter documents exactly those variables for Claude Code
* (`ANTHROPIC_BASE_URL=https://openrouter.ai/api`), Codex (`wire_api = "responses"`) and OpenCode — and
* the endpoint and key are read HERE, from this computer's store, so the app never holds the key.
*
* Listing a model list is a free read; nothing here makes a paid request.
*/
import { createHash } from 'node:crypto'
import { ApiConnectionError, type ApiConnections } from './apiConnections.js'
import { rememberApiBase } from './gridAssignment.js'
import { API_NETWORK_PREFIX, contextWindowHint, isApiLaunch, type GridLaunchOverride } from './gridLaunch.js'
import { MIN_CODING_CONTEXT } from './localModels.js'
export interface ApiModel {
id: string
/** The API's display name for it, when it gives one (OpenRouter: "Anthropic: Claude Sonnet 4.6"). */
name?: string
/** Its context window in tokens, when listed — what the engine is told to compact inside. */
contextWindow?: number
}
/** A list is re-read after this long, or at once when the connection's URL or key changed. */
const LIST_FRESH_MS = 10 * 60_000
const LIST_TIMEOUT_MS = 15_000
/** More than any API lists; an answer past it is cut. */
const MAX_MODELS = 2000
const MODEL_ID = /^[^\s\x00-\x1f\x7f]{1,256}$/
interface Listed { fingerprint: string; at: number; models: ApiModel[] }
const lists = new Map<string, Listed>()
/** Forget every list read, so the next ask reads each API again. */
export function forgetApiModels(): void {
lists.clear()
}
export interface ApiModelDeps {
fetch?: typeof fetch
now?: () => number
}
/** The models in an OpenAI-shaped `/models` answer that a coding agent can run on.
*
* A field an API does not send says nothing: a Custom API's rows are usually just `{id}` and are all
* kept. Where the API does say (OpenRouter), a model that takes no tools, answers in no text, or has a
* window under the smallest a coding agent can work in (64K, as for local models) is left out. */
export function chatModels(body: unknown): ApiModel[] {
const rows = body && typeof body === 'object' && Array.isArray((body as { data?: unknown }).data)
? (body as { data: unknown[] }).data
: []
const seen = new Set<string>()
const models: ApiModel[] = []
for (const raw of rows.slice(0, MAX_MODELS)) {
if (!raw || typeof raw !== 'object') continue
const row = raw as Record<string, unknown>
const id = typeof row.id === 'string' ? row.id.trim() : ''
if (!MODEL_ID.test(id) || seen.has(id)) continue
const parameters = row.supported_parameters
if (Array.isArray(parameters) && !parameters.includes('tools')) continue
const outputs = (row.architecture as { output_modalities?: unknown } | undefined)?.output_modalities
if (Array.isArray(outputs) && !outputs.includes('text')) continue
const listed = typeof row.context_length === 'number' ? row.context_length : row.context_window
if (typeof listed === 'number' && listed < MIN_CODING_CONTEXT) continue
const contextWindow = contextWindowHint(listed)
const name = typeof row.name === 'string' && row.name.trim() && row.name.length <= 200 ? row.name.trim() : undefined
seen.add(id)
models.push({ id, ...(name && name !== id ? { name } : {}), ...(contextWindow ? { contextWindow } : {}) })
}
return models.sort((a, b) => a.id.localeCompare(b.id))
}
/** The chat models `connectionId` lists, read with its saved key and kept for [LIST_FRESH_MS]. */
export async function listApiModels(
store: ApiConnections,
connectionId: string,
options: ApiModelDeps & { refresh?: boolean } = {},
): Promise<ApiModel[]> {
const { connection, apiKey } = store.modelAccess(connectionId)
const now = options.now ?? Date.now
const fingerprint = createHash('sha256').update(`${connection.baseUrl}\n${apiKey}`).digest('hex')
const kept = lists.get(connection.id)
if (!options.refresh && kept?.fingerprint === fingerprint && now() - kept.at < LIST_FRESH_MS) return kept.models
let response: Response
try {
response = await (options.fetch ?? fetch)(`${connection.baseUrl.replace(/\/+$/, '')}/models`, {
method: 'GET',
headers: { authorization: `Bearer ${apiKey}`, accept: 'application/json' },
redirect: 'manual',
signal: AbortSignal.timeout(LIST_TIMEOUT_MS),
})
} catch {
throw new ApiConnectionError(`${connection.name} did not answer. Check your connection and try again.`)
}
if (!response.ok) {
await response.body?.cancel().catch(() => {})
throw new ApiConnectionError(response.status === 401 || response.status === 403
? `${connection.name} did not accept the saved key. Edit the connection and paste a new key.`
: `${connection.name} could not list its models (HTTP ${response.status}). Try again.`)
}
const models = chatModels(await response.json().catch(() => null))
lists.set(connection.id, { fingerprint, at: now(), models })
return models
}
/**
* The launch that puts an agent on `model` of the saved API `connectionId`.
*
* ⚠️ The answer carries the live key; it travels only through `gridLaunch.ts`, into the engine's
* environment. A model the API does not list is refused rather than launched into an engine that
* would fail on its first message.
*/
export async function resolveApiTarget(
store: ApiConnections,
connectionId: string,
model: string,
deps: ApiModelDeps = {},
): Promise<GridLaunchOverride> {
const { connection, apiKey } = store.modelAccess(connectionId)
if (!MODEL_ID.test(model)) throw new ApiConnectionError('Choose a model.')
const listed = (await listApiModels(store, connectionId, deps)).find((row) => row.id === model)
if (!listed) throw new ApiConnectionError(`${connection.name} does not list ${model} for coding agents.`)
rememberApiBase(connection.baseUrl)
return {
networkId: `${API_NETWORK_PREFIX}${connection.id}`,
networkName: connection.name,
baseUrl: connection.baseUrl,
apiKey,
model,
...(listed.contextWindow ? { contextWindow: listed.contextWindow } : {}),
}
}
/**
* [launch] with the endpoint and key as saved NOW. A restart, a restore or a resume repeats the row's
* launch, and the person may have pasted a new key since; a launch that is not onto an API comes back
* unchanged. Throws when the connection was removed: its key must not keep being used.
*/
export function refreshApiLaunch(store: ApiConnections, launch: GridLaunchOverride): GridLaunchOverride {
if (!isApiLaunch(launch)) return launch
const { connection, apiKey } = store.modelAccess(launch.networkId.slice(API_NETWORK_PREFIX.length))
rememberApiBase(connection.baseUrl)
return { ...launch, networkName: connection.name, baseUrl: connection.baseUrl, apiKey }
}
/** Every saved API's endpoint, so agents already running on one are recognised (`gridAssignment.ts`). */
export function rememberSavedApis(store: ApiConnections): void {
try {
for (const connection of store.list()) rememberApiBase(connection.baseUrl)
} catch {
// An unreadable store is reported where it is managed; recognising agents is best effort.
}
}
/** `api_connections {action: 'models', id}`: one API's chat models. No key in any reply. */
export async function apiModelsRequest(
store: ApiConnections,
payload: Record<string, unknown>,
deps: ApiModelDeps = {},
): Promise<Record<string, unknown>> {
if (typeof payload.id !== 'string' || !payload.id) return { error: 'API_MODELS_FAILED', detail: 'Choose a saved API.' }
try {
return { id: payload.id, models: await listApiModels(store, payload.id, { ...deps, refresh: payload.refresh === true }) }
} catch (error) {
return { error: 'API_MODELS_FAILED', detail: error instanceof ApiConnectionError ? error.message : 'Models are unavailable. Try again.' }
}
}