diff --git a/README.md b/README.md index 27af179..dadb9ad 100644 --- a/README.md +++ b/README.md @@ -129,22 +129,29 @@ For the complete first-session walkthrough, see the Backboard SSO into a separate application, see the [Backboard SSO integration guide](https://docs.backboard.io/concepts/sso). -## Use your own model-provider keys +## Use your own model providers -A Backboard login is not required when you want to call a supported provider +A Backboard login is not required when you want to call a model provider directly. On the authentication screen choose **Bring your own key**, or run: ```text -/keys +/providers ``` -R-CLI currently supports direct keys for: +`/keys` remains an alias for `/providers`. + +R-CLI includes first-class direct integrations for: - Anthropic - OpenAI - Google - OpenRouter +You can also add custom OpenAI Chat Completions, OpenAI Responses, and +Anthropic Messages compatible endpoints. This covers most hosted gateways and +local servers, including Ollama, LM Studio, vLLM, Together, Fireworks, Groq, +and similar OpenAI-compatible services. + Provider keys are validated before saving, encrypted at rest in `~/.backboard/keys.json`, and never written to project session logs. Add keys through the interactive flow rather than placing provider secrets in project @@ -154,6 +161,74 @@ You can keep both a Backboard login and provider keys. When the same provider is available through both, the enabled direct key takes precedence for that provider's models. +### Custom model providers + +Use `/providers`, choose **Add custom provider**, and enter: + +- A stable provider ID and display name +- The API protocol +- A base URL +- An encrypted API key, environment variable, or no authentication +- Optional model discovery endpoint, manual models, headers, and request + arguments + +For an OpenAI-compatible local endpoint: + +```text +Name: Local Provider +ID: local-provider +Protocol: OpenAI Chat Completions +Base URL: http://localhost:8000/v1 +Authentication: No authentication +Models endpoint: models +``` + +The connection is tested before saving. Discovered models then appear under +the provider's own tab in `/model`. The provider manager also supports editing, +re-testing, enabling/disabling, and removal. + +Advanced users can edit the non-secret definitions in +`~/.backboard/config.json`: + +```json +{ + "providers": [ + { + "id": "local-provider", + "name": "Local Provider", + "protocol": "openai-responses", + "baseUrl": "http://localhost:8000/v1", + "auth": { "type": "none" }, + "headers": { + "X-Workspace": "${WORKSPACE_ID}" + }, + "extraArgs": { + "temperature": 0.2 + }, + "models": [ + { + "id": "gpt-5.6-sol", + "contextLimit": 400000, + "maxOutputTokens": 32768, + "supportsThinking": true + } + ] + } + ] +} +``` + +Set `"discoverModels": false` for an endpoint without `GET /models`; manual +models are then required. `modelsPath` may be a relative path or full URL. +String values in `baseUrl`, `modelsPath`, `headers`, `extraArgs`, and +model-level `extraArgs` support `${ENV_VAR}` references. Missing variables fail +clearly without sending the literal placeholder. + +This compatibility layer targets OpenAI-compatible and Anthropic-compatible +HTTP APIs. Provider-native authentication such as AWS SigV4, cloud SDK +credential chains, and provider-specific OAuth requires a dedicated +integration. + ## Start your first session Run `backboard` from the project you want it to work on: @@ -288,7 +363,7 @@ Type `/help` inside R-CLI for the authoritative command list. | --------------------------- | ------------------------------------------ | | `/model` | Choose a model and thinking mode | | `/settings` | Adjust session preferences | -| `/keys` | Manage direct provider API keys | +| `/providers`, `/keys` | Manage model providers and credentials | | `/sessions`, `/session` | Browse Backboard and local BYOK sessions | | `/resume SESSION_ID` | Resume a session directly by ID | | `/context` | Inspect context-window usage | diff --git a/scripts/verify-tool-schemas.ts b/scripts/verify-tool-schemas.ts index 3b8fdbe..65ef7a4 100644 --- a/scripts/verify-tool-schemas.ts +++ b/scripts/verify-tool-schemas.ts @@ -12,10 +12,10 @@ * Opt-in; never part of `bun test`. Costs a few tokens per model. */ import { Config } from "../src/config/Config.ts"; +import { BYOK_PROVIDER_IDS } from "../src/core/keys/ProviderKeyTypes.ts"; import { ToolRegistry } from "../src/core/tools/ToolRegistry.ts"; import { BackboardClient } from "../src/providers/backboard/BackboardClient.ts"; import type { ModelCatalogItem } from "../src/providers/backboard/types.ts"; -import { byokAdapter } from "../src/providers/byok/registry.ts"; import { createAgentClient } from "../src/providers/createAgentClient.ts"; import { createDefaultTools } from "../src/tools/index.ts"; @@ -30,17 +30,21 @@ config.enableComputerUse(); config.enableBrowserUse(); const router = createAgentClient(config); await Promise.all( - config.auth.providerKeys.map(async ({ provider, key }) => { - try { - await byokAdapter(provider).listModels(key); - } catch (err) { - throw new Error( - `${provider} catalog failed: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } - }), + config.auth.providerKeys + .filter(({ provider }) => !filter || provider.includes(filter)) + .map(async ({ provider, key }) => { + try { + const adapter = config.providerRegistry.get(provider); + if (!adapter) throw new Error("provider adapter is unavailable"); + await adapter.listModels(key); + } catch (err) { + throw new Error( + `${provider} catalog failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + }), ); let toolList: ReturnType = []; toolList = createDefaultTools({ @@ -71,7 +75,12 @@ const PREFERRED = [ ]; function pick(models: ModelCatalogItem[]): string | undefined { - const names = models.map((m) => m.name).filter((n) => !SKIP.test(n)); + const names = models + .filter( + (model) => !filter || `${model.provider}/${model.name}`.includes(filter), + ) + .map((m) => m.name) + .filter((n) => !SKIP.test(n)); for (const re of PREFERRED) { const hit = names.find((n) => re.test(n)); if (hit) return hit; @@ -164,8 +173,11 @@ if (backboard) { const server = catalog.filter((m) => m.source !== "byok"); const providers = new Set(server.map((m) => m.provider)); // The merged catalog hides Backboard's own route for providers you also hold a key for. - for (const provider of [...new Set(byok.map((m) => m.provider))]) - providers.add(provider); + for (const provider of BYOK_PROVIDER_IDS) { + if (byok.some((model) => model.provider === provider)) { + providers.add(provider); + } + } for (const provider of providers) { if (SKIP.test(provider)) continue; const model = diff --git a/src/config/BackboardConfigTypes.ts b/src/config/BackboardConfigTypes.ts index b095d84..39d33ed 100644 --- a/src/config/BackboardConfigTypes.ts +++ b/src/config/BackboardConfigTypes.ts @@ -1,5 +1,6 @@ import type { JsonObject } from "../utils/JsonTypes.ts"; import type { MemoryMode, MemoryProfile, ThinkingIntent } from "./defaults.ts"; +import type { CustomProviderDefinition } from "./providers.ts"; export interface BackboardConfigFile { apiKey?: string; @@ -13,6 +14,8 @@ export interface BackboardConfigFile { memoryProfile?: MemoryProfile; notify?: boolean; verbose?: boolean; + /** User-defined HTTP model providers. Secrets remain in keys.json. */ + providers?: CustomProviderDefinition[]; /** Expert mode: implementation runs on `model`, planning stays on `/model`. */ expert?: ExpertConfig; } diff --git a/src/config/Config.ts b/src/config/Config.ts index fabafca..39c06c1 100644 --- a/src/config/Config.ts +++ b/src/config/Config.ts @@ -1,5 +1,6 @@ import { canonicalToolName } from "../core/tools/names.ts"; import { ToolPolicy } from "../core/tools/ToolPolicy.ts"; +import type { ProviderRegistry } from "../providers/byok/registry.ts"; import { type AuthState, hasAnyCredentials, @@ -473,19 +474,26 @@ export class Config { return this.auth.providerKeys.length > 0; } + get providerRegistry(): ProviderRegistry { + return this.auth.providerRegistry; + } + hasProviderKeyFor(provider: string): boolean { - return this.auth.providerKeys.some((entry) => entry.provider === provider); + const normalized = provider.trim().toLowerCase(); + return this.auth.providerKeys.some( + (entry) => entry.provider.trim().toLowerCase() === normalized, + ); } private hasBackendFor(model: ModelRef): boolean { - return this.hasBackboardAuth || this.hasProviderKeyFor(model.provider); + const hasDirectProvider = this.hasProviderKeyFor(model.provider); + return this.providerRegistry.definition(model.provider) + ? hasDirectProvider + : this.hasBackboardAuth || hasDirectProvider; } get hasBackendForCurrentModel(): boolean { - return ( - this.hasBackboardAuth || - this.hasProviderKeyFor(this.currentModel.provider) - ); + return this.hasBackendFor(this.currentModel); } /** diff --git a/src/config/auth.ts b/src/config/auth.ts index b3d5c4c..b0fd447 100644 --- a/src/config/auth.ts +++ b/src/config/auth.ts @@ -1,11 +1,6 @@ -import { - enabledProviderKeys, - readProviderKeys, -} from "../core/keys/ProviderKeyStore.ts"; -import type { - ByokProviderId, - ResolvedProviderKey, -} from "../core/keys/ProviderKeyTypes.ts"; +import { readProviderKeys } from "../core/keys/ProviderKeyStore.ts"; +import type { ResolvedProviderKey } from "../core/keys/ProviderKeyTypes.ts"; +import { ProviderRegistry } from "../providers/byok/registry.ts"; import { readBackboardConfig } from "./backboardConfig.ts"; import { type BackboardEnv, resolveApiUrl } from "./env.ts"; @@ -31,6 +26,7 @@ export interface AuthState { backboard: BackboardEnv | null; /** Saved provider keys that are currently toggled on. */ providerKeys: ResolvedProviderKey[]; + providerRegistry: ProviderRegistry; } export interface ResolveAuthOptions { @@ -43,12 +39,19 @@ export function resolveAuth(options: ResolveAuthOptions = {}): AuthState { const apiKey = isUsableApiKey(envApiKey) ? envApiKey : fileConfig.apiKey; const apiUrl = resolveApiUrl(fileConfig.apiUrl); + const providerRegistry = new ProviderRegistry(fileConfig.providers ?? []); + const savedKeys = readProviderKeys(options.homeDir); + const providerKeys = providerRegistry.adapters.flatMap((adapter) => { + const key = providerRegistry.credentialFor( + adapter.id, + savedKeys[adapter.id], + ); + return key === null ? [] : [{ provider: adapter.id, key }]; + }); return { backboard: apiKey ? { apiKey, apiUrl } : null, - // Env vars are deliberately not consulted: a provider key becomes usable - // only by being added through the BYOK flow or `/keys`, so what the CLI - // bills to is always something the user chose explicitly. - providerKeys: enabledProviderKeys(readProviderKeys(options.homeDir)), + providerKeys, + providerRegistry, }; } @@ -59,7 +62,7 @@ export function hasAnyCredentials(auth: AuthState): boolean { /** Builds the provider -> key lookup `ByokClient` and `ClientRouter` use. */ export function providerKeyResolver( auth: AuthState, -): (provider: ByokProviderId) => string | null { +): (provider: string) => string | null { const byProvider = new Map( auth.providerKeys.map((entry) => [entry.provider, entry.key]), ); diff --git a/src/config/backboardConfig.ts b/src/config/backboardConfig.ts index 3028a25..ebc8e55 100644 --- a/src/config/backboardConfig.ts +++ b/src/config/backboardConfig.ts @@ -14,6 +14,7 @@ import { parseMemoryProfile, type ThinkingLevel, } from "./defaults.ts"; +import { parseCustomProviders } from "./providers.ts"; export type { BackboardConfigFile } from "./BackboardConfigTypes.ts"; @@ -50,6 +51,7 @@ export function readBackboardConfig( memoryProfile: readMemoryProfileConfig(config), notify: typeof config.notify === "boolean" ? config.notify : undefined, verbose: typeof config.verbose === "boolean" ? config.verbose : undefined, + providers: parseCustomProviders(config.providers), expert: readExpertConfig(config), }; } catch (err) { @@ -194,10 +196,18 @@ export async function saveBackboardConfig( ): Promise { const file = backboardConfigPath(homeDir); const dir = path.dirname(file); + const opaqueProviders = readOpaqueProviderEntries(file); + const output = + opaqueProviders.length === 0 + ? config + : { + ...config, + providers: [...(config.providers ?? []), ...opaqueProviders], + }; await mkdir(dir, { recursive: true, mode: 0o700 }); await chmod(dir, 0o700).catch(() => undefined); - await writeFile(file, `${JSON.stringify(config, null, 2)}\n`, { + await writeFile(file, `${JSON.stringify(output, null, 2)}\n`, { mode: 0o600, }); await chmod(file, 0o600).catch(() => undefined); @@ -205,6 +215,37 @@ export async function saveBackboardConfig( return file; } +function readOpaqueProviderEntries(file: string): JsonValue[] { + let parsed: JsonValue; + try { + parsed = JSON.parse(readFileSync(file, "utf8")) as JsonValue; + } catch (err) { + if ((err as { code?: string }).code === "ENOENT") return []; + throw new Error( + `Failed to preserve providers in ${file}: ${errorMessage(err)}`, + ); + } + if ( + typeof parsed !== "object" || + parsed === null || + Array.isArray(parsed) || + !Array.isArray(parsed.providers) + ) { + return []; + } + const opaque: JsonValue[] = []; + const seen = new Set(); + for (const entry of parsed.providers) { + const provider = parseCustomProviders([entry])?.[0]; + if (!provider || seen.has(provider.id)) { + opaque.push(entry); + continue; + } + seen.add(provider.id); + } + return opaque; +} + export async function deleteBackboardConfig( homeDir = os.homedir(), ): Promise<{ path: string; removed: boolean }> { @@ -219,3 +260,16 @@ export async function deleteBackboardConfig( throw new Error(`Failed to delete ${file}: ${errorMessage(err)}`); } } + +/** Removes only the Backboard credential while preserving local preferences/providers. */ +export async function clearBackboardCredential( + homeDir = os.homedir(), +): Promise<{ path: string; removed: boolean }> { + const existing = readBackboardConfig(homeDir); + if (!existing.apiKey) { + return { path: backboardConfigPath(homeDir), removed: false }; + } + const { apiKey: _removed, ...rest } = existing; + await saveBackboardConfig(rest, homeDir); + return { path: backboardConfigPath(homeDir), removed: true }; +} diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 8371cb9..28f8f23 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -44,7 +44,7 @@ const KEY_ONLY_DEFAULT_MODELS: Readonly> = { export function keyOnlyDefaultModel( provider: ByokProviderId | undefined, ): ModelRef | null { - return provider ? KEY_ONLY_DEFAULT_MODELS[provider] : null; + return provider ? (KEY_ONLY_DEFAULT_MODELS[provider] ?? null) : null; } export function formatModel(ref: ModelRef): string { diff --git a/src/config/providers.ts b/src/config/providers.ts new file mode 100644 index 0000000..9b363b0 --- /dev/null +++ b/src/config/providers.ts @@ -0,0 +1,352 @@ +import type { JsonObject, JsonValue } from "../utils/JsonTypes.ts"; + +export const CUSTOM_PROVIDER_PROTOCOLS = [ + "openai-chat", + "openai-responses", + "anthropic-messages", +] as const; + +export type CustomProviderProtocol = (typeof CUSTOM_PROVIDER_PROTOCOLS)[number]; +export type CustomProviderAuth = + | { type: "apiKey" } + | { type: "env"; variable: string } + | { type: "none" }; + +export interface CustomModelDefinition { + id: string; + name?: string; + contextLimit?: number; + maxOutputTokens?: number; + noImageSupport?: boolean; + supportsThinking?: boolean; + enabled?: boolean; + extraArgs?: JsonObject; +} + +export interface CustomProviderDefinition { + id: string; + name: string; + protocol: CustomProviderProtocol; + baseUrl: string; + enabled?: boolean; + auth?: CustomProviderAuth; + headers?: Record; + extraArgs?: JsonObject; + discoverModels?: boolean; + modelsPath?: string; + models?: CustomModelDefinition[]; +} + +const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/; +export const RESERVED_CUSTOM_PROVIDER_IDS: ReadonlySet = new Set([ + "anthropic", + "openai", + "google", + "openrouter", + "gemini", + "google-gemini", +]); + +export function normalizeProviderId(value: string): string { + return value.trim().toLowerCase(); +} + +export function isValidProviderId(value: string): boolean { + return PROVIDER_ID_PATTERN.test(normalizeProviderId(value)); +} + +export function parseCustomProviders( + value: JsonValue | undefined, +): CustomProviderDefinition[] | undefined { + if (!Array.isArray(value)) return undefined; + const providers: CustomProviderDefinition[] = []; + const seen = new Set(); + for (const entry of value) { + const provider = parseProvider(entry); + if (!provider || seen.has(provider.id)) continue; + seen.add(provider.id); + providers.push(provider); + } + return providers; +} + +export function normalizeCustomProviderDefinition( + definition: CustomProviderDefinition, +): CustomProviderDefinition { + const json = JSON.parse(JSON.stringify([definition])) as JsonValue; + const parsed = parseCustomProviders(json)?.[0]; + if (!parsed) throw new Error("The custom provider configuration is invalid."); + return parsed; +} + +function parseProvider(value: JsonValue): CustomProviderDefinition | null { + if (!isObject(value)) return null; + if ( + typeof value.id !== "string" || + !isValidProviderId(value.id) || + typeof value.name !== "string" || + !value.name.trim() || + typeof value.protocol !== "string" || + !isProtocol(value.protocol) || + typeof value.baseUrl !== "string" || + !isHttpUrlTemplate(value.baseUrl) + ) { + return null; + } + const id = normalizeProviderId(value.id); + if (RESERVED_CUSTOM_PROVIDER_IDS.has(id)) return null; + const provider: CustomProviderDefinition = { + id, + name: value.name.trim(), + protocol: value.protocol, + baseUrl: trimUrl(value.baseUrl), + }; + if (typeof value.enabled === "boolean") provider.enabled = value.enabled; + const auth = parseAuth(value.auth); + const headers = stringRecord(value.headers); + if (value.headers !== undefined && headers === null) return null; + if (usesCredentials(auth, headers) && !isSecureProviderUrl(value.baseUrl)) { + return null; + } + if (auth) provider.auth = auth; + if (headers) provider.headers = headers; + const extraArgs = jsonObject(value.extraArgs); + if (extraArgs) provider.extraArgs = extraArgs; + if (typeof value.discoverModels === "boolean") { + provider.discoverModels = value.discoverModels; + } + if (typeof value.modelsPath === "string" && value.modelsPath.trim()) { + if ( + usesCredentials(auth, headers) && + /^https?:\/\//i.test(value.modelsPath.trim()) && + !isSecureProviderUrl(value.modelsPath) + ) { + return null; + } + provider.modelsPath = value.modelsPath.trim(); + } + const models = parseModels(value.models); + if (models) provider.models = models; + return provider; +} + +function parseAuth(value: JsonValue | undefined): CustomProviderAuth | null { + if (!isObject(value) || typeof value.type !== "string") return null; + if (value.type === "apiKey" || value.type === "none") { + return { type: value.type }; + } + if ( + value.type === "env" && + typeof value.variable === "string" && + /^[A-Za-z_][A-Za-z0-9_]*$/.test(value.variable.trim()) + ) { + return { type: "env", variable: value.variable.trim() }; + } + return null; +} + +function parseModels( + value: JsonValue | undefined, +): CustomModelDefinition[] | null { + if (!Array.isArray(value)) return null; + const models: CustomModelDefinition[] = []; + const seen = new Set(); + for (const entry of value) { + if (!isObject(entry) || typeof entry.id !== "string" || !entry.id.trim()) { + continue; + } + const id = entry.id.trim(); + if (seen.has(id.toLowerCase())) continue; + seen.add(id.toLowerCase()); + const model: CustomModelDefinition = { id }; + if (typeof entry.name === "string" && entry.name.trim()) { + model.name = entry.name.trim(); + } + const contextLimit = positiveInteger(entry.contextLimit); + if (contextLimit) model.contextLimit = contextLimit; + const maxOutputTokens = positiveInteger(entry.maxOutputTokens); + if (maxOutputTokens) model.maxOutputTokens = maxOutputTokens; + if (typeof entry.noImageSupport === "boolean") { + model.noImageSupport = entry.noImageSupport; + } + if (typeof entry.supportsThinking === "boolean") { + model.supportsThinking = entry.supportsThinking; + } + if (typeof entry.enabled === "boolean") model.enabled = entry.enabled; + const extraArgs = jsonObject(entry.extraArgs); + if (extraArgs) model.extraArgs = extraArgs; + models.push(model); + } + return models; +} + +export function resolveEnvReferences( + value: string, + label: string, + env: NodeJS.ProcessEnv = process.env, +): string { + return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, name: string) => { + const resolved = env[name]; + if (resolved === undefined) { + throw new Error( + `${label} references missing environment variable ${name}.`, + ); + } + return resolved; + }); +} + +export function resolveProviderHeaders( + provider: CustomProviderDefinition, + env: NodeJS.ProcessEnv = process.env, +): Record { + const headers: Record = {}; + for (const [name, value] of Object.entries(provider.headers ?? {})) { + if ( + isCredentialHeader(name) && + !/\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(value) + ) { + throw new Error( + `${provider.name} header ${name} may contain a secret; reference an environment variable instead of storing it in config.json.`, + ); + } + headers[name] = resolveEnvReferences( + value, + `${provider.name} header ${name}`, + env, + ); + } + return headers; +} + +export function isCredentialHeader(name: string): boolean { + return /(?:authorization|cookie|token|secret|api[-_]?key|credential|password|(?:^|[-_])auth(?:$|[-_]))/i.test( + name.trim(), + ); +} + +export function resolveJsonEnvReferences( + value: JsonValue, + label: string, + env: NodeJS.ProcessEnv = process.env, +): JsonValue { + if (typeof value === "string") return resolveEnvReferences(value, label, env); + if (Array.isArray(value)) { + return value.map((entry) => resolveJsonEnvReferences(entry, label, env)); + } + if (typeof value === "object" && value !== null) { + const resolved: JsonObject = {}; + for (const [key, entry] of Object.entries(value)) { + resolved[key] = resolveJsonEnvReferences(entry, `${label}.${key}`, env); + } + return resolved; + } + return value; +} + +export function joinProviderUrl(baseUrl: string, endpoint: string): string { + const base = trimUrl(baseUrl); + const suffix = endpoint.trim(); + if (!suffix) return base; + if (/^https?:\/\//i.test(suffix)) return trimUrl(suffix); + const normalizedSuffix = suffix.replace(/^\/+/, ""); + const basePath = new URL(base).pathname.replace(/\/+$/, ""); + if ( + basePath && + basePath !== "/" && + (normalizedSuffix === basePath.slice(1) || + normalizedSuffix.startsWith(`${basePath.slice(1)}/`)) + ) { + return `${new URL(base).origin}/${normalizedSuffix}`; + } + return `${base}/${normalizedSuffix}`; +} + +function isProtocol(value: string): value is CustomProviderProtocol { + return (CUSTOM_PROVIDER_PROTOCOLS as readonly string[]).includes(value); +} + +function trimUrl(value: string): string { + return value.trim().replace(/\/+$/, ""); +} + +function isHttpUrlTemplate(value: string): boolean { + try { + const url = new URL( + value.trim().replace(/\$\{[A-Za-z_][A-Za-z0-9_]*\}/g, "placeholder"), + ); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + !url.username && + !url.password + ); + } catch { + return false; + } +} + +export function isSecureProviderUrl(value: string): boolean { + try { + const url = new URL( + value.trim().replace(/\$\{[A-Za-z_][A-Za-z0-9_]*\}/g, "placeholder"), + ); + if (url.protocol === "https:") return true; + if (url.protocol !== "http:") return false; + const host = url.hostname.toLowerCase(); + return ( + host === "localhost" || + host.endsWith(".localhost") || + host === "::1" || + host === "[::1]" || + /^127(?:\.\d{1,3}){3}$/.test(host) + ); + } catch { + return false; + } +} + +function usesCredentials( + auth: CustomProviderAuth | null, + headers: Record | null, +): boolean { + return ( + (auth?.type ?? "apiKey") !== "none" || + Object.keys(headers ?? {}).some(isCredentialHeader) + ); +} + +function positiveInteger(value: JsonValue | undefined): number | null { + return typeof value === "number" && + Number.isInteger(value) && + value > 0 && + Number.isSafeInteger(value) + ? value + : null; +} + +function stringRecord( + value: JsonValue | undefined, +): Record | null { + if (!isObject(value)) return null; + const record: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if ( + !key.trim() || + /[\r\n]/.test(key) || + typeof entry !== "string" || + /[\r\n]/.test(entry) + ) { + return null; + } + record[key] = entry; + } + return record; +} + +function jsonObject(value: JsonValue | undefined): JsonObject | null { + return isObject(value) ? value : null; +} + +function isObject(value: JsonValue | undefined): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/config/thinking.ts b/src/config/thinking.ts index 8dc81ec..e42908a 100644 --- a/src/config/thinking.ts +++ b/src/config/thinking.ts @@ -72,7 +72,9 @@ export function resolveThinking( ); } const budget = resolveBudget( - profile?.budgetPolicyId ?? "generic", + metadata?.thinking_controls?.budget_policy ?? + profile?.budgetPolicyId ?? + "generic", intent.level, { field: tokenField, diff --git a/src/config/thinking.types.ts b/src/config/thinking.types.ts index a20ac27..22953cf 100644 --- a/src/config/thinking.types.ts +++ b/src/config/thinking.types.ts @@ -14,6 +14,7 @@ export interface ThinkingControlsMetadata { supported: boolean; allowed_fields: string[]; defaults_only: boolean; + budget_policy?: "anthropicLegacy" | "google" | "generic"; } export interface ThinkingModelMetadata { diff --git a/src/core/auth/BackboardAuthSession.ts b/src/core/auth/BackboardAuthSession.ts index 3e36a8f..10579ed 100644 --- a/src/core/auth/BackboardAuthSession.ts +++ b/src/core/auth/BackboardAuthSession.ts @@ -1,6 +1,6 @@ import os from "node:os"; import { - deleteBackboardConfig, + clearBackboardCredential, readBackboardConfig, saveBackboardConfig, } from "../../config/backboardConfig.ts"; @@ -36,6 +36,7 @@ export async function loginWithBackboardSso( ); } const configPath = await saveBackboardConfig({ + ...readBackboardConfig(), apiKey, apiUrl, }); @@ -55,7 +56,7 @@ function canOpenBrowser(): boolean { } export async function logoutSavedCredentials(): Promise { - const result = await deleteBackboardConfig(); + const result = await clearBackboardCredential(); const message = result.removed ? `Signed out. Removed saved credentials from ${result.path}.` : `No saved Backboard credentials found at ${result.path}.`; diff --git a/src/core/keys/ProviderKeyController.ts b/src/core/keys/ProviderKeyController.ts index 2cad779..ece7b3c 100644 --- a/src/core/keys/ProviderKeyController.ts +++ b/src/core/keys/ProviderKeyController.ts @@ -1,7 +1,17 @@ +import { resolveAuth } from "../../config/auth.ts"; +import { + readBackboardConfig, + saveBackboardConfig, +} from "../../config/backboardConfig.ts"; +import { + type CustomProviderDefinition, + normalizeCustomProviderDefinition, + normalizeProviderId, +} from "../../config/providers.ts"; import { ByokError } from "../../providers/byok/ByokError.ts"; import { - BYOK_ADAPTER_LIST, - byokAdapter, + ProviderRegistry, + RESERVED_PROVIDER_IDS, } from "../../providers/byok/registry.ts"; import { errorMessage } from "../../utils/errors.ts"; import { @@ -28,17 +38,65 @@ export class ProviderKeyController { /** Every supported provider, configured or not, in display order. */ list(): ProviderKeyStatus[] { const saved = readProviderKeys(this.options.homeDir); - return BYOK_ADAPTER_LIST.map((adapter) => { + const definitions = + readBackboardConfig(this.options.homeDir).providers ?? []; + const registry = new ProviderRegistry(definitions, { + includeDisabled: true, + }); + const statuses: ProviderKeyStatus[] = registry.adapters.map((adapter) => { const entry = saved[adapter.id]; + const definition = definitions.find( + (candidate) => candidate.id === adapter.id, + ); + const auth = definition?.auth ?? { type: "apiKey" as const }; + const credential = registry.credentialFor(adapter.id, entry); return { provider: adapter.id, label: adapter.label, - configured: entry !== undefined, - masked: entry ? maskProviderKey(entry.key) : adapter.keyHint, - enabled: entry?.enabled ?? false, + configured: definition + ? auth.type !== "apiKey" || entry !== undefined + : entry !== undefined, + masked: entry + ? maskProviderKey(entry.key) + : auth.type === "none" + ? "keyless" + : auth.type === "env" + ? `$${auth.variable}` + : adapter.keyHint, + enabled: + (definition?.enabled ?? true) && + credential !== null && + (definition ? true : (entry?.enabled ?? false)), addedAt: entry?.addedAt ?? null, + ...(definition + ? { + custom: true, + protocol: definition.protocol, + baseUrl: definition.baseUrl, + } + : {}), }; }); + for (const definition of definitions) { + if (statuses.some((status) => status.provider === definition.id)) + continue; + const entry = saved[definition.id]; + statuses.push({ + provider: definition.id, + label: definition.name, + configured: true, + masked: entry ? maskProviderKey(entry.key) : "configuration error", + enabled: false, + addedAt: entry?.addedAt ?? null, + custom: true, + protocol: definition.protocol, + baseUrl: definition.baseUrl, + error: + registry.error(definition.id)?.message ?? + "Provider configuration is unavailable.", + }); + } + return statuses; } /** @@ -51,7 +109,8 @@ export class ProviderKeyController { key: string, signal?: AbortSignal, ): Promise { - const adapter = byokAdapter(provider); + const adapter = this.registry(true).get(provider); + if (!adapter) throw new Error(`Unknown provider: ${provider}`); const trimmed = key.trim(); if (!trimmed) { throw new Error(`Enter a ${adapter.label} API key.`); @@ -73,6 +132,12 @@ export class ProviderKeyController { } async setEnabled(provider: ByokProviderId, enabled: boolean): Promise { + const definition = this.definition(provider); + if (definition) { + await this.saveDefinition({ ...definition, enabled }); + this.options.onChange?.(); + return; + } await setProviderKeyEnabled(provider, enabled, this.options.homeDir); this.options.onChange?.(); } @@ -86,9 +151,166 @@ export class ProviderKeyController { } async remove(provider: ByokProviderId): Promise { + const config = readBackboardConfig(this.options.homeDir); + const definitions = config.providers ?? []; + if (definitions.some((entry) => entry.id === provider)) { + await saveBackboardConfig( + { + ...config, + providers: definitions.filter((entry) => entry.id !== provider), + ...(config.model?.provider === provider ? { model: undefined } : {}), + }, + this.options.homeDir, + ); + } await removeProviderKey(provider, this.options.homeDir); this.options.onChange?.(); } + + customProviders(): CustomProviderDefinition[] { + return readBackboardConfig(this.options.homeDir).providers ?? []; + } + + async addCustomProvider( + definition: CustomProviderDefinition, + apiKey?: string, + signal?: AbortSignal, + ): Promise { + await this.saveCustomProvider(definition, apiKey, undefined, signal); + } + + async saveCustomProvider( + definition: CustomProviderDefinition, + apiKey?: string, + previousId?: string, + signal?: AbortSignal, + ): Promise { + const requestedId = normalizeProviderId(definition.id); + if (RESERVED_PROVIDER_IDS.has(requestedId)) { + throw new Error( + `Provider id "${requestedId}" is reserved for a built-in provider.`, + ); + } + const candidate = normalizeCustomProviderDefinition(definition); + const existing = this.customProviders(); + const duplicate = existing.find( + (entry) => entry.id === candidate.id && entry.id !== previousId, + ); + if (duplicate) { + throw new Error(`A provider with id "${candidate.id}" already exists.`); + } + const registry = new ProviderRegistry([candidate], { + includeDisabled: true, + }); + const adapter = registry.get(candidate.id); + if (!adapter) { + const registryError = registry.error(candidate.id); + if (registryError) throw registryError; + throw new Error( + `Provider id "${candidate.id}" conflicts with a built-in.`, + ); + } + const auth = candidate.auth ?? { type: "apiKey" as const }; + const saved = readProviderKeys(this.options.homeDir); + const key = + auth.type === "apiKey" + ? (apiKey?.trim() ?? + (previousId ? saved[previousId]?.key : undefined) ?? + "") + : auth.type === "env" + ? (process.env[auth.variable]?.trim() ?? "") + : ""; + if (auth.type !== "none" && !key) { + throw new Error( + auth.type === "env" + ? `Environment variable ${auth.variable} is not set.` + : `Enter credentials for ${candidate.name}.`, + ); + } + let models: string[]; + try { + models = (await adapter.listModels(key, signal)).map( + (model) => model.name, + ); + } catch (err) { + if (err instanceof ByokError && err.isAuthFailure) { + throw new Error(validationMessage(adapter.label, err)); + } + throw new Error( + `Could not load models from ${adapter.label}: ${errorMessage(err)}`, + ); + } + if (models.length === 0) { + throw new Error( + `${adapter.label} did not expose any models. Enable discovery or add at least one manual model.`, + ); + } + await this.saveDefinition(candidate, previousId, models); + if (auth.type === "apiKey") { + await setProviderKey(candidate.id, key, this.options.homeDir); + } else { + await removeProviderKey(candidate.id, this.options.homeDir); + } + if (previousId && previousId !== candidate.id) { + await removeProviderKey(previousId, this.options.homeDir); + } + this.options.onChange?.(); + } + + private registry(includeDisabled = false): ProviderRegistry { + return new ProviderRegistry(this.customProviders(), { includeDisabled }); + } + + definition(provider: string): CustomProviderDefinition | undefined { + return this.customProviders().find((entry) => entry.id === provider); + } + + private async saveDefinition( + definition: CustomProviderDefinition, + previousId = definition.id, + models: readonly string[] = [], + ): Promise { + const config = readBackboardConfig(this.options.homeDir); + const auth = resolveAuth({ homeDir: this.options.homeDir }); + const currentProvider = config.model?.provider; + const currentIsCustom = Boolean( + currentProvider && + config.providers?.some((provider) => provider.id === currentProvider), + ); + const hasDirectProvider = Boolean( + currentProvider && + auth.providerKeys.some((entry) => entry.provider === currentProvider), + ); + const currentModelReachable = + hasDirectProvider || (!currentIsCustom && auth.backboard !== null); + const providers = config.providers ?? []; + const index = providers.findIndex((entry) => entry.id === previousId); + const next = + index < 0 + ? [...providers, definition] + : providers.map((entry, position) => + position === index ? definition : entry, + ); + await saveBackboardConfig( + { + ...config, + providers: next, + ...(config.model?.provider === previousId && models[0] + ? { + model: { + provider: definition.id, + model: models.includes(config.model.model) + ? config.model.model + : models[0], + }, + } + : (!config.model || !currentModelReachable) && models[0] + ? { model: { provider: definition.id, model: models[0] } } + : {}), + }, + this.options.homeDir, + ); + } } function validationMessage(label: string, err: unknown): string { diff --git a/src/core/keys/ProviderKeyTypes.ts b/src/core/keys/ProviderKeyTypes.ts index cd1b0c4..62d6b50 100644 --- a/src/core/keys/ProviderKeyTypes.ts +++ b/src/core/keys/ProviderKeyTypes.ts @@ -1,3 +1,6 @@ +import type { CustomProviderProtocol } from "../../config/providers.ts"; +import { isValidProviderId } from "../../config/providers.ts"; + /** * BYOK (bring-your-own-key) provider identity and storage shapes. * @@ -12,10 +15,12 @@ export const BYOK_PROVIDER_IDS = [ "openrouter", ] as const; -export type ByokProviderId = (typeof BYOK_PROVIDER_IDS)[number]; +/** Built-ins are a closed display-order list; configured provider ids are dynamic. */ +export type BuiltinProviderId = (typeof BYOK_PROVIDER_IDS)[number]; +export type ByokProviderId = string; export function isByokProviderId(value: string): value is ByokProviderId { - return (BYOK_PROVIDER_IDS as readonly string[]).includes(value); + return isValidProviderId(value); } /** One saved key as it appears on disk in ~/.backboard/keys.json. */ @@ -26,9 +31,7 @@ export interface StoredProviderKey { addedAt: string; } -export type ProviderKeyFile = Partial< - Record ->; +export type ProviderKeyFile = Record; /** A saved key resolved for use: the secret plus who it belongs to. */ export interface ResolvedProviderKey { @@ -49,6 +52,10 @@ export interface ProviderKeyStatus { masked: string; enabled: boolean; addedAt: string | null; + custom?: boolean; + protocol?: CustomProviderProtocol; + baseUrl?: string; + error?: string; } export interface ProviderKeyControllerOptions { diff --git a/src/providers/ClientRouter.ts b/src/providers/ClientRouter.ts index 4e0a95b..2b0a959 100644 --- a/src/providers/ClientRouter.ts +++ b/src/providers/ClientRouter.ts @@ -18,7 +18,7 @@ import type { SendMessageRequest, SubmitToolOutputsRequest, } from "./backboard/types.ts"; -import { byokAdapterFor } from "./byok/registry.ts"; +import { BUILTIN_PROVIDER_REGISTRY } from "./byok/registry.ts"; export type ClientSource = "byok" | "backboard"; @@ -35,6 +35,10 @@ export interface ClientRouterDeps { hasBackboardAuth?: () => boolean; /** True when an enabled saved key can serve this provider. */ hasKeyFor: (provider: string) => boolean; + /** True when the live provider registry can serve this provider id. */ + hasProvider?: (provider: string) => boolean; + /** True when the provider id belongs to a user-defined provider. */ + hasCustomProvider?: (provider: string) => boolean; } export class BackendUnavailableError extends Error { @@ -63,9 +67,13 @@ export class ClientRouter implements AgentClient { /** Which backend owns a given model, honouring key-over-SSO precedence. */ sourceFor(model: ModelRef = this.deps.getModel()): ClientSource { + if (this.deps.byok && this.deps.hasCustomProvider?.(model.provider)) { + return "byok"; + } if ( this.deps.byok && - byokAdapterFor(model.provider) && + (this.deps.hasProvider?.(model.provider) ?? + BUILTIN_PROVIDER_REGISTRY.get(model.provider) !== null) && this.deps.hasKeyFor(model.provider) ) { return "byok"; diff --git a/src/providers/backboard/modelCatalog.ts b/src/providers/backboard/modelCatalog.ts index 2d0843b..bce744b 100644 --- a/src/providers/backboard/modelCatalog.ts +++ b/src/providers/backboard/modelCatalog.ts @@ -26,12 +26,13 @@ export function normalizeModel(model: ModelCatalogItem): ModelInfo | null { if (!provider || !name || !isSelectableProvider(provider)) return null; const ref = { provider, model: name }; + const displayName = model.display_name?.trim(); const releaseTimestamp = timestamp(model.last_updated); return { id: formatModel(ref), provider, model: name, - label: formatModel(ref), + label: displayName ? `${provider}/${displayName}` : formatModel(ref), ...(typeof model.max_output_tokens === "number" || model.max_output_tokens === null ? { max_output_tokens: model.max_output_tokens } @@ -66,8 +67,8 @@ function dedupe(models: ModelInfo[]): ModelInfo[] { const seen = new Set(); const out: ModelInfo[] = []; for (const model of models) { - if (seen.has(model.label)) continue; - seen.add(model.label); + if (seen.has(model.id)) continue; + seen.add(model.id); out.push(model); } return out; diff --git a/src/providers/backboard/types.ts b/src/providers/backboard/types.ts index ac9aa81..03bb28d 100644 --- a/src/providers/backboard/types.ts +++ b/src/providers/backboard/types.ts @@ -211,6 +211,8 @@ export interface ProviderToolCall { * this unset. */ signature?: string; + /** Provider id that issued `signature`; prevents cross-provider replay. */ + signatureProvider?: string; } export interface ProviderUsage { @@ -242,6 +244,7 @@ export interface ModelInfo extends ThinkingModelMetadata { export interface ModelCatalogItem { name: string; + display_name?: string; provider: string; model_type: string; last_updated?: string | number | null; diff --git a/src/providers/byok/ByokClient.ts b/src/providers/byok/ByokClient.ts index 7497518..73f6100 100644 --- a/src/providers/byok/ByokClient.ts +++ b/src/providers/byok/ByokClient.ts @@ -31,7 +31,10 @@ import type { ByokMessage, ProviderAdapter, } from "./ByokTypes.ts"; -import { BYOK_ADAPTER_LIST, byokAdapterFor } from "./registry.ts"; +import { + BUILTIN_PROVIDER_REGISTRY, + type ProviderRegistry, +} from "./registry.ts"; /** Resolves the usable key for a provider, or null when none is enabled. */ export type ProviderKeyResolver = (provider: ByokProviderId) => string | null; @@ -113,6 +116,8 @@ export class ByokClient implements AgentClient { private readonly resolveKey: ProviderKeyResolver, private readonly serverLog?: ServerEventLog, private readonly conversationStore?: ByokConversationStore, + private readonly resolveRegistry: () => ProviderRegistry = () => + BUILTIN_PROVIDER_REGISTRY, ) {} async *runMessage( @@ -491,22 +496,26 @@ export class ByokClient implements AgentClient { provider: string, model: string, ): Promise { - const adapter = byokAdapterFor(provider); + const adapter = this.resolveRegistry().get(provider); + const supportsThinking = adapter + ? await adapter.supportsThinking(model, this.keyFor(adapter)) + : false; return { provider, model, - supports_thinking: adapter - ? await adapter.supportsThinking(model, this.keyFor(adapter)) - : false, + supports_thinking: supportsThinking, + ...(supportsThinking && adapter?.thinkingControls + ? { thinking_controls: adapter.thinkingControls(model) } + : {}), }; } /** Every model reachable with a currently enabled key. */ async listModels(options: RequestOptions = {}): Promise { const results = await Promise.all( - BYOK_ADAPTER_LIST.map(async (adapter) => { + this.resolveRegistry().adapters.map(async (adapter) => { const key = this.resolveKey(adapter.id); - if (!key) return []; + if (key === null) return []; try { return await adapter.listModels(key, options.signal); } catch { @@ -696,10 +705,10 @@ export class ByokClient implements AgentClient { } private adapterFor(provider: string | undefined): ProviderAdapter { - const adapter = provider ? byokAdapterFor(provider) : null; + const adapter = provider ? this.resolveRegistry().get(provider) : null; if (!adapter) { throw new Error( - `No API key provider handles "${provider ?? "unknown"}". Run /keys to add one, or sign in with Backboard for the full catalog.`, + `No API key provider handles "${provider ?? "unknown"}". Run /providers to add a custom provider, or sign in with Backboard for the full catalog.`, ); } return adapter; @@ -707,9 +716,9 @@ export class ByokClient implements AgentClient { private keyFor(adapter: ProviderAdapter): string { const key = this.resolveKey(adapter.id); - if (!key) { + if (key === null) { throw new Error( - `No enabled ${adapter.label} API key. Add or enable one with /keys.`, + `No enabled ${adapter.label} API key or credential. Add or enable one with /providers.`, ); } return key; diff --git a/src/providers/byok/ByokError.ts b/src/providers/byok/ByokError.ts index 8d68210..8fbb481 100644 --- a/src/providers/byok/ByokError.ts +++ b/src/providers/byok/ByokError.ts @@ -22,13 +22,13 @@ export class ByokError extends Error { } export function unexpectedStreamEndMessage(provider: ByokProviderId): string { - const labels: Record = { + const labels: Partial> = { anthropic: "Anthropic", openai: "OpenAI", google: "Google", openrouter: "OpenRouter", }; - const label = labels[provider]; + const label = labels[provider] ?? provider; return `${label} stream closed unexpectedly before a terminal event.`; } diff --git a/src/providers/byok/ByokTypes.ts b/src/providers/byok/ByokTypes.ts index cdae37d..5fe176a 100644 --- a/src/providers/byok/ByokTypes.ts +++ b/src/providers/byok/ByokTypes.ts @@ -76,6 +76,8 @@ export interface ProviderAdapter { readonly consoleUrl: string; /** Human hint for the expected key shape, e.g. "sk-ant-...". */ readonly keyHint: string; + /** False for keyless or independently authenticated endpoints. */ + readonly requiresKey?: boolean; /** Cheap local shape check run before spending a network round-trip. */ looksLikeKey(key: string): boolean; @@ -96,6 +98,11 @@ export interface ProviderAdapter { */ supportsThinking(model: string, key: string): boolean | Promise; + /** Provider-native thinking controls for custom provider IDs. */ + thinkingControls?( + model: string, + ): ModelCatalogItem["thinking_controls"] | undefined; + /** Streams one assistant turn as the same ProviderEvents Backboard yields. */ stream(request: ByokStreamRequest, key: string): AsyncIterable; } diff --git a/src/providers/byok/adapters/AnthropicAdapter.ts b/src/providers/byok/adapters/AnthropicAdapter.ts index 5afea50..0d4ef8f 100644 --- a/src/providers/byok/adapters/AnthropicAdapter.ts +++ b/src/providers/byok/adapters/AnthropicAdapter.ts @@ -1,3 +1,7 @@ +import { + type CustomModelDefinition, + joinProviderUrl, +} from "../../../config/providers.ts"; import { contextWindowFor } from "../../../core/context/ContextWindow.ts"; import type { OpenAITool } from "../../../core/tools/schema.ts"; import type { @@ -18,8 +22,8 @@ import { usesNativeAdaptiveThinking, } from "../thinking.ts"; import { planToolImages, renderToolResult } from "../toolImages.ts"; +import type { ConfigurableAdapterOptions } from "./ConfigurableAdapterTypes.ts"; -const API_BASE = "https://api.anthropic.com/v1"; const API_VERSION = "2023-06-01"; const DEFAULT_MAX_OUTPUT_TOKENS = 32_000; @@ -67,75 +71,182 @@ interface PendingToolBlock { json: string; } -export const anthropicAdapter: ProviderAdapter = { +export type AnthropicAdapterOptions = ConfigurableAdapterOptions; + +const ANTHROPIC_OPTIONS: AnthropicAdapterOptions = { id: "anthropic", label: "Anthropic", + baseUrl: "https://api.anthropic.com/v1", consoleUrl: "https://console.anthropic.com/settings/keys", keyHint: "sk-ant-...", + requiresKey: true, +}; - looksLikeKey(key) { - return /^sk-ant-[\w-]{20,}$/.test(key.trim()); - }, - - async validateKey(key, signal) { - // A models list is the cheapest authenticated GET: it costs no tokens - // and still fails closed on a bad key. - await getJson( - `${API_BASE}/models?limit=1`, - headers(key), - "anthropic", - signal, - ); - }, - - async listModels(key, signal) { - const response = await getJson( - `${API_BASE}/models?limit=1000`, - headers(key), - "anthropic", - signal, - ); - const models: ModelCatalogItem[] = []; - for (const model of response.data ?? []) { - if (!model.id) continue; - models.push({ - name: model.id, - provider: "anthropic", - model_type: "llm", - last_updated: model.created_at ?? null, - supports_thinking: true, - context_limit: contextWindowFor({ - provider: "anthropic", - model: model.id, - }), - }); - } - return models; - }, +export const anthropicAdapter: ProviderAdapter = + createAnthropicAdapter(ANTHROPIC_OPTIONS); - supportsThinking() { - return true; - }, +export function createAnthropicAdapter( + options: AnthropicAdapterOptions, +): ProviderAdapter { + return { + id: options.id, + label: options.label, + consoleUrl: options.consoleUrl ?? options.baseUrl, + keyHint: options.keyHint ?? "API key (optional for keyless endpoints)", + requiresKey: options.requiresKey ?? true, + + looksLikeKey(key) { + return options.id === "anthropic" + ? /^sk-ant-[\w-]{20,}$/.test(key.trim()) + : Boolean(key.trim()) || options.requiresKey === false; + }, + + async validateKey(key, signal) { + if (options.discoverModels === false) return; + // A models list is the cheapest authenticated GET: it costs no tokens + // and still fails closed on a bad key. + await getJson( + modelsUrlWithLimit(options, 1), + requestHeaders(key, options), + options.id, + signal, + ); + }, + + async listModels(key, signal) { + const response = + options.discoverModels === false + ? { data: [] } + : await getJson( + modelsUrlWithLimit(options, 1000), + requestHeaders(key, options), + options.id, + signal, + ); + const models = new Map(); + for (const model of response.data ?? []) { + if (!model.id) continue; + models.set(model.id.toLowerCase(), { + name: model.id, + display_name: model.display_name, + provider: options.id, + model_type: "llm", + last_updated: model.created_at ?? null, + supports_thinking: true, + thinking_controls: anthropicThinkingControls(model.id), + context_limit: contextWindowFor({ + provider: options.id, + model: model.id, + }), + }); + } + for (const model of options.models ?? []) { + if (model.enabled === false) { + models.delete(model.id.toLowerCase()); + continue; + } + models.set(model.id.toLowerCase(), configuredModel(options.id, model)); + } + return [...models.values()]; + }, + + supportsThinking(model) { + const configured = options.models?.find((entry) => entry.id === model); + return configured?.supportsThinking ?? true; + }, + + thinkingControls(model) { + const configured = options.models?.find((entry) => entry.id === model); + return configured?.supportsThinking === false + ? undefined + : anthropicThinkingControls(model); + }, + + stream(request, key) { + return streamAnthropic(request, key, options); + }, + }; +} - stream(request, key) { - return streamAnthropic(request, key); - }, -}; +function requestHeaders( + key: string, + options: AnthropicAdapterOptions, +): Record { + return { + ...(key.trim() ? { "x-api-key": key.trim() } : {}), + "anthropic-version": API_VERSION, + ...options.headers, + }; +} + +function modelsUrl(options: AnthropicAdapterOptions): string { + return joinProviderUrl(options.baseUrl, options.modelsPath ?? "models"); +} -function headers(key: string): Record { - return { "x-api-key": key.trim(), "anthropic-version": API_VERSION }; +function modelsUrlWithLimit( + options: AnthropicAdapterOptions, + limit: number, +): string { + const url = new URL(modelsUrl(options)); + url.searchParams.set("limit", String(limit)); + return url.toString(); +} + +function configuredModel( + provider: string, + model: CustomModelDefinition, +): ModelCatalogItem { + return { + name: model.id, + provider, + model_type: "llm", + ...(model.name ? { display_name: model.name } : {}), + ...(model.contextLimit ? { context_limit: model.contextLimit } : {}), + ...(model.maxOutputTokens + ? { max_output_tokens: model.maxOutputTokens } + : {}), + ...(typeof model.supportsThinking === "boolean" + ? { supports_thinking: model.supportsThinking } + : { supports_thinking: true }), + ...(model.supportsThinking === false + ? {} + : { thinking_controls: anthropicThinkingControls(model.id) }), + }; +} + +function anthropicThinkingControls( + model: string, +): NonNullable { + return { + supported: true, + ...(usesNativeAdaptiveThinking("anthropic", model) + ? { allowed_fields: ["effort"] } + : { + allowed_fields: ["budget_tokens"], + budget_policy: "anthropicLegacy" as const, + }), + defaults_only: false, + }; } async function* streamAnthropic( request: ByokStreamRequest, key: string, + options: AnthropicAdapterOptions = ANTHROPIC_OPTIONS, ): AsyncIterable { + const modelConfig = options.models?.find( + (entry) => entry.id === request.model, + ); const maxTokens = maxOutputTokensFor( request.model, - request.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS, + request.maxOutputTokens ?? + modelConfig?.maxOutputTokens ?? + DEFAULT_MAX_OUTPUT_TOKENS, ); const body: Record = { + ...options.extraArgs, + ...modelConfig?.extraArgs, model: request.model, max_tokens: maxTokens, // Blocks, not a bare string, so the prefix can carry a cache breakpoint. @@ -146,7 +257,10 @@ async function* streamAnthropic( cache_control: EPHEMERAL, }, ], - messages: toAnthropicMessages(request.messages), + messages: toAnthropicMessages( + request.messages, + modelConfig?.noImageSupport !== true, + ), stream: true, }; if (request.tools.length > 0) body.tools = toAnthropicTools(request.tools); @@ -162,10 +276,10 @@ async function* streamAnthropic( let messageStopped = false; const sseRequest: Parameters[0] = { - url: `${API_BASE}/messages`, - headers: headers(key), + url: joinProviderUrl(options.baseUrl, "messages"), + headers: requestHeaders(key, options), body, - provider: "anthropic", + provider: options.id, }; if (request.signal) sseRequest.signal = request.signal; @@ -176,7 +290,7 @@ async function* streamAnthropic( const error = event.error as { message?: string } | undefined; yield { kind: "failed", - error: error?.message ?? "Anthropic returned an error event", + error: error?.message ?? `${options.label} returned an error event`, }; return; } @@ -264,7 +378,7 @@ async function* streamAnthropic( if (!messageStopped) { yield { kind: "failed", - error: unexpectedStreamEndMessage("anthropic"), + error: unexpectedStreamEndMessage(options.label), retryable: true, }; return; @@ -282,8 +396,11 @@ async function* streamAnthropic( totalTokens: promptTokens + outputTokens, cachedTokens, cacheWriteTokens, - provider: "anthropic", + provider: options.id, model: request.model, + ...(modelConfig?.contextLimit + ? { contextLimit: modelConfig.contextLimit } + : {}), }; if (truncatedCall) { @@ -413,8 +530,11 @@ function toAnthropicTools(tools: readonly OpenAITool[]): unknown[] { * transcript at full input price each time. With it, each leg writes only the * delta past the previous breakpoint and reads everything before it. */ -function toAnthropicMessages(messages: readonly ByokMessage[]): unknown[] { - const rendered = renderMessages(messages); +function toAnthropicMessages( + messages: readonly ByokMessage[], + acceptsImages = true, +): unknown[] { + const rendered = renderMessages(messages, acceptsImages); const last = rendered.at(-1); if (last) { const lastBlock = last.content.at(-1); @@ -428,12 +548,20 @@ interface RenderedMessage { content: Array>; } -function renderMessages(messages: readonly ByokMessage[]): RenderedMessage[] { +function renderMessages( + messages: readonly ByokMessage[], + acceptsImages: boolean, +): RenderedMessage[] { const out: RenderedMessage[] = []; - const imagePlan = planToolImages(messages); + const imagePlan = acceptsImages + ? planToolImages(messages) + : new Set(); for (const [messageIndex, message] of messages.entries()) { if (message.role === "user") { - out.push({ role: "user", content: userContent(message) }); + out.push({ + role: "user", + content: userContent(message, acceptsImages), + }); continue; } if (message.role === "assistant") { @@ -490,10 +618,11 @@ function renderMessages(messages: readonly ByokMessage[]): RenderedMessage[] { function userContent( message: Extract, + acceptsImages: boolean, ): Array> { const content: Array> = []; for (const attachment of message.attachments ?? []) { - if (attachment.base64) { + if (attachment.base64 && acceptsImages) { content.push({ type: "image", source: { @@ -502,6 +631,11 @@ function userContent( data: attachment.base64, }, }); + } else if (attachment.base64) { + content.push({ + type: "text", + text: `[Image attachment omitted because this model is configured without image support: ${attachment.path}]`, + }); } else if (attachment.text) { content.push({ type: "text", diff --git a/src/providers/byok/adapters/ConfigurableAdapterTypes.ts b/src/providers/byok/adapters/ConfigurableAdapterTypes.ts new file mode 100644 index 0000000..cbd3c8e --- /dev/null +++ b/src/providers/byok/adapters/ConfigurableAdapterTypes.ts @@ -0,0 +1,15 @@ +import type { CustomModelDefinition } from "../../../config/providers.ts"; + +export interface ConfigurableAdapterOptions { + id: string; + label: string; + baseUrl: string; + consoleUrl?: string; + keyHint?: string; + requiresKey?: boolean; + headers?: Record; + extraArgs?: Record; + modelsPath?: string; + discoverModels?: boolean; + models?: readonly CustomModelDefinition[]; +} diff --git a/src/providers/byok/adapters/CustomProviderAdapter.ts b/src/providers/byok/adapters/CustomProviderAdapter.ts new file mode 100644 index 0000000..ee68192 --- /dev/null +++ b/src/providers/byok/adapters/CustomProviderAdapter.ts @@ -0,0 +1,81 @@ +import { + type CustomProviderDefinition, + isCredentialHeader, + isSecureProviderUrl, + resolveEnvReferences, + resolveJsonEnvReferences, + resolveProviderHeaders, +} from "../../../config/providers.ts"; +import type { JsonObject } from "../../../utils/JsonTypes.ts"; +import type { ProviderAdapter } from "../ByokTypes.ts"; +import { createAnthropicAdapter } from "./AnthropicAdapter.ts"; +import { createOpenAIChatAdapter } from "./OpenAIAdapter.ts"; +import { createOpenAIResponsesAdapter } from "./OpenAIResponsesAdapter.ts"; + +export function createCustomProviderAdapter( + definition: CustomProviderDefinition, +): ProviderAdapter { + const baseUrl = resolveEnvReferences( + definition.baseUrl, + `${definition.name} base URL`, + ); + const headers = resolveProviderHeaders(definition); + const modelsPath = definition.modelsPath + ? resolveEnvReferences( + definition.modelsPath, + `${definition.name} models path`, + ).trim() + : undefined; + const usesCredentials = + (definition.auth ?? { type: "apiKey" as const }).type !== "none" || + Object.keys(headers).some(isCredentialHeader); + if (usesCredentials && !isSecureProviderUrl(baseUrl)) { + throw new Error( + `${definition.name} must use HTTPS when credentials are configured.`, + ); + } + if ( + usesCredentials && + modelsPath && + /^https?:\/\//i.test(modelsPath) && + !isSecureProviderUrl(modelsPath) + ) { + throw new Error( + `${definition.name} models endpoint must use HTTPS when credentials are configured.`, + ); + } + const common = { + id: definition.id, + label: definition.name, + baseUrl, + requiresKey: (definition.auth ?? { type: "apiKey" }).type !== "none", + headers, + extraArgs: definition.extraArgs + ? (resolveJsonEnvReferences( + definition.extraArgs, + `${definition.name} extraArgs`, + ) as JsonObject) + : undefined, + modelsPath, + discoverModels: definition.discoverModels, + models: definition.models?.map((model) => ({ + ...model, + ...(model.extraArgs + ? { + extraArgs: resolveJsonEnvReferences( + model.extraArgs, + `${definition.name} model ${model.id} extraArgs`, + ) as JsonObject, + } + : {}), + })), + }; + switch (definition.protocol) { + case "openai-chat": + return createOpenAIChatAdapter(common); + case "openai-responses": + return createOpenAIResponsesAdapter(common); + case "anthropic-messages": + return createAnthropicAdapter(common); + } +} diff --git a/src/providers/byok/adapters/GoogleAdapter.ts b/src/providers/byok/adapters/GoogleAdapter.ts index 4973462..d0ed2c6 100644 --- a/src/providers/byok/adapters/GoogleAdapter.ts +++ b/src/providers/byok/adapters/GoogleAdapter.ts @@ -208,7 +208,10 @@ async function* streamGoogle( // the next request if the call comes back unsigned, so the token // has to survive the round-trip through the transcript. ...(entry.thoughtSignature - ? { signature: entry.thoughtSignature } + ? { + signature: entry.thoughtSignature, + signatureProvider: "google", + } : {}), }; calls.push(call); @@ -354,7 +357,10 @@ export function renderGoogleContents( args: call.input ?? {}, ...(isProviderCallId(call.id) ? { id: call.id } : {}), }, - ...(call.signature ? { thoughtSignature: call.signature } : {}), + ...(call.signature && + (!call.signatureProvider || call.signatureProvider === "google") + ? { thoughtSignature: call.signature } + : {}), }); } if (parts.length > 0) out.push({ role: "model", parts }); diff --git a/src/providers/byok/adapters/OpenAIAdapter.ts b/src/providers/byok/adapters/OpenAIAdapter.ts index 2366b00..175476b 100644 --- a/src/providers/byok/adapters/OpenAIAdapter.ts +++ b/src/providers/byok/adapters/OpenAIAdapter.ts @@ -1,3 +1,4 @@ +import { joinProviderUrl } from "../../../config/providers.ts"; import { contextWindowFor } from "../../../core/context/ContextWindow.ts"; import type { ModelCatalogItem, @@ -11,6 +12,7 @@ import type { ProviderAdapter, } from "../ByokTypes.ts"; import { getJson, postSseJson } from "../httpStream.ts"; +import { compatibleOpenAITools } from "../openAIToolSchemas.ts"; import { thinkingEffort } from "../thinking.ts"; import { imageDataUri, @@ -18,16 +20,18 @@ import { renderToolResult, TOOL_IMAGE_NOTE, } from "../toolImages.ts"; +import type { ConfigurableAdapterOptions } from "./ConfigurableAdapterTypes.ts"; import { OPENAI_DISABLED_TOOL_REASONING_PATTERN, OPENAI_NON_CHAT_MODEL_PATTERNS, } from "./OpenAIAdapter.constants.ts"; - -const API_BASE = "https://api.openai.com/v1"; - -interface OpenAIModelsResponse { - data?: Array<{ id?: string; created?: number }>; -} +import { + configurableModelsUrl, + configuredOpenAIModel, + effortThinkingControls, + type OpenAICompatibleModelsResponse, + openAICompatibleHeaders, +} from "./OpenAICompatibleShared.ts"; /** A tool call assembled across `delta.tool_calls` chunks, keyed by index. */ interface PendingToolCall { @@ -35,67 +39,114 @@ interface PendingToolCall { name: string; args: string; announced: boolean; + signature?: string; } -export const openaiAdapter: ProviderAdapter = { +export type OpenAIChatAdapterOptions = ConfigurableAdapterOptions; + +const OPENAI_OPTIONS: OpenAIChatAdapterOptions = { id: "openai", label: "OpenAI", + baseUrl: "https://api.openai.com/v1", consoleUrl: "https://platform.openai.com/api-keys", keyHint: "sk-...", + requiresKey: true, +}; - looksLikeKey(key) { - return /^sk-[\w-]{20,}$/.test(key.trim()); - }, +export const openaiAdapter: ProviderAdapter = + createOpenAIChatAdapter(OPENAI_OPTIONS); - async validateKey(key, signal) { - await getJson( - `${API_BASE}/models`, - headers(key), - "openai", - signal, - ); - }, +export function createOpenAIChatAdapter( + options: OpenAIChatAdapterOptions, +): ProviderAdapter { + return { + id: options.id, + label: options.label, + consoleUrl: options.consoleUrl ?? options.baseUrl, + keyHint: options.keyHint ?? "API key (optional for keyless endpoints)", + requiresKey: options.requiresKey ?? true, - async listModels(key, signal) { - const response = await getJson( - `${API_BASE}/models`, - headers(key), - "openai", - signal, - ); - const models: ModelCatalogItem[] = []; - for (const model of response.data ?? []) { - if (!model.id || !isChatModel(model.id)) continue; - models.push({ - name: model.id, - provider: "openai", - model_type: "llm", - // The models list reports epoch seconds; the catalog sorter wants ms. - last_updated: - typeof model.created === "number" ? model.created * 1000 : null, - context_limit: contextWindowFor({ - provider: "openai", - model: model.id, - }), - }); - } - return models; - }, + looksLikeKey(key) { + return options.id === "openai" + ? /^sk-[\w-]{20,}$/.test(key.trim()) + : Boolean(key.trim()) || options.requiresKey === false; + }, - supportsThinking() { - return true; - }, + async validateKey(key, signal) { + if (options.discoverModels === false) return; + await getJson( + configurableModelsUrl(options), + openAICompatibleHeaders(key, options), + options.id, + signal, + ); + }, - stream(request, key) { - return streamOpenAI(request, key); - }, -}; + async listModels(key, signal) { + const response = + options.discoverModels === false + ? { data: [] } + : await getJson( + configurableModelsUrl(options), + openAICompatibleHeaders(key, options), + options.id, + signal, + ); + const models = new Map(); + for (const model of response.data ?? response.models ?? []) { + const id = model.id ?? model.name; + if (!id || !isOpenAIChatModel(id)) continue; + models.set(id.toLowerCase(), { + name: id, + provider: options.id, + model_type: "llm", + ...(options.id === "openai" + ? {} + : { + supports_thinking: true, + thinking_controls: effortThinkingControls(), + }), + // The models list reports epoch seconds; the catalog sorter wants ms. + last_updated: + typeof model.created === "number" ? model.created * 1000 : null, + context_limit: contextWindowFor({ + provider: options.id, + model: id, + }), + }); + } + for (const model of options.models ?? []) { + if (model.enabled === false) { + models.delete(model.id.toLowerCase()); + continue; + } + models.set( + model.id.toLowerCase(), + configuredOpenAIModel(options.id, model), + ); + } + return [...models.values()]; + }, + + supportsThinking(model) { + const configured = options.models?.find((entry) => entry.id === model); + return configured?.supportsThinking ?? true; + }, + + thinkingControls(model) { + const configured = options.models?.find((entry) => entry.id === model); + return configured?.supportsThinking === false + ? undefined + : effortThinkingControls(); + }, -function headers(key: string): Record { - return { Authorization: `Bearer ${key.trim()}` }; + stream(request, key) { + return streamOpenAI(request, key, options); + }, + }; } -function isChatModel(id: string): boolean { +export function isOpenAIChatModel(id: string): boolean { const normalized = id.toLowerCase(); return !OPENAI_NON_CHAT_MODEL_PATTERNS.some((pattern) => normalized.includes(pattern), @@ -114,23 +165,50 @@ export function openAIModelAcceptsImages(model: string): boolean { async function* streamOpenAI( request: ByokStreamRequest, key: string, + options: OpenAIChatAdapterOptions = OPENAI_OPTIONS, ): AsyncIterable { + const modelConfig = options.models?.find( + (entry) => entry.id === request.model, + ); const body: Record = { + ...options.extraArgs, + ...modelConfig?.extraArgs, model: request.model, - messages: toOpenAIMessages(request), + messages: toOpenAIMessages( + request, + modelConfig?.noImageSupport === true ? false : undefined, + options.id, + ), stream: true, - // Without this the final chunk carries no usage at all. - stream_options: { include_usage: true }, }; + // Official OpenAI needs this to emit usage. Some compatible servers reject + // the option, so custom providers opt in through extraArgs instead. + if (options.id === "openai") { + body.stream_options = { include_usage: true }; + } if (request.tools.length > 0) { - body.tools = request.tools; + body.tools = compatibleOpenAITools(request.tools); body.tool_choice = "auto"; } // OpenAI caches automatically on the prompt prefix (>1024 tokens), but only // when a request lands on a machine that already holds it. The cache key // pins one conversation to one shard, which is what turns an incidental hit // rate into a reliable one across a long tool loop. - if (request.cacheKey) body.prompt_cache_key = request.cacheKey; + if (request.cacheKey && options.id === "openai") { + body.prompt_cache_key = request.cacheKey; + } + const maxOutputTokens = + request.maxOutputTokens ?? modelConfig?.maxOutputTokens; + if (maxOutputTokens) { + if ( + modelConfig?.supportsThinking === true || + /^(?:gpt-5|o[1-9])(?:$|[-.])/i.test(request.model) + ) { + body.max_completion_tokens = maxOutputTokens; + } else { + body.max_tokens = maxOutputTokens; + } + } const effort = thinkingEffort(request.thinking); // Only sent when thinking was actually requested: non-reasoning models // reject the parameter outright. GPT-5.4 through GPT-5.6 also reject enabled @@ -155,10 +233,10 @@ async function* streamOpenAI( }; const sseRequest: Parameters[0] = { - url: `${API_BASE}/chat/completions`, - headers: headers(key), + url: joinProviderUrl(options.baseUrl, "chat/completions"), + headers: openAICompatibleHeaders(key, options), body, - provider: "openai", + provider: options.id, }; if (request.signal) sseRequest.signal = request.signal; @@ -167,7 +245,7 @@ async function* streamOpenAI( const error = chunk.error as { message?: string }; yield { kind: "failed", - error: error.message ?? "OpenAI returned an error event", + error: error.message ?? `${options.label} returned an error event`, }; return; } @@ -183,6 +261,20 @@ async function* streamOpenAI( index?: number; id?: string; function?: { name?: string; arguments?: string }; + extra_content?: { + google?: { thought_signature?: string }; + }; + }>; + }; + message?: { + content?: string | null; + tool_calls?: Array<{ + index?: number; + id?: string; + function?: { name?: string; arguments?: string }; + extra_content?: { + google?: { thought_signature?: string }; + }; }>; }; finish_reason?: string | null; @@ -191,12 +283,12 @@ async function* streamOpenAI( if (!choice) continue; if (choice.finish_reason) finishReason = choice.finish_reason; - const delta = choice.delta; + const delta = choice.delta ?? choice.message; if (delta?.content) { yield { kind: "assistant_delta", text: delta.content }; } - for (const partial of delta?.tool_calls ?? []) { - const index = partial.index ?? 0; + for (const [position, partial] of (delta?.tool_calls ?? []).entries()) { + const index = partial.index ?? position; const existing = pending.get(index) ?? { id: partial.id ?? `call_${index}`, name: "", @@ -208,6 +300,8 @@ async function* streamOpenAI( if (partial.function?.arguments) { existing.args += partial.function.arguments; } + const signature = partial.extra_content?.google?.thought_signature; + if (signature) existing.signature = signature; pending.set(index, existing); // Announce as soon as the name is known so the row renders while the // arguments are still streaming. @@ -221,7 +315,7 @@ async function* streamOpenAI( if (finishReason === null) { yield { kind: "failed", - error: unexpectedStreamEndMessage("openai"), + error: unexpectedStreamEndMessage(options.label), retryable: true, }; return; @@ -252,6 +346,12 @@ async function* streamOpenAI( id: partial.id, name: partial.name, input: parseArguments(partial.args), + ...(partial.signature + ? { + signature: partial.signature, + signatureProvider: options.id, + } + : {}), }; calls.push(call); yield { kind: "tool_ready", call }; @@ -260,8 +360,11 @@ async function* streamOpenAI( const finalUsage = { ...usage, - provider: "openai", + provider: options.id, model: request.model, + ...(modelConfig?.contextLimit + ? { contextLimit: modelConfig.contextLimit } + : {}), }; // Some models report finish_reason "stop" alongside emitted tool calls; @@ -311,9 +414,13 @@ function parseArguments(args: string): unknown { } } -export function toOpenAIMessages(request: ByokStreamRequest): unknown[] { +export function toOpenAIMessages( + request: ByokStreamRequest, + imageSupport?: boolean, + providerId?: string, +): unknown[] { const out: unknown[] = [{ role: "system", content: request.systemPrompt }]; - const acceptsImages = openAIModelAcceptsImages(request.model); + const acceptsImages = imageSupport ?? openAIModelAcceptsImages(request.model); const imagePlan = acceptsImages ? planToolImages(request.messages) : new Set(); @@ -338,6 +445,15 @@ export function toOpenAIMessages(request: ByokStreamRequest): unknown[] { name: call.name, arguments: JSON.stringify(call.input ?? {}), }, + ...(call.signature && + call.signatureProvider && + call.signatureProvider === providerId + ? { + extra_content: { + google: { thought_signature: call.signature }, + }, + } + : {}), })); } out.push(entry); diff --git a/src/providers/byok/adapters/OpenAICompatibleShared.ts b/src/providers/byok/adapters/OpenAICompatibleShared.ts new file mode 100644 index 0000000..f35f9c8 --- /dev/null +++ b/src/providers/byok/adapters/OpenAICompatibleShared.ts @@ -0,0 +1,57 @@ +import type { CustomModelDefinition } from "../../../config/providers.ts"; +import { joinProviderUrl } from "../../../config/providers.ts"; +import type { ModelCatalogItem } from "../../backboard/types.ts"; +import type { ConfigurableAdapterOptions } from "./ConfigurableAdapterTypes.ts"; + +export interface OpenAICompatibleModelsResponse { + data?: Array<{ id?: string; name?: string; created?: number }>; + models?: Array<{ id?: string; name?: string; created?: number }>; +} + +export function openAICompatibleHeaders( + key: string, + options: ConfigurableAdapterOptions, +): Record { + return { + ...(key.trim() ? { Authorization: `Bearer ${key.trim()}` } : {}), + ...options.headers, + }; +} + +export function configurableModelsUrl( + options: ConfigurableAdapterOptions, +): string { + return joinProviderUrl(options.baseUrl, options.modelsPath ?? "models"); +} + +export function configuredOpenAIModel( + provider: string, + model: CustomModelDefinition, +): ModelCatalogItem { + return { + name: model.id, + provider, + model_type: "llm", + ...(model.name ? { display_name: model.name } : {}), + ...(model.contextLimit ? { context_limit: model.contextLimit } : {}), + ...(model.maxOutputTokens + ? { max_output_tokens: model.maxOutputTokens } + : {}), + ...(typeof model.supportsThinking === "boolean" + ? { supports_thinking: model.supportsThinking } + : { supports_thinking: true }), + ...(model.supportsThinking === false + ? {} + : { thinking_controls: effortThinkingControls() }), + }; +} + +export function effortThinkingControls(): NonNullable< + ModelCatalogItem["thinking_controls"] +> { + return { + supported: true, + allowed_fields: ["effort"], + defaults_only: false, + }; +} diff --git a/src/providers/byok/adapters/OpenAIResponsesAdapter.ts b/src/providers/byok/adapters/OpenAIResponsesAdapter.ts new file mode 100644 index 0000000..60adb7e --- /dev/null +++ b/src/providers/byok/adapters/OpenAIResponsesAdapter.ts @@ -0,0 +1,542 @@ +import { joinProviderUrl } from "../../../config/providers.ts"; +import { contextWindowFor } from "../../../core/context/ContextWindow.ts"; +import type { OpenAITool } from "../../../core/tools/schema.ts"; +import type { + ModelCatalogItem, + ProviderEvent, + ProviderToolCall, +} from "../../backboard/types.ts"; +import { unexpectedStreamEndMessage } from "../ByokError.ts"; +import type { + ByokMessage, + ByokStreamRequest, + ProviderAdapter, +} from "../ByokTypes.ts"; +import { getJson, postSseJson } from "../httpStream.ts"; +import { compatibleOpenAITools } from "../openAIToolSchemas.ts"; +import { thinkingEffort } from "../thinking.ts"; +import { + planToolImages, + renderToolResult, + TOOL_IMAGE_NOTE, +} from "../toolImages.ts"; +import type { ConfigurableAdapterOptions } from "./ConfigurableAdapterTypes.ts"; +import { + configurableModelsUrl, + configuredOpenAIModel, + effortThinkingControls, + type OpenAICompatibleModelsResponse, + openAICompatibleHeaders, +} from "./OpenAICompatibleShared.ts"; + +export type OpenAIResponsesAdapterOptions = ConfigurableAdapterOptions; + +interface PendingCall { + id: string; + name: string; + args: string; + announced: boolean; +} + +export function createOpenAIResponsesAdapter( + options: OpenAIResponsesAdapterOptions, +): ProviderAdapter { + return { + id: options.id, + label: options.label, + consoleUrl: options.consoleUrl ?? options.baseUrl, + keyHint: options.keyHint ?? "API key (optional for keyless endpoints)", + requiresKey: options.requiresKey ?? true, + looksLikeKey(key) { + return Boolean(key.trim()) || options.requiresKey === false; + }, + async validateKey(key, signal) { + if (options.discoverModels === false) return; + await getJson( + configurableModelsUrl(options), + openAICompatibleHeaders(key, options), + options.id, + signal, + ); + }, + async listModels(key, signal) { + const response = + options.discoverModels === false + ? { data: [] } + : await getJson( + configurableModelsUrl(options), + openAICompatibleHeaders(key, options), + options.id, + signal, + ); + const models = new Map(); + for (const model of response.data ?? response.models ?? []) { + const id = model.id ?? model.name; + if (!id || !isResponsesModel(id)) continue; + models.set(id.toLowerCase(), { + name: id, + provider: options.id, + model_type: "llm", + last_updated: + typeof model.created === "number" ? model.created * 1000 : null, + context_limit: contextWindowFor({ + provider: options.id, + model: id, + }), + supports_thinking: true, + thinking_controls: effortThinkingControls(), + }); + } + for (const model of options.models ?? []) { + if (model.enabled === false) { + models.delete(model.id.toLowerCase()); + continue; + } + models.set( + model.id.toLowerCase(), + configuredOpenAIModel(options.id, model), + ); + } + return [...models.values()]; + }, + supportsThinking(model) { + const configured = options.models?.find((entry) => entry.id === model); + return configured?.supportsThinking ?? true; + }, + thinkingControls(model) { + const configured = options.models?.find((entry) => entry.id === model); + return configured?.supportsThinking === false + ? undefined + : effortThinkingControls(); + }, + stream(request, key) { + return streamResponses(request, key, options); + }, + }; +} + +async function* streamResponses( + request: ByokStreamRequest, + key: string, + options: OpenAIResponsesAdapterOptions, +): AsyncIterable { + const modelConfig = options.models?.find( + (entry) => entry.id === request.model, + ); + const body: Record = { + ...options.extraArgs, + ...modelConfig?.extraArgs, + model: request.model, + instructions: request.systemPrompt, + input: toResponsesInput( + request.messages, + modelConfig?.noImageSupport !== true, + options.id, + ), + stream: true, + }; + if (request.tools.length > 0) { + body.tools = toResponsesTools(request.tools); + body.tool_choice = "auto"; + body.parallel_tool_calls = true; + } + const effort = thinkingEffort(request.thinking); + if (effort) body.reasoning = { effort }; + const maxOutputTokens = + request.maxOutputTokens ?? modelConfig?.maxOutputTokens; + if (maxOutputTokens) body.max_output_tokens = maxOutputTokens; + if (request.cacheKey) body.prompt_cache_key = request.cacheKey; + + const pending = new Map(); + const itemToCall = new Map(); + const metadata: unknown[] = []; + let terminal = false; + let usage = emptyUsage(); + + const sseRequest: Parameters[0] = { + url: joinProviderUrl(options.baseUrl, "responses"), + headers: openAICompatibleHeaders(key, options), + body, + provider: options.id, + }; + if (request.signal) sseRequest.signal = request.signal; + + for await (const event of postSseJson(sseRequest)) { + const type = typeof event.type === "string" ? event.type : ""; + if (!type && (event.object === "response" || Array.isArray(event.output))) { + terminal = true; + usage = readUsage(objectOf(event.usage)); + for (const raw of arrayOf(event.output)) { + const item = objectOf(raw); + if (item?.type === "message") { + for (const content of arrayOf(item.content)) { + const block = objectOf(content); + if (block?.type === "output_text" && stringOf(block.text)) { + yield { + kind: "assistant_delta", + text: stringOf(block.text) as string, + }; + } + if (block?.type === "refusal" && stringOf(block.refusal)) { + yield { + kind: "assistant_delta", + text: stringOf(block.refusal) as string, + }; + } + } + } + if (item?.type === "reasoning") metadata.push(item); + if (item?.type !== "function_call") continue; + const id = stringOf(item.call_id) ?? stringOf(item.id) ?? "call_0"; + pending.set(id, { + id, + name: stringOf(item.name) ?? "", + args: stringOf(item.arguments) ?? "", + announced: false, + }); + } + continue; + } + if (type === "error" || type === "response.failed") { + terminal = true; + const error = (event.error ?? + (event.response as Record | undefined)?.error) as + | { message?: string } + | undefined; + yield { + kind: "failed", + error: error?.message ?? `${options.label} returned a failed response`, + }; + return; + } + if (type === "response.output_text.delta") { + const delta = typeof event.delta === "string" ? event.delta : ""; + if (delta) yield { kind: "assistant_delta", text: delta }; + continue; + } + if (type === "response.refusal.delta") { + const delta = typeof event.delta === "string" ? event.delta : ""; + if (delta) yield { kind: "assistant_delta", text: delta }; + continue; + } + if (type === "response.output_item.added") { + const item = objectOf(event.item); + if (item?.type !== "function_call") continue; + const id = stringOf(item.call_id) ?? stringOf(item.id) ?? "call_0"; + const itemId = stringOf(item.id); + if (itemId) itemToCall.set(itemId, id); + const call: PendingCall = { + id, + name: stringOf(item.name) ?? "", + args: stringOf(item.arguments) ?? "", + announced: false, + }; + pending.set(id, call); + if (call.name) { + call.announced = true; + yield { kind: "tool_started", id: call.id, name: call.name }; + } + continue; + } + if (type === "response.function_call_arguments.delta") { + const eventId = + stringOf(event.call_id) ?? stringOf(event.item_id) ?? "call_0"; + const id = itemToCall.get(eventId) ?? eventId; + const call = pending.get(id) ?? { + id, + name: stringOf(event.name) ?? "", + args: "", + announced: false, + }; + call.args += stringOf(event.delta) ?? ""; + pending.set(id, call); + if (!call.announced && call.name) { + call.announced = true; + yield { kind: "tool_started", id: call.id, name: call.name }; + } + continue; + } + if (type === "response.output_item.done") { + const item = objectOf(event.item); + if (!item) continue; + if (item.type === "reasoning") metadata.push(item); + if (item.type !== "function_call") continue; + const itemId = stringOf(item.id); + const id = + stringOf(item.call_id) ?? + (itemId ? itemToCall.get(itemId) : null) ?? + itemId ?? + "call_0"; + const call = pending.get(id) ?? { + id, + name: "", + args: "", + announced: false, + }; + call.name = stringOf(item.name) ?? call.name; + call.args = stringOf(item.arguments) ?? call.args; + pending.set(id, call); + continue; + } + if (type === "response.completed" || type === "response.incomplete") { + terminal = true; + const response = objectOf(event.response); + usage = readUsage(objectOf(response?.usage)); + for (const raw of arrayOf(response?.output)) { + const item = objectOf(raw); + if (item?.type !== "function_call") continue; + const id = stringOf(item.call_id) ?? stringOf(item.id) ?? "call_0"; + const call = pending.get(id) ?? { + id, + name: "", + args: "", + announced: false, + }; + call.name = stringOf(item.name) ?? call.name; + call.args = stringOf(item.arguments) ?? call.args; + pending.set(id, call); + } + if (type === "response.incomplete" && pending.size > 0) { + yield { + kind: "failed", + error: + "The model hit its output limit while writing a tool call, so the call was incomplete.", + retryable: true, + }; + return; + } + } + } + + if (!terminal) { + yield { + kind: "failed", + error: unexpectedStreamEndMessage(options.label), + retryable: true, + }; + return; + } + + const calls: ProviderToolCall[] = []; + for (const call of pending.values()) { + if (!call.name) continue; + const input = parseArguments(call.args); + if (input === null) { + yield { + kind: "failed", + error: `The provider returned invalid JSON arguments for tool ${call.name}.`, + }; + return; + } + const ready: ProviderToolCall = { + id: call.id, + name: call.name, + input, + }; + calls.push(ready); + } + for (const call of calls) { + yield { kind: "tool_ready", call }; + } + const finalUsage = { + ...usage, + provider: options.id, + model: request.model, + ...(modelConfig?.contextLimit + ? { contextLimit: modelConfig.contextLimit } + : {}), + }; + if (calls.length > 0) { + yield { kind: "usage", usage: finalUsage }; + yield { + kind: "requires_action", + runId: null, + calls, + ...(metadata.length > 0 + ? { + providerMetadata: JSON.stringify({ + provider: options.id, + items: metadata, + }), + } + : {}), + }; + return; + } + yield { kind: "completed", usage: finalUsage }; +} + +function toResponsesInput( + messages: readonly ByokMessage[], + acceptsImages: boolean, + providerId: string, +): unknown[] { + const input: unknown[] = []; + const imagePlan = acceptsImages + ? planToolImages(messages) + : new Set(); + for (const [messageIndex, message] of messages.entries()) { + if (message.role === "user") { + const content: unknown[] = [ + { type: "input_text", text: message.content }, + ]; + for (const attachment of message.attachments ?? []) { + if ( + acceptsImages && + attachment.base64 && + attachment.mediaType.startsWith("image/") + ) { + content.push({ + type: "input_image", + image_url: `data:${attachment.mediaType};base64,${attachment.base64}`, + }); + } else if (attachment.base64) { + content.push({ + type: "input_text", + text: `\n\n[Image attachment omitted because this model is configured without image support: ${attachment.path}]`, + }); + } else if (attachment.text) { + content.push({ + type: "input_text", + text: `\n\n\n${attachment.text}\n`, + }); + } + } + input.push({ role: "user", content }); + continue; + } + if (message.role === "assistant") { + const metadata = parseMetadata(message.providerMetadata, providerId); + input.push(...metadata); + if (message.content) { + input.push({ + role: "assistant", + content: [{ type: "output_text", text: message.content }], + }); + } + for (const call of message.toolCalls) { + input.push({ + type: "function_call", + call_id: call.id, + name: call.name, + arguments: JSON.stringify(call.input ?? {}), + }); + } + continue; + } + const images: Array<{ mediaType: string; base64: string }> = []; + for (const [resultIndex, result] of message.results.entries()) { + const rendered = renderToolResult( + result.output, + imagePlan.has(`${messageIndex}:${resultIndex}`), + undefined, + acceptsImages ? "older screenshot" : "model does not accept images", + ); + input.push({ + type: "function_call_output", + call_id: result.id, + output: rendered.text || "(no output)", + }); + images.push(...rendered.images); + } + if (images.length > 0) { + input.push({ + role: "user", + content: [ + { type: "input_text", text: TOOL_IMAGE_NOTE }, + ...images.map((image) => ({ + type: "input_image", + image_url: `data:${image.mediaType};base64,${image.base64}`, + })), + ], + }); + } + } + return input; +} + +function toResponsesTools(tools: readonly OpenAITool[]): unknown[] { + return compatibleOpenAITools(tools).map((tool) => ({ + type: "function", + name: tool.function.name, + description: tool.function.description, + parameters: tool.function.parameters, + })); +} + +function parseMetadata( + value: string | undefined, + providerId: string, +): unknown[] { + if (!value) return []; + try { + const parsed = JSON.parse(value); + if ( + typeof parsed === "object" && + parsed !== null && + (parsed as { provider?: unknown }).provider === providerId && + Array.isArray((parsed as { items?: unknown }).items) + ) { + return (parsed as { items: unknown[] }).items; + } + return []; + } catch { + return []; + } +} + +function parseArguments(value: string): unknown | null { + if (!value.trim()) return {}; + try { + return JSON.parse(value); + } catch { + return null; + } +} + +function isResponsesModel(id: string): boolean { + return !/(?:embedding|whisper|tts|dall-e|moderation|audio|realtime|image|transcribe|search)/i.test( + id, + ); +} + +function readUsage(value: Record | null): { + inputTokens: number; + outputTokens: number; + totalTokens: number; + cachedTokens: number; +} { + const inputTokens = numberOf(value?.input_tokens); + const outputTokens = numberOf(value?.output_tokens); + const cachedTokens = numberOf( + objectOf(value?.input_tokens_details)?.cached_tokens, + ); + return { + inputTokens, + outputTokens, + totalTokens: numberOf(value?.total_tokens) || inputTokens + outputTokens, + cachedTokens, + }; +} + +function emptyUsage(): ReturnType { + return { inputTokens: 0, outputTokens: 0, totalTokens: 0, cachedTokens: 0 }; +} + +function objectOf(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +function stringOf(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +function numberOf(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function arrayOf(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} diff --git a/src/providers/byok/httpStream.ts b/src/providers/byok/httpStream.ts index de58c6a..e5c42e0 100644 --- a/src/providers/byok/httpStream.ts +++ b/src/providers/byok/httpStream.ts @@ -71,6 +71,14 @@ export async function* postSseJson( null, ); } + const contentType = res.headers.get("content-type")?.toLowerCase() ?? ""; + if (contentType.includes("application/json")) { + const payload = (await res.json()) as unknown; + if (payload && typeof payload === "object" && !Array.isArray(payload)) { + yield payload as Record; + } + return; + } for await (const frame of readSseFrames(res.body)) { const data = sseDataPayload(frame); @@ -111,6 +119,7 @@ function providerLabel(provider: ByokProviderId): string { case "openrouter": return "OpenRouter"; } + return provider; } export function safeJson(text: string): unknown { diff --git a/src/providers/byok/openAIToolSchemas.ts b/src/providers/byok/openAIToolSchemas.ts new file mode 100644 index 0000000..5b2dd89 --- /dev/null +++ b/src/providers/byok/openAIToolSchemas.ts @@ -0,0 +1,110 @@ +import type { OpenAITool } from "../../core/tools/schema.ts"; + +const SCHEMA_CACHE = new WeakMap< + object, + OpenAITool["function"]["parameters"] +>(); + +/** + * OpenAI-compatible providers vary in their JSON Schema support. In particular, + * Gemini's compatibility endpoint rejects required recursive `$ref` loops. + * Inline local references and replace only recursive edges with a permissive + * object so the rest of each tool contract remains intact. + */ +export function compatibleOpenAITools( + tools: readonly OpenAITool[], +): OpenAITool[] { + return tools.map((tool) => ({ + ...tool, + function: { + ...tool.function, + parameters: compatibleParameters(tool.function.parameters), + }, + })); +} + +function compatibleParameters( + parameters: OpenAITool["function"]["parameters"], +): OpenAITool["function"]["parameters"] { + const cached = SCHEMA_CACHE.get(parameters); + if (cached) return cached; + const expanded = expandSchema( + parameters, + parameters, + new Set(), + ) as OpenAITool["function"]["parameters"]; + SCHEMA_CACHE.set(parameters, expanded); + return expanded; +} + +function expandSchema( + value: unknown, + root: unknown, + activeRefs: ReadonlySet, +): unknown { + if (Array.isArray(value)) { + return value.map((entry) => expandSchema(entry, root, activeRefs)); + } + if (typeof value !== "object" || value === null) return value; + + const source = value as Record; + const reference = typeof source.$ref === "string" ? source.$ref : null; + if (reference?.startsWith("#/")) { + if (activeRefs.has(reference)) { + return {}; + } + const target = resolveLocalReference(root, reference); + if (target) { + const nextRefs = new Set(activeRefs); + nextRefs.add(reference); + const expanded = expandSchema(target, root, nextRefs); + const siblings = expandObject(source, root, activeRefs, true); + if ( + typeof expanded === "object" && + expanded !== null && + !Array.isArray(expanded) + ) { + return { ...(expanded as Record), ...siblings }; + } + return siblings; + } + } + return expandObject(source, root, activeRefs, false); +} + +function expandObject( + source: Record, + root: unknown, + activeRefs: ReadonlySet, + skipReference: boolean, +): Record { + const out: Record = {}; + for (const [key, child] of Object.entries(source)) { + if ( + key === "$defs" || + key === "definitions" || + (skipReference && key === "$ref") + ) { + continue; + } + out[key] = expandSchema(child, root, activeRefs); + } + return out; +} + +function resolveLocalReference( + root: unknown, + reference: string, +): Record | null { + let current: unknown = root; + for (const rawPart of reference.slice(2).split("/")) { + if (typeof current !== "object" || current === null) return null; + const part = rawPart.replaceAll("~1", "/").replaceAll("~0", "~"); + current = (current as Record)[part]; + } + return typeof current === "object" && + current !== null && + !Array.isArray(current) + ? (current as Record) + : null; +} diff --git a/src/providers/byok/registry.ts b/src/providers/byok/registry.ts index c51491b..3ea892d 100644 --- a/src/providers/byok/registry.ts +++ b/src/providers/byok/registry.ts @@ -1,9 +1,14 @@ import { + type CustomProviderDefinition, + RESERVED_CUSTOM_PROVIDER_IDS, +} from "../../config/providers.ts"; +import type { StoredProviderKey } from "../../core/keys/ProviderKeyTypes.ts"; +import { + type BuiltinProviderId, BYOK_PROVIDER_IDS, - type ByokProviderId, - isByokProviderId, } from "../../core/keys/ProviderKeyTypes.ts"; import { anthropicAdapter } from "./adapters/AnthropicAdapter.ts"; +import { createCustomProviderAdapter } from "./adapters/CustomProviderAdapter.ts"; import { googleAdapter } from "./adapters/GoogleAdapter.ts"; import { openaiAdapter } from "./adapters/OpenAIAdapter.ts"; import { openRouterAdapter } from "./adapters/OpenRouterAdapter.ts"; @@ -14,7 +19,7 @@ import type { ProviderAdapter } from "./ByokTypes.ts"; * adapter and adding it here plus to BYOK_PROVIDER_IDS - `/keys`, the BYOK * setup flow, the model catalog, and request routing all read from this map. */ -export const BYOK_ADAPTERS: Record = { +export const BYOK_ADAPTERS: Record = { anthropic: anthropicAdapter, openai: openaiAdapter, google: googleAdapter, @@ -24,19 +29,75 @@ export const BYOK_ADAPTERS: Record = { /** Adapters in a stable display order for pickers. */ export const BYOK_ADAPTER_LIST: readonly ProviderAdapter[] = BYOK_PROVIDER_IDS.map((id) => BYOK_ADAPTERS[id]); +export const RESERVED_PROVIDER_IDS = RESERVED_CUSTOM_PROVIDER_IDS; -export function byokAdapter(id: ByokProviderId): ProviderAdapter { - return BYOK_ADAPTERS[id]; -} +export class ProviderRegistry { + readonly adapters: readonly ProviderAdapter[]; + private readonly byId: ReadonlyMap; + private readonly definitions: ReadonlyMap; + private readonly errors: ReadonlyMap; + + constructor( + customProviders: readonly CustomProviderDefinition[] = [], + options: { includeDisabled?: boolean } = {}, + ) { + const adapters = [...BYOK_ADAPTER_LIST]; + const byId = new Map(adapters.map((adapter) => [adapter.id, adapter])); + const definitions = new Map(); + const errors = new Map(); + for (const definition of customProviders) { + if (RESERVED_PROVIDER_IDS.has(definition.id)) { + continue; + } + definitions.set(definition.id, definition); + if (definition.enabled === false && !options.includeDisabled) continue; + try { + const adapter = createCustomProviderAdapter(definition); + adapters.push(adapter); + byId.set(adapter.id, adapter); + } catch (error) { + errors.set( + definition.id, + error instanceof Error ? error : new Error(String(error)), + ); + } + } + this.adapters = adapters; + this.byId = byId; + this.definitions = definitions; + this.errors = errors; + } + + get(id: string): ProviderAdapter | null { + const normalized = id.trim().toLowerCase(); + const direct = this.byId.get(normalized); + if (direct) return direct; + if (normalized === "gemini" || normalized === "google-gemini") { + return this.byId.get("google") ?? null; + } + return null; + } -/** Resolves a catalog provider string (e.g. from `provider/model`) to an adapter. */ -export function byokAdapterFor(provider: string): ProviderAdapter | null { - const normalized = provider.trim().toLowerCase(); - if (isByokProviderId(normalized)) return BYOK_ADAPTERS[normalized]; - // Backboard names Gemini's provider "google"; accept the common aliases so - // a model selected from either catalog routes the same way. - if (normalized === "gemini" || normalized === "google-gemini") { - return BYOK_ADAPTERS.google; + definition(id: string): CustomProviderDefinition | null { + return this.definitions.get(id.trim().toLowerCase()) ?? null; + } + + error(id: string): Error | null { + return this.errors.get(id.trim().toLowerCase()) ?? null; + } + + credentialFor( + id: string, + saved: StoredProviderKey | undefined, + env: NodeJS.ProcessEnv = process.env, + ): string | null { + const definition = this.definition(id); + if (!definition) return saved?.enabled ? saved.key : null; + const auth = definition.auth ?? { type: "apiKey" as const }; + if (auth.type === "none") return ""; + if (auth.type === "env") return env[auth.variable]?.trim() || null; + return saved?.enabled ? saved.key : null; } - return null; } + +export const BUILTIN_PROVIDER_REGISTRY = new ProviderRegistry(); diff --git a/src/providers/createAgentClient.ts b/src/providers/createAgentClient.ts index b9e035c..0c3d646 100644 --- a/src/providers/createAgentClient.ts +++ b/src/providers/createAgentClient.ts @@ -28,6 +28,7 @@ export function createAgentClient( (provider) => providerKeyResolver(config.auth)(provider), serverLog, new ByokConversationStore(config.cwd), + () => config.providerRegistry, ); return new ClientRouter({ @@ -36,5 +37,8 @@ export function createAgentClient( getModel: () => config.model, hasBackboardAuth: () => config.hasBackboardAuth, hasKeyFor: (provider) => config.hasProviderKeyFor(provider), + hasProvider: (provider) => config.providerRegistry.get(provider) !== null, + hasCustomProvider: (provider) => + config.providerRegistry.definition(provider) !== null, }); } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 131444e..e51d54f 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -9,6 +9,7 @@ import { useRef, useState, } from "react"; +import { readBackboardConfig } from "../config/backboardConfig.ts"; import { APP_DISPLAY_NAME, APP_VERSION } from "../config/branding.ts"; import type { Config } from "../config/Config.ts"; import { formatModel } from "../config/defaults.ts"; @@ -157,7 +158,10 @@ import type { PromptHistoryState, QueuedPromptItem } from "./input/types.ts"; import { playCompletionNotification } from "./notify.ts"; import { theme } from "./theme/theme.ts"; import { composeSubmissionWithNotes } from "./utils/modelNotes.ts"; -import { refreshCredentials as refreshClientCredentials } from "./utils/refreshCredentials.ts"; +import { + refreshCredentials as refreshClientCredentials, + shouldAdoptPersistedModel, +} from "./utils/refreshCredentials.ts"; import { activateResumeTarget, hydrateResumeTarget, @@ -308,6 +312,23 @@ export function App({ const refreshCredentials = useCallback((): void => { const wasExpert = config.isExpertModeEnabled; refreshClientCredentials(config, client); + const persistedModel = readBackboardConfig().model; + if ( + shouldAdoptPersistedModel( + config.flags.model, + config.modelString, + persistedModel, + ) + ) { + config.setModel(persistedModel); + agent.setModelLabel(formatModel(persistedModel)); + } else if (!config.hasBackendForCurrentModel) { + agent.notice( + "Choose another model because the selected provider is no longer enabled.", + "warning", + ); + setMode("model"); + } if (wasExpert && !config.isExpertModeEnabled) { agent.notice( "Expert mode is off: its model's provider key is no longer enabled.", @@ -316,7 +337,7 @@ export function App({ } setModels([]); }, [agent, config, client]); - // Every `/keys` change re-reads credentials into the live Config, so the + // Every `/providers` change re-reads credentials into the live Config, so the // next request routes through the new key set without a restart. The model // list is dropped because which models exist depends on those keys. const providerKeys = useMemo( @@ -1600,7 +1621,7 @@ export function App({ case "lsp": toggleLsp(); break; - case "keys": + case "providers": setMode("keys"); break; case "context": diff --git a/src/ui/AuthScreen.tsx b/src/ui/AuthScreen.tsx index 75e6ee4..a419d17 100644 --- a/src/ui/AuthScreen.tsx +++ b/src/ui/AuthScreen.tsx @@ -8,7 +8,7 @@ import type { AuthScreenProps, } from "./AuthScreenTypes.ts"; import { AuthPrompt } from "./components/AuthPrompt.tsx"; -import { ProviderKeySetup } from "./components/ProviderKeySetup.tsx"; +import { ProviderKeyManager } from "./components/ProviderKeyManager.tsx"; import { Spinner } from "./components/Spinner.tsx"; import { useResizeStabilizer } from "./hooks/useResizeStabilizer.ts"; import { theme } from "./theme/theme.ts"; @@ -138,13 +138,17 @@ export function AuthScreen({ return ( - { - onKeySaved?.(); - app.exit(); + signedIn={false} + onClose={() => { + if (keys.list().some((provider) => provider.enabled)) { + onKeySaved?.(); + app.exit(); + } else { + setMode("select"); + } }} - onCancel={() => setMode("select")} /> ); diff --git a/src/ui/commands/index.ts b/src/ui/commands/index.ts index 88f9f56..d4c9a84 100644 --- a/src/ui/commands/index.ts +++ b/src/ui/commands/index.ts @@ -14,7 +14,7 @@ export type Command = | { type: "browser" } | { type: "lsp" } | { type: "mcp" } - | { type: "keys" } + | { type: "providers" } | { type: "context" } | { type: "compress" } | { type: "discover" } @@ -93,10 +93,10 @@ export const SLASH_COMMANDS: readonly SlashCommandDefinition[] = [ aliases: ["compact"], }, { - name: "keys", - type: "keys", - description: "Manage provider API keys (BYOK)", - aliases: ["apikeys"], + name: "providers", + type: "providers", + description: "Manage model providers and credentials", + aliases: ["keys", "apikeys"], }, { name: "discover", @@ -229,7 +229,7 @@ export function canRunCommandAfterSessionEnd( command === "login" || command === "logout" || command === "memory" || - command === "keys" || + command === "providers" || command === "context" || command === "settings" || command === "cua" || diff --git a/src/ui/components/AuthPrompt.tsx b/src/ui/components/AuthPrompt.tsx index 52d72e3..a390ac0 100644 --- a/src/ui/components/AuthPrompt.tsx +++ b/src/ui/components/AuthPrompt.tsx @@ -15,7 +15,7 @@ import type { const ACTIONS: Record = { login: "Login with Backboard", - byok: "Use my own API key", + byok: "Use my own model provider", exit: "Exit", }; @@ -52,7 +52,7 @@ export function AuthPrompt({ - Sign in with Backboard, or bring your own provider API key. + Sign in with Backboard, or bring your own model provider. {Object.entries(ACTIONS).map(([key, label]) => { diff --git a/src/ui/components/CustomProviderSetup.tsx b/src/ui/components/CustomProviderSetup.tsx new file mode 100644 index 0000000..27ffc24 --- /dev/null +++ b/src/ui/components/CustomProviderSetup.tsx @@ -0,0 +1,577 @@ +import { Box, Text, useInput } from "ink"; +import TextInput from "ink-text-input"; +import type React from "react"; +import { useState } from "react"; +import { normalizeApiUrl } from "../../config/env.ts"; +import { + CUSTOM_PROVIDER_PROTOCOLS, + type CustomModelDefinition, + type CustomProviderAuth, + type CustomProviderDefinition, + type CustomProviderProtocol, + isCredentialHeader, + isValidProviderId, + normalizeProviderId, +} from "../../config/providers.ts"; +import type { ProviderKeyController } from "../../core/keys/ProviderKeyController.ts"; +import { errorMessage } from "../../utils/errors.ts"; +import type { JsonObject } from "../../utils/JsonTypes.ts"; +import { useAsyncAction } from "../hooks/useAsyncAction.ts"; +import { theme } from "../theme/theme.ts"; +import { EntryListEditor } from "./EntryListEditor.tsx"; +import type { EntryListItem } from "./EntryListEditor.types.ts"; +import { ErrorLine } from "./ErrorLine.tsx"; +import { HintFooter } from "./HintFooter.tsx"; +import { Panel } from "./Panel.tsx"; +import { SelectRow } from "./SelectRow.tsx"; +import { Spinner } from "./Spinner.tsx"; + +interface Props { + controller: ProviderKeyController; + existing?: CustomProviderDefinition; + onDone: (provider: string) => void; + onCancel: () => void; +} + +const AUTH_OPTIONS: readonly CustomProviderAuth["type"][] = [ + "apiKey", + "env", + "none", +]; + +type Draft = { + name: string; + id: string; + protocol: CustomProviderProtocol; + baseUrl: string; + authType: CustomProviderAuth["type"]; + credential: string; + modelsEndpoint: string; + models: CustomModelDefinition[]; + headers: Record; + extraArgs: JsonObject; +}; + +const FIELD_LABELS = [ + "Provider name", + "Provider id", + "Protocol", + "Base URL", + "Authentication", + "Credential", + "Models endpoint", + "Manual models", + "Headers", + "Extra request arguments", +] as const; + +export function CustomProviderSetup({ + controller, + existing, + onDone, + onCancel, +}: Props): React.ReactElement { + const [draft, setDraft] = useState(() => ({ + name: existing?.name ?? "", + id: existing?.id ?? "", + protocol: existing?.protocol ?? "openai-chat", + baseUrl: existing?.baseUrl ?? "", + authType: existing ? (existing.auth?.type ?? "apiKey") : "none", + credential: existing?.auth?.type === "env" ? existing.auth.variable : "", + modelsEndpoint: + existing?.discoverModels === false + ? "off" + : (existing?.modelsPath ?? "models"), + models: existing?.models ?? [], + headers: existing?.headers ?? {}, + extraArgs: existing?.extraArgs ?? {}, + })); + const [step, setStep] = useState(0); + const [choiceIndex, setChoiceIndex] = useState(0); + const asyncAction = useAsyncAction(); + const { error, running: saving, setError } = asyncAction; + const editing = existing !== undefined; + + const isProtocol = step === 2; + const isAuth = step === 4; + const isEntryList = step >= 7 && step <= 9; + const isReview = step >= FIELD_LABELS.length; + + const advance = (): void => { + setError(null); + let next = step + 1; + if (step === 4 && draft.authType === "none") next = 6; + setStep(next); + setChoiceIndex(choiceForStep(next, draft)); + }; + + const back = (): void => { + if (step === 0) { + onCancel(); + return; + } + let next = step - 1; + if (step === 6 && draft.authType === "none") next = 4; + setStep(next); + setError(null); + setChoiceIndex(choiceForStep(next, draft)); + }; + + const save = (): void => { + if (saving) return; + let definition: CustomProviderDefinition; + try { + definition = buildDefinition(draft); + } catch (err) { + setError(errorMessage(err)); + return; + } + asyncAction.run(`Testing ${draft.name}`, async (signal) => { + await controller.saveCustomProvider( + definition, + draft.authType === "apiKey" && draft.credential.trim() + ? draft.credential + : undefined, + existing?.id, + signal, + ); + onDone(definition.id); + }); + }; + + useInput((input, key) => { + if (isEntryList) return; + if (saving) { + if (key.escape) asyncAction.cancel(); + return; + } + if (key.escape) { + back(); + return; + } + if (isReview) { + if (key.return) save(); + return; + } + if (!isProtocol && !isAuth) return; + const options = isProtocol ? CUSTOM_PROVIDER_PROTOCOLS : AUTH_OPTIONS; + if (key.upArrow || input === "k") { + setChoiceIndex((index) => (index - 1 + options.length) % options.length); + return; + } + if (key.downArrow || input === "j") { + setChoiceIndex((index) => (index + 1) % options.length); + return; + } + if (key.return) { + if (isProtocol) { + setDraft((current) => ({ + ...current, + protocol: CUSTOM_PROVIDER_PROTOCOLS[choiceIndex] ?? "openai-chat", + })); + setStep(3); + setChoiceIndex(0); + setError(null); + } else { + const authType = AUTH_OPTIONS[choiceIndex] ?? "apiKey"; + setDraft((current) => ({ + ...current, + authType, + credential: authType === current.authType ? current.credential : "", + })); + setStep(authType === "none" ? 6 : 5); + setChoiceIndex(0); + setError(null); + } + } + }); + + if (isProtocol || isAuth) { + const options = isProtocol ? CUSTOM_PROVIDER_PROTOCOLS : AUTH_OPTIONS; + return ( + + + {options.map((option, index) => ( + + + {optionLabel(option)} + + + ))} + + + + ); + } + + if (isReview) { + return ( + + + + {draft.name} ({normalizeProviderId(draft.id)}) + + + {draft.protocol} · {draft.baseUrl} + + + Auth: {optionLabel(draft.authType)} · Models:{" "} + {draft.modelsEndpoint.trim().toLowerCase() === "off" + ? "manual only" + : `discover via ${draft.modelsEndpoint}`} + + + + {saving && asyncAction.label ? ( + + ) : null} + + + ); + } + + if (step === 7) { + return ( + + ({ + key: model.id, + value: "", + data: model, + }))} + keyLabel="Model ID" + keyPlaceholder="gpt-5" + onChange={(entries) => { + setDraft((current) => ({ + ...current, + models: entries.map((entry) => ({ + ...(isCustomModelDefinition(entry.data) ? entry.data : {}), + id: entry.key, + })), + })); + setError(null); + }} + onSubmit={advance} + onCancel={back} + /> + + ); + } + + if (step === 8 || step === 9) { + const headers = step === 8; + const entries = objectEntries(headers ? draft.headers : draft.extraArgs); + return ( + + validateEntry(entry, headers)} + onChange={(next) => { + setDraft((current) => ({ + ...current, + ...(headers + ? { headers: entriesToHeaders(next) } + : { extraArgs: entriesToObject(next) }), + })); + setError(null); + }} + onSubmit={advance} + onCancel={back} + /> + + ); + } + + const value = valueForStep(draft, step); + const secret = step === 5 && draft.authType === "apiKey"; + return ( + + {helpForStep(step, draft.authType)} + + { + setDraft((current) => setValueForStep(current, step, next)); + setError(null); + }} + onSubmit={() => { + try { + validateStep(draft, step); + if (step === 0 && !draft.id) { + setDraft((current) => ({ + ...current, + id: slug(current.name), + })); + } + advance(); + } catch (err) { + setError(errorMessage(err)); + } + }} + placeholder={placeholderForStep(step, draft.authType, editing)} + {...(secret ? { mask: "•" } : {})} + /> + + + + + ); +} + +function buildDefinition(draft: Draft): CustomProviderDefinition { + for (const step of [0, 1, 3, 5, 6, 7, 8, 9]) { + if (step === 5 && draft.authType === "none") continue; + validateStep(draft, step); + } + const endpoint = draft.modelsEndpoint.trim(); + const definition: CustomProviderDefinition = { + id: normalizeProviderId(draft.id), + name: draft.name.trim(), + protocol: draft.protocol, + baseUrl: normalizeApiUrl(draft.baseUrl), + auth: + draft.authType === "env" + ? { type: "env", variable: draft.credential.trim() } + : { type: draft.authType }, + discoverModels: endpoint.toLowerCase() !== "off", + headers: draft.headers, + extraArgs: draft.extraArgs, + models: draft.models, + }; + if (definition.discoverModels && endpoint && endpoint !== "models") { + definition.modelsPath = endpoint; + } + return definition; +} + +function validateStep(draft: Draft, step: number): void { + if (step === 0 && !draft.name.trim()) + throw new Error("Enter a provider name."); + if (step === 1 && !isValidProviderId(draft.id)) { + throw new Error( + "Provider id must use lowercase letters, numbers, dots, dashes, or underscores.", + ); + } + if (step === 3) { + try { + const url = new URL( + draft.baseUrl + .trim() + .replace(/\$\{[A-Za-z_][A-Za-z0-9_]*\}/g, "placeholder"), + ); + if (url.protocol !== "http:" && url.protocol !== "https:") throw null; + } catch { + throw new Error("Enter an HTTP or HTTPS base URL."); + } + } + if (step === 5) { + if (draft.authType === "env") { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(draft.credential.trim())) { + throw new Error("Enter an environment variable name."); + } + } + } + if (step === 6 && !draft.modelsEndpoint.trim()) { + throw new Error('Enter a models endpoint, or "off".'); + } +} + +function parseEntryValue(value: string): JsonObject[string] { + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function objectEntries(value: JsonObject): EntryListItem[] { + return Object.entries(value).map(([key, entry]) => ({ + key, + value: displayEntryValue(entry), + data: entry, + })); +} + +function entriesToObject(entries: readonly EntryListItem[]): JsonObject { + return Object.fromEntries( + entries.map((entry) => [ + entry.key, + entry.data !== undefined && + displayEntryValue(entry.data as JsonObject[string]) === entry.value + ? (entry.data as JsonObject[string]) + : parseEntryValue(entry.value), + ]), + ); +} + +function displayEntryValue(value: JsonObject[string]): string { + return typeof value === "string" ? value : JSON.stringify(value); +} + +function isCustomModelDefinition( + value: unknown, +): value is CustomModelDefinition { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + typeof (value as { id?: unknown }).id === "string" + ); +} + +function entriesToHeaders( + entries: readonly EntryListItem[], +): Record { + return Object.fromEntries(entries.map((entry) => [entry.key, entry.value])); +} + +function isSensitiveKey(key: string): boolean { + return ( + isCredentialHeader(key) || + /(?:auth|cookie|token|secret|api[-_]?key|credential|password)/i.test(key) + ); +} + +function validateEntry(entry: EntryListItem, header: boolean): void { + if (header && !entry.value.trim()) { + throw new Error("Header values cannot be empty."); + } + if ( + isSensitiveKey(entry.key) && + !/\$\{[A-Za-z_][A-Za-z0-9_]*\}/.test(entry.value) + ) { + throw new Error( + `${entry.key} may contain a secret; use an environment variable reference.`, + ); + } +} + +function slug(value: string): string { + return normalizeProviderId(value) + .replace(/[^a-z0-9._-]+/g, "-") + .replace(/^[^a-z0-9]+/, "") + .slice(0, 64); +} + +function valueForStep(draft: Draft, step: number): string { + switch (step) { + case 0: + return draft.name; + case 1: + return draft.id; + case 3: + return draft.baseUrl; + case 5: + return draft.credential; + case 6: + return draft.modelsEndpoint; + default: + return ""; + } +} + +function setValueForStep(draft: Draft, step: number, value: string): Draft { + switch (step) { + case 0: + return { ...draft, name: value }; + case 1: + return { ...draft, id: value }; + case 3: + return { ...draft, baseUrl: value }; + case 5: + return { ...draft, credential: value }; + case 6: + return { ...draft, modelsEndpoint: value }; + default: + return draft; + } +} + +function optionLabel(value: string): string { + switch (value) { + case "apiKey": + return "Encrypted API key"; + case "env": + return "Environment variable"; + case "none": + return "No authentication"; + case "openai-chat": + return "OpenAI Chat Completions"; + case "openai-responses": + return "OpenAI Responses"; + case "anthropic-messages": + return "Anthropic Messages"; + default: + return value; + } +} + +function choiceForStep(step: number, draft: Draft): number { + if (step === 2) { + return Math.max(0, CUSTOM_PROVIDER_PROTOCOLS.indexOf(draft.protocol)); + } + if (step === 4) { + return Math.max(0, AUTH_OPTIONS.indexOf(draft.authType)); + } + return 0; +} + +function helpForStep(step: number, auth: CustomProviderAuth["type"]): string { + switch (step) { + case 1: + return "Stable id used in provider/model references."; + case 5: + return auth === "env" + ? "The variable is resolved each time the CLI starts." + : "Stored encrypted and never rendered in terminal output."; + case 6: + return 'Usually "models". Use a path or full URL, or "off" for manual models only.'; + default: + return ""; + } +} + +function placeholderForStep( + step: number, + auth: CustomProviderAuth["type"], + editing: boolean, +): string { + switch (step) { + case 0: + return "My provider"; + case 1: + return "my-provider"; + case 3: + return "https://api.example.com"; + case 5: + return auth === "env" + ? "PROVIDER_API_KEY" + : editing + ? "Leave blank to keep saved key" + : "API key"; + case 6: + return "models"; + default: + return ""; + } +} diff --git a/src/ui/components/EntryListEditor.tsx b/src/ui/components/EntryListEditor.tsx new file mode 100644 index 0000000..44db288 --- /dev/null +++ b/src/ui/components/EntryListEditor.tsx @@ -0,0 +1,230 @@ +import { Box, Text, useInput } from "ink"; +import TextInput from "ink-text-input"; +import type React from "react"; +import { useState } from "react"; +import { errorMessage } from "../../utils/errors.ts"; +import { useListSelection } from "../hooks/useListSelection.ts"; +import { theme } from "../theme/theme.ts"; +import type { + EntryListEditorProps, + EntryListItem, +} from "./EntryListEditor.types.ts"; +import { ErrorLine } from "./ErrorLine.tsx"; +import { HintFooter } from "./HintFooter.tsx"; +import { SelectRow } from "./SelectRow.tsx"; + +type EditField = "key" | "value"; + +export function EntryListEditor({ + title, + help, + entries, + keyLabel, + valueLabel, + keyPlaceholder, + valuePlaceholder, + isSecret, + validate, + onChange, + onSubmit, + onCancel, +}: EntryListEditorProps): React.ReactElement { + const [editingIndex, setEditingIndex] = useState(null); + const [field, setField] = useState(null); + const [draft, setDraft] = useState({ key: "", value: "" }); + const [error, setError] = useState(null); + const rowCount = entries.length + 2; + const selection = useListSelection(rowCount, { + initialIndex: entries.length, + }); + const selected = selection.index; + const setSelected = selection.setIndex; + + const beginEdit = (index: number | null): void => { + const entry = index === null ? { key: "", value: "" } : entries[index]; + setEditingIndex(index); + setDraft(entry ?? { key: "", value: "" }); + setField("key"); + setError(null); + }; + + const saveDraft = (): void => { + const candidate: EntryListItem = { + ...draft, + key: draft.key.trim(), + value: draft.value, + }; + if (!candidate.key) { + setError(`Enter a ${keyLabel.toLowerCase()}.`); + setField("key"); + return; + } + const duplicate = entries.some( + (entry, index) => + index !== editingIndex && + entry.key.toLowerCase() === candidate.key.toLowerCase(), + ); + if (duplicate) { + setError(`${candidate.key} is already listed.`); + setField("key"); + return; + } + try { + validate?.(candidate); + } catch (err) { + setError(errorMessage(err)); + return; + } + const next = [...entries]; + if (editingIndex === null) next.push(candidate); + else next[editingIndex] = candidate; + onChange(next); + setSelected(editingIndex ?? next.length); + setEditingIndex(null); + setField(null); + setDraft({ key: "", value: "" }); + setError(null); + }; + + useInput((input, key) => { + if (field) { + if (key.escape) { + setEditingIndex(null); + setField(null); + setError(null); + } + return; + } + if (key.escape) { + onCancel(); + return; + } + if (selection.onInput(input, key)) return; + if (input === "k") { + setSelected((index) => (index - 1 + rowCount) % rowCount); + return; + } + if (input === "j") { + setSelected((index) => (index + 1) % rowCount); + return; + } + if ((input === "d" || key.delete) && selected < entries.length) { + onChange(entries.filter((_, index) => index !== selected)); + setSelected((index) => Math.min(index, entries.length - 1)); + setError(null); + return; + } + if (!key.return) return; + if (selected < entries.length) { + beginEdit(selected); + return; + } + if (selected === entries.length) { + beginEdit(null); + return; + } + onSubmit(); + }); + + if (field) { + const editingKey = field === "key"; + const secret = !editingKey && (isSecret?.(draft.key) ?? false); + const label = editingKey ? keyLabel : (valueLabel ?? "Value"); + const value = editingKey ? draft.key : draft.value; + return ( + + + {editingIndex === null ? "Add entry" : "Edit entry"} + + + {label}: + { + setDraft((current) => ({ + ...current, + [field]: next, + })); + setError(null); + }} + onSubmit={() => { + if (editingKey && valueLabel) { + if (!draft.key.trim()) { + setError(`Enter a ${keyLabel.toLowerCase()}.`); + return; + } + setField("value"); + setError(null); + return; + } + saveDraft(); + }} + placeholder={ + editingKey + ? (keyPlaceholder ?? keyLabel.toLowerCase()) + : (valuePlaceholder ?? valueLabel?.toLowerCase() ?? "value") + } + {...(secret ? { mask: "•" } : {})} + focus + /> + + {secret ? ( + + Secret values should use an environment reference. + + ) : null} + + + + ); + } + + return ( + + + {title} + + {help ? {help} : null} + + {entries.map((entry, index) => { + const secret = isSecret?.(entry.key) ?? false; + return ( + + + {entry.key} + {valueLabel ? `: ${secret ? "(secret)" : entry.value}` : ""} + + + ); + })} + + + + Add more + + + + + Done + + + + + + + ); +} diff --git a/src/ui/components/EntryListEditor.types.ts b/src/ui/components/EntryListEditor.types.ts new file mode 100644 index 0000000..c86a013 --- /dev/null +++ b/src/ui/components/EntryListEditor.types.ts @@ -0,0 +1,21 @@ +export interface EntryListItem { + key: string; + value: string; + /** Caller-owned provenance retained while an entry is unchanged or renamed. */ + data?: unknown; +} + +export interface EntryListEditorProps { + title: string; + help?: string; + entries: readonly EntryListItem[]; + keyLabel: string; + valueLabel?: string; + keyPlaceholder?: string; + valuePlaceholder?: string; + isSecret?: (key: string) => boolean; + validate?: (entry: EntryListItem) => void; + onChange: (entries: EntryListItem[]) => void; + onSubmit: () => void; + onCancel: () => void; +} diff --git a/src/ui/components/ProviderKeyManager.tsx b/src/ui/components/ProviderKeyManager.tsx index c3f283b..1f3f338 100644 --- a/src/ui/components/ProviderKeyManager.tsx +++ b/src/ui/components/ProviderKeyManager.tsx @@ -2,13 +2,15 @@ import { Box, Text, useInput } from "ink"; import type React from "react"; import { useState } from "react"; import type { ProviderKeyController } from "../../core/keys/ProviderKeyController.ts"; -import type { - ByokProviderId, - ProviderKeyStatus, +import type { ProviderKeyStatus } from "../../core/keys/ProviderKeyTypes.ts"; +import { + type BuiltinProviderId, + BYOK_PROVIDER_IDS, } from "../../core/keys/ProviderKeyTypes.ts"; import { errorMessage } from "../../utils/errors.ts"; import { useListSelection } from "../hooks/useListSelection.ts"; import { theme } from "../theme/theme.ts"; +import { CustomProviderSetup } from "./CustomProviderSetup.tsx"; import { ErrorLine } from "./ErrorLine.tsx"; import { HintFooter } from "./HintFooter.tsx"; import { Panel } from "./Panel.tsx"; @@ -23,9 +25,8 @@ interface Props { } /** - * `/keys`. A flat list with direct-action keys rather than nested menus - there - * are only a few providers and four verbs, so a submenu would cost a keystroke - * and buy nothing. + * `/providers` (with `/keys` as an alias). Built-ins expose key management; + * custom providers expose their complete connection definition. */ export function ProviderKeyManager({ controller, @@ -35,12 +36,14 @@ export function ProviderKeyManager({ const [statuses, setStatuses] = useState(() => controller.list(), ); - const selection = useListSelection(statuses.length); - const [adding, setAdding] = useState(null); + const selection = useListSelection(statuses.length + 1); + const [adding, setAdding] = useState(null); + const [editingCustom, setEditingCustom] = useState(null); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const current = statuses[Math.min(selection.index, statuses.length - 1)]; + const addSelected = selection.index === statuses.length; const refresh = (message: string): void => { setStatuses(controller.list()); @@ -55,19 +58,29 @@ export function ProviderKeyManager({ }; useInput((input, key) => { - if (adding) return; + if (adding || editingCustom) return; if (key.escape) { onClose(); return; } if (selection.onInput(input, key)) return; - if (!current) return; if (key.return) { + if (addSelected) { + setEditingCustom("__new__"); + return; + } + if (!current) return; + if (current.custom) { + setEditingCustom(current.provider); + return; + } + if (!isBuiltinProvider(current.provider)) return; setAdding(current.provider); setError(null); setNotice(null); return; } + if (!current || addSelected) return; if (input === " ") { if (!current.configured) { setError(`No ${current.label} key saved yet - press Enter to add one.`); @@ -87,6 +100,24 @@ export function ProviderKeyManager({ } }); + if (editingCustom) { + return ( + { + setEditingCustom(null); + refresh( + `${controller.definition(provider)?.name ?? provider} saved and enabled.`, + ); + }} + onCancel={() => setEditingCustom(null)} + /> + ); + } + if (adding) { return ( - API Keys + Model Providers {signedIn - ? "Enabled keys take precedence over your Backboard sign-in." - : "Keys are the only credentials for this session."}{" "} - Encrypted at rest, bound to this machine. + ? "Enabled direct providers take precedence over matching Backboard models." + : "At least one enabled provider is required."}{" "} + Static keys are encrypted at rest. {statuses.map((status, position) => { @@ -137,7 +168,9 @@ export function ProviderKeyManager({ > {status.label.padEnd(16)} - {status.masked.padEnd(18)} + + {status.masked.padEnd(18)} + {status.configured ? status.enabled @@ -148,7 +181,20 @@ export function ProviderKeyManager({ ); })} + + + + Add custom provider + + + {current?.error && !error ? ( + + + + ) : null} {error ? ( @@ -162,7 +208,7 @@ export function ProviderKeyManager({ ); } + +function isBuiltinProvider(provider: string): provider is BuiltinProviderId { + return (BYOK_PROVIDER_IDS as readonly string[]).includes(provider); +} diff --git a/src/ui/components/ProviderKeySetup.tsx b/src/ui/components/ProviderKeySetup.tsx index 91fc190..c2b52b7 100644 --- a/src/ui/components/ProviderKeySetup.tsx +++ b/src/ui/components/ProviderKeySetup.tsx @@ -4,13 +4,13 @@ import type React from "react"; import { useState } from "react"; import type { ProviderKeyController } from "../../core/keys/ProviderKeyController.ts"; import { + type BuiltinProviderId, BYOK_PROVIDER_IDS, - type ByokProviderId, maskProviderKey, } from "../../core/keys/ProviderKeyTypes.ts"; import { + BUILTIN_PROVIDER_REGISTRY, BYOK_ADAPTER_LIST, - BYOK_ADAPTERS, } from "../../providers/byok/registry.ts"; import { errorMessage } from "../../utils/errors.ts"; import { useListSelection } from "../hooks/useListSelection.ts"; @@ -24,8 +24,8 @@ import { Spinner } from "./Spinner.tsx"; interface Props { controller: ProviderKeyController; /** Skips the provider step when the caller already picked one. */ - provider?: ByokProviderId; - onDone: (provider: ByokProviderId) => void; + provider?: BuiltinProviderId; + onDone: (provider: BuiltinProviderId) => void; onCancel: () => void; } @@ -45,13 +45,14 @@ export function ProviderKeySetup({ }: Props): React.ReactElement { const [step, setStep] = useState(provider ? "key" : "provider"); const selection = useListSelection(BYOK_PROVIDER_IDS.length); - const selected: ByokProviderId = + const selected: BuiltinProviderId = provider ?? BYOK_PROVIDER_IDS[selection.index] ?? BYOK_PROVIDER_IDS[0]; const [value, setValue] = useState(""); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); - const adapter = BYOK_ADAPTERS[selected]; + const adapter = BUILTIN_PROVIDER_REGISTRY.get(selected); + if (!adapter) throw new Error(`Unknown provider: ${selected}`); // Read once, not per render: `list()` re-reads the key file from disk and // decrypts every entry, and this component re-renders on each keystroke of // the masked input. Nothing can change it while this screen is open - the diff --git a/src/ui/hooks/useListSelection.ts b/src/ui/hooks/useListSelection.ts index 9896b55..fe026c1 100644 --- a/src/ui/hooks/useListSelection.ts +++ b/src/ui/hooks/useListSelection.ts @@ -14,9 +14,9 @@ export interface ListSelection { */ export function useListSelection( count: number, - opts: { digitJump?: boolean } = {}, + opts: { digitJump?: boolean; initialIndex?: number } = {}, ): ListSelection { - const [index, setIndex] = useState(0); + const [index, setIndex] = useState(opts.initialIndex ?? 0); useEffect(() => { if (count > 0 && index >= count) setIndex(count - 1); }, [count, index]); diff --git a/src/ui/utils/refreshCredentials.ts b/src/ui/utils/refreshCredentials.ts index b29d9b5..43f9021 100644 --- a/src/ui/utils/refreshCredentials.ts +++ b/src/ui/utils/refreshCredentials.ts @@ -1,11 +1,23 @@ -import type { Config } from "../../config/Config.ts"; +import { formatModel, type ModelRef } from "../../config/defaults.ts"; import type { AgentClient } from "../../providers/AgentClient.ts"; import { resetModelCache } from "../../providers/backboard/models.ts"; export function refreshCredentials( - config: Pick, + config: { refreshAuth(): void }, client: Pick, ): void { config.refreshAuth(); resetModelCache(client); } + +export function shouldAdoptPersistedModel( + explicitModel: string | undefined, + currentModel: string, + persistedModel: ModelRef | undefined, +): persistedModel is ModelRef { + return ( + explicitModel === undefined && + persistedModel !== undefined && + formatModel(persistedModel) !== currentModel + ); +} diff --git a/tests/ClientRouter.test.ts b/tests/ClientRouter.test.ts index ded3a54..5ded9cd 100644 --- a/tests/ClientRouter.test.ts +++ b/tests/ClientRouter.test.ts @@ -82,6 +82,7 @@ function router(options: { byokClient?: RecordingClient; model?: { provider: string; model: string }; keyed?: string[]; + custom?: string[]; signedIn?: boolean; }): ClientRouter { return new ClientRouter({ @@ -97,6 +98,7 @@ function router(options: { ? {} : { hasBackboardAuth: () => options.signedIn === true }), hasKeyFor: (provider) => (options.keyed ?? []).includes(provider), + hasCustomProvider: (provider) => (options.custom ?? []).includes(provider), }); } @@ -142,6 +144,17 @@ describe("ClientRouter precedence", () => { ).toBe("backboard"); }); + it("keeps unavailable custom providers on the BYOK backend", () => { + expect( + router({ + backboardClient: backboard(), + byokClient: byok(), + custom: ["local"], + signedIn: true, + }).sourceFor({ provider: "local", model: "local-model" }), + ).toBe("byok"); + }); + it("reports the active backend's capabilities", () => { const withKey = router({ backboardClient: backboard(), diff --git a/tests/CredentialRefresh.test.ts b/tests/CredentialRefresh.test.ts index b5fffa5..965c9e6 100644 --- a/tests/CredentialRefresh.test.ts +++ b/tests/CredentialRefresh.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "bun:test"; import { fetchModels } from "../src/providers/backboard/models.ts"; import type { ModelsListResponse } from "../src/providers/backboard/types.ts"; -import { refreshCredentials } from "../src/ui/utils/refreshCredentials.ts"; +import { + refreshCredentials, + shouldAdoptPersistedModel, +} from "../src/ui/utils/refreshCredentials.ts"; describe("refreshCredentials", () => { it("reloads auth and invalidates the model catalog cache", async () => { @@ -64,4 +67,14 @@ describe("refreshCredentials", () => { expect(catalogLoads).toBe(2); }); + + it("does not replace an explicit --model selection with persisted state", () => { + const persisted = { provider: "saved", model: "saved-model" }; + expect( + shouldAdoptPersistedModel("cli/cli-model", "cli/cli-model", persisted), + ).toBe(false); + expect( + shouldAdoptPersistedModel(undefined, "cli/cli-model", persisted), + ).toBe(true); + }); }); diff --git a/tests/CustomProviderSetup.test.ts b/tests/CustomProviderSetup.test.ts new file mode 100644 index 0000000..a985b2d --- /dev/null +++ b/tests/CustomProviderSetup.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from "bun:test"; +import { render } from "ink"; +import React from "react"; +import type { CustomProviderDefinition } from "../src/config/providers.ts"; +import type { ProviderKeyController } from "../src/core/keys/ProviderKeyController.ts"; +import { CustomProviderSetup } from "../src/ui/components/CustomProviderSetup.tsx"; +import { makeInkTty } from "./inkHarness.ts"; + +const ESC = String.fromCharCode(27); +const KEY = { + down: `${ESC}[B`, + up: `${ESC}[A`, + enter: String.fromCharCode(13), +}; +const TRACE_REFERENCE = "$" + "{TRACE_ID}"; + +const sleep = (ms = 25): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 40; attempt++) { + if (predicate()) return; + await sleep(); + } +} + +function mount(existing?: CustomProviderDefinition) { + const tty = makeInkTty(110, 32); + let saved: + | { + definition: CustomProviderDefinition; + key?: string; + previousId?: string; + } + | undefined; + const controller = { + saveCustomProvider: async ( + definition: CustomProviderDefinition, + key?: string, + previousId?: string, + ) => { + saved = { definition, key, previousId }; + }, + } as unknown as ProviderKeyController; + let completed: string | null = null; + const instance = render( + React.createElement(CustomProviderSetup, { + controller, + onDone: (provider: string) => { + completed = provider; + }, + onCancel: () => undefined, + ...(existing ? { existing } : {}), + }), + { + stdout: tty.stdout as unknown as NodeJS.WriteStream, + stdin: tty.stdin, + patchConsole: false, + exitOnCtrlC: false, + }, + ); + const send = async (...inputs: string[]): Promise => { + for (const input of inputs) { + tty.feed(input); + await sleep(); + } + }; + return { + send, + saved: () => saved, + completed: () => completed, + written: tty.written, + unmount: instance.unmount, + }; +} + +describe("CustomProviderSetup", () => { + it("creates a keyless OpenAI-compatible provider through the UI", async () => { + const ui = mount(); + await sleep(); + await ui.send( + "Local Provider", + KEY.enter, + KEY.enter, + KEY.enter, + "http://localhost:8000/v1", + KEY.enter, + KEY.enter, + KEY.enter, + KEY.enter, + "local-model", + KEY.enter, + KEY.down, + KEY.enter, + KEY.enter, + "X-Trace", + KEY.enter, + TRACE_REFERENCE, + KEY.enter, + KEY.down, + KEY.enter, + KEY.enter, + "temperature", + KEY.enter, + "0.2", + KEY.enter, + KEY.down, + KEY.enter, + KEY.enter, + ); + await waitFor(() => ui.saved() !== undefined); + + expect(ui.saved()).toEqual({ + definition: { + id: "local-provider", + name: "Local Provider", + protocol: "openai-chat", + baseUrl: "http://localhost:8000/v1", + auth: { type: "none" }, + discoverModels: true, + headers: { "X-Trace": TRACE_REFERENCE }, + extraArgs: { temperature: 0.2 }, + models: [{ id: "local-model" }], + }, + key: undefined, + previousId: undefined, + }); + expect(ui.completed()).toBe("local-provider"); + ui.unmount(); + }); + + it("never renders a pasted API key in terminal output", async () => { + const ui = mount(); + await sleep(); + await ui.send( + "Private", + KEY.enter, + KEY.enter, + KEY.enter, + "https://api.example.com/v1", + KEY.enter, + KEY.down, + KEY.enter, + "super-secret-provider-token", + ); + await sleep(); + + expect(ui.written()).not.toContain("super-secret-provider-token"); + ui.unmount(); + }); + + it("never renders credential-bearing header values in terminal output", async () => { + const ui = mount(); + await sleep(); + await ui.send( + "Private", + KEY.enter, + KEY.enter, + KEY.enter, + "https://api.example.com/v1", + KEY.enter, + KEY.enter, + KEY.enter, + KEY.down, + KEY.enter, + KEY.enter, + "X-Auth", + KEY.enter, + "header-secret-token", + ); + await sleep(); + + expect(ui.written()).not.toContain("header-secret-token"); + ui.unmount(); + }); + + it("preserves implicit API-key authentication when editing", async () => { + const ui = mount({ + id: "legacy", + name: "Legacy", + protocol: "openai-chat", + baseUrl: "https://models.example/v1", + models: [ + { + id: "legacy-model", + contextLimit: 128000, + supportsThinking: true, + extraArgs: { reasoning_effort: "high" }, + }, + ], + headers: { "X-Trace": TRACE_REFERENCE }, + extraArgs: { + temperature: 0.25, + booleanText: "true", + numberText: "123", + nested: { enabled: true }, + }, + }); + await sleep(); + await ui.send( + KEY.enter, + KEY.enter, + KEY.enter, + KEY.enter, + KEY.enter, + KEY.enter, + KEY.enter, + KEY.up, + KEY.enter, + "-renamed", + KEY.enter, + KEY.down, + KEY.down, + KEY.enter, + KEY.down, + KEY.enter, + KEY.enter, + "top_p", + KEY.enter, + "0.9", + KEY.enter, + KEY.down, + KEY.enter, + KEY.enter, + ); + await waitFor(() => ui.saved() !== undefined); + + expect(ui.saved()?.definition.auth).toEqual({ type: "apiKey" }); + expect(ui.saved()?.previousId).toBe("legacy"); + expect(ui.saved()?.definition.models).toEqual([ + { + id: "legacy-model-renamed", + contextLimit: 128000, + supportsThinking: true, + extraArgs: { reasoning_effort: "high" }, + }, + ]); + expect(ui.saved()?.definition.headers).toEqual({ + "X-Trace": TRACE_REFERENCE, + }); + expect(ui.saved()?.definition.extraArgs).toEqual({ + temperature: 0.25, + booleanText: "true", + numberText: "123", + nested: { enabled: true }, + top_p: 0.9, + }); + ui.unmount(); + }); + + it("keeps the environment variable name when editing env authentication", async () => { + const ui = mount({ + id: "env-provider", + name: "Environment Provider", + protocol: "openai-chat", + baseUrl: "https://models.example/v1", + auth: { type: "env", variable: "EXISTING_PROVIDER_KEY" }, + discoverModels: false, + models: [{ id: "test-model" }], + }); + await sleep(); + await ui.send(KEY.enter, KEY.enter, KEY.enter, KEY.enter, KEY.enter); + await sleep(); + + expect(ui.written()).toContain("EXISTING_PROVIDER_KEY"); + ui.unmount(); + }); +}); diff --git a/tests/CustomProviders.test.ts b/tests/CustomProviders.test.ts new file mode 100644 index 0000000..3aa63ea --- /dev/null +++ b/tests/CustomProviders.test.ts @@ -0,0 +1,1028 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtemp } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { resolveAuth } from "../src/config/auth.ts"; +import { + clearBackboardCredential, + readBackboardConfig, + saveBackboardConfig, +} from "../src/config/backboardConfig.ts"; +import { Config } from "../src/config/Config.ts"; +import { + joinProviderUrl, + resolveEnvReferences, +} from "../src/config/providers.ts"; +import { ProviderKeyController } from "../src/core/keys/ProviderKeyController.ts"; +import { providerKeysPath } from "../src/core/keys/ProviderKeyStore.ts"; +import type { ProviderEvent } from "../src/providers/backboard/types.ts"; +import { createAnthropicAdapter } from "../src/providers/byok/adapters/AnthropicAdapter.ts"; +import { + createOpenAIChatAdapter, + toOpenAIMessages, +} from "../src/providers/byok/adapters/OpenAIAdapter.ts"; +import { createOpenAIResponsesAdapter } from "../src/providers/byok/adapters/OpenAIResponsesAdapter.ts"; +import { ByokClient } from "../src/providers/byok/ByokClient.ts"; +import { ProviderRegistry } from "../src/providers/byok/registry.ts"; + +const originalFetch = globalThis.fetch; +const TRACE_REFERENCE = "$" + "{TRACE_ID}"; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +async function home(): Promise { + return mkdtemp(path.join(os.tmpdir(), "custom-provider-")); +} + +async function collect( + stream: AsyncIterable, +): Promise { + const events: ProviderEvent[] = []; + for await (const event of stream) events.push(event); + return events; +} + +describe("custom provider configuration", () => { + it("round-trips valid definitions without deleting malformed entries", async () => { + const dir = await home(); + await Bun.write( + path.join(dir, ".backboard", "config.json"), + JSON.stringify({ + providers: [ + { + id: "local-provider", + name: "Local Provider", + protocol: "openai-responses", + baseUrl: "http://localhost:8000/v1/", + auth: { type: "none" }, + headers: { "X-Trace": TRACE_REFERENCE }, + models: [ + { + id: "gpt-5.6-sol", + contextLimit: 400000, + maxOutputTokens: 32768, + }, + ], + }, + { + id: "insecure", + name: "Insecure", + protocol: "openai-chat", + baseUrl: "http://models.example/v1", + auth: { type: "apiKey" }, + }, + { + id: "insecure-header", + name: "Insecure Header", + protocol: "openai-chat", + baseUrl: "http://models.example/v1", + auth: { type: "none" }, + headers: { Authorization: `Bearer ${TRACE_REFERENCE}` }, + }, + { + id: "insecure-catalog", + name: "Insecure Catalog", + protocol: "openai-chat", + baseUrl: "https://models.example/v1", + auth: { type: "apiKey" }, + modelsPath: "http://catalog.example/models", + }, + { + id: "malformed-headers", + name: "Malformed Headers", + protocol: "openai-chat", + baseUrl: "http://remote.example/v1", + auth: { type: "none" }, + headers: { + Authorization: `Bearer ${TRACE_REFERENCE}`, + "X-Valid": "kept", + "X-Invalid": 123, + }, + }, + { + id: "openai", + name: "Reserved", + protocol: "openai-chat", + baseUrl: "http://localhost:8002", + auth: { type: "none" }, + }, + { id: "BAD ID", name: "Bad", protocol: "wat", baseUrl: "file:///x" }, + ], + }), + ); + + expect(readBackboardConfig(dir).providers).toEqual([ + { + id: "local-provider", + name: "Local Provider", + protocol: "openai-responses", + baseUrl: "http://localhost:8000/v1", + auth: { type: "none" }, + headers: { "X-Trace": TRACE_REFERENCE }, + models: [ + { + id: "gpt-5.6-sol", + contextLimit: 400000, + maxOutputTokens: 32768, + }, + ], + }, + ]); + await saveBackboardConfig( + { ...readBackboardConfig(dir), notify: true }, + dir, + ); + const raw = (await Bun.file( + path.join(dir, ".backboard", "config.json"), + ).json()) as { providers: Array<{ id: string }> }; + expect(raw.providers.map((provider) => provider.id)).toEqual([ + "local-provider", + "insecure", + "insecure-header", + "insecure-catalog", + "malformed-headers", + "openai", + "BAD ID", + ]); + const openAIStatus = new ProviderKeyController({ homeDir: dir }) + .list() + .find((status) => status.provider === "openai"); + expect(openAIStatus?.custom).toBeUndefined(); + expect(openAIStatus?.configured).toBe(false); + }); + + it("preserves the selected model when editing a disabled provider", async () => { + const dir = await home(); + const provider = { + id: "disabled-provider", + name: "Disabled Provider", + protocol: "openai-chat" as const, + baseUrl: "http://localhost:8317/v1", + enabled: false, + auth: { type: "none" as const }, + discoverModels: false, + models: [{ id: "fallback-model" }, { id: "selected-model" }], + }; + await saveBackboardConfig( + { + providers: [provider], + model: { + provider: provider.id, + model: "selected-model", + }, + }, + dir, + ); + + await new ProviderKeyController({ homeDir: dir }).saveCustomProvider( + provider, + undefined, + provider.id, + ); + + expect(readBackboardConfig(dir).model).toEqual({ + provider: provider.id, + model: "selected-model", + }); + }); + + it("joins standard and absolute provider endpoints safely", () => { + expect(joinProviderUrl("http://localhost:8000/v1/", "models")).toBe( + "http://localhost:8000/v1/models", + ); + expect( + joinProviderUrl( + "http://localhost:8000/v1", + "http://localhost:9000/catalog", + ), + ).toBe("http://localhost:9000/catalog"); + }); + + it("expands environment references and fails clearly when missing", () => { + expect( + resolveEnvReferences("Bearer $" + "{TOKEN}", "header", { TOKEN: "abc" }), + ).toBe("Bearer abc"); + expect(() => resolveEnvReferences("$" + "{MISSING}", "header", {})).toThrow( + /MISSING/, + ); + }); + + it("treats a configured keyless provider as usable authentication", async () => { + const dir = await home(); + await saveBackboardConfig( + { + providers: [ + { + id: "local-provider", + name: "Local Provider", + protocol: "openai-chat", + baseUrl: "http://localhost:8000/v1", + auth: { type: "none" }, + discoverModels: false, + models: [{ id: "gpt-5.6-sol" }], + }, + ], + }, + dir, + ); + const auth = resolveAuth({ homeDir: dir }); + expect(auth.providerKeys).toContainEqual({ + provider: "local-provider", + key: "", + }); + expect(auth.providerRegistry?.get("local-provider")?.requiresKey).toBe( + false, + ); + }); + + it("preserves custom providers when Backboard credentials are cleared", async () => { + const dir = await home(); + await saveBackboardConfig( + { + apiKey: "backboard-key", + providers: [ + { + id: "local", + name: "Local", + protocol: "openai-chat", + baseUrl: "http://localhost:1234/v1", + auth: { type: "none" }, + discoverModels: false, + models: [{ id: "local-model" }], + }, + ], + }, + dir, + ); + + expect((await clearBackboardCredential(dir)).removed).toBe(true); + expect(readBackboardConfig(dir)).toMatchObject({ + apiKey: undefined, + providers: [{ id: "local" }], + }); + }); + + it("saves custom definitions while keeping static secrets encrypted", async () => { + const dir = await home(); + globalThis.fetch = (async () => + new Response(JSON.stringify({ data: [{ id: "model-a" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + })) as unknown as typeof fetch; + const controller = new ProviderKeyController({ homeDir: dir }); + await controller.addCustomProvider( + { + id: "acme", + name: "Acme", + protocol: "openai-chat", + baseUrl: "https://models.example/v1", + auth: { type: "apiKey" }, + }, + "super-secret-token", + ); + + expect(readBackboardConfig(dir).providers?.[0]?.id).toBe("acme"); + expect(readBackboardConfig(dir).model).toEqual({ + provider: "acme", + model: "model-a", + }); + const raw = await Bun.file(providerKeysPath(dir)).text(); + expect(raw).not.toContain("super-secret-token"); + expect( + controller.list().find((entry) => entry.provider === "acme"), + ).toMatchObject({ + custom: true, + configured: true, + enabled: true, + }); + }); + + it("rejects built-in provider ids before sending credentials anywhere", async () => { + const dir = await home(); + let requests = 0; + globalThis.fetch = (async () => { + requests++; + return new Response("{}", { status: 200 }); + }) as unknown as typeof fetch; + + const controller = new ProviderKeyController({ homeDir: dir }); + for (const id of ["openai", "gemini", "google-gemini"]) { + await expect( + controller.addCustomProvider( + { + id, + name: "Not Built-in", + protocol: "openai-chat", + baseUrl: "https://proxy.example/v1", + auth: { type: "apiKey" }, + }, + "custom-secret", + ), + ).rejects.toThrow(/reserved/); + } + expect(requests).toBe(0); + }); + + it("requires environment references for credential-bearing headers", () => { + const registry = new ProviderRegistry([ + { + id: "unsafe", + name: "Unsafe", + protocol: "openai-chat", + baseUrl: "https://example.test/v1", + auth: { type: "none" }, + headers: { "X-Auth": "plaintext-secret" }, + }, + ]); + expect(registry.get("unsafe")).toBeNull(); + expect(registry.error("unsafe")?.message).toMatch(/environment variable/); + }); + + it("clears a persisted model when its custom provider is removed", async () => { + const dir = await home(); + await saveBackboardConfig( + { + model: { provider: "local", model: "local-model" }, + providers: [ + { + id: "local", + name: "Local", + protocol: "openai-chat", + baseUrl: "http://localhost:1234/v1", + auth: { type: "none" }, + discoverModels: false, + models: [{ id: "local-model" }], + }, + ], + }, + dir, + ); + + await new ProviderKeyController({ homeDir: dir }).remove("local"); + expect(readBackboardConfig(dir).model).toBeUndefined(); + }); + + it("replaces a persisted model that has no usable authentication", async () => { + const dir = await home(); + await saveBackboardConfig( + { + apiKey: "backboard-key", + model: { provider: "stale-custom", model: "stale-model" }, + providers: [ + { + id: "stale-custom", + name: "Stale Custom", + protocol: "openai-chat", + baseUrl: "https://stale.example/v1", + auth: { + type: "env", + variable: "BACKBOARD_TEST_MISSING_CUSTOM_KEY", + }, + discoverModels: false, + models: [{ id: "stale-model" }], + }, + ], + }, + dir, + ); + expect( + new Config({ argv: [], homeDir: dir }).hasBackendForCurrentModel, + ).toBe(false); + + await new ProviderKeyController({ homeDir: dir }).addCustomProvider({ + id: "local", + name: "Local", + protocol: "openai-chat", + baseUrl: "http://localhost:1234/v1", + auth: { type: "none" }, + discoverModels: false, + models: [{ id: "local-model" }], + }); + expect(readBackboardConfig(dir).model).toEqual({ + provider: "local", + model: "local-model", + }); + }); +}); + +describe("custom provider adapters", () => { + it("parameterizes Chat Completions URLs, auth, headers, args, and models", async () => { + const requests: Array<{ url: string; body?: Record }> = []; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + requests.push({ + url: String(url), + ...(init?.body + ? { body: JSON.parse(String(init.body)) as Record } + : {}), + }); + if (!init?.body) { + return new Response(JSON.stringify({ data: [{ id: "gpt-live" }] }), { + status: 200, + }); + } + return new Response( + 'data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}\n\ndata: [DONE]\n\n', + { status: 200 }, + ); + }) as unknown as typeof fetch; + const adapter = createOpenAIChatAdapter({ + id: "acme", + label: "Acme", + baseUrl: "https://models.example/v1", + requiresKey: false, + headers: { "X-Custom": "yes" }, + extraArgs: { temperature: 0.2 }, + models: [{ id: "manual", contextLimit: 200000 }], + }); + + const models = await adapter.listModels(""); + expect(models.map((model) => model.name)).toEqual(["gpt-live", "manual"]); + expect(models[0]?.thinking_controls?.allowed_fields).toEqual(["effort"]); + const events = await collect( + adapter.stream( + { + model: "manual", + systemPrompt: "system", + tools: [], + messages: [{ role: "user", content: "hello" }], + }, + "", + ), + ); + expect(requests[0]?.url).toBe("https://models.example/v1/models"); + expect(requests[1]?.url).toBe("https://models.example/v1/chat/completions"); + expect(requests[1]?.body).toMatchObject({ + model: "manual", + temperature: 0.2, + stream: true, + }); + expect(events).toContainEqual({ kind: "assistant_delta", text: "ok" }); + expect(events.at(-1)).toMatchObject({ kind: "completed" }); + }); + + it("keeps multiple non-stream Chat Completions tool calls separate", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + choices: [ + { + message: { + tool_calls: [ + { + id: "call_1", + extra_content: { + google: { thought_signature: "signed-call" }, + }, + function: { + name: "read", + arguments: '{"path":"one.txt"}', + }, + }, + { + id: "call_2", + function: { + name: "read", + arguments: '{"path":"two.txt"}', + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage: {}, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + )) as unknown as typeof fetch; + const adapter = createOpenAIChatAdapter({ + id: "chat", + label: "Chat", + baseUrl: "https://models.example/v1", + requiresKey: false, + discoverModels: false, + models: [{ id: "gpt-x" }], + }); + + const events = await collect( + adapter.stream( + { + model: "gpt-x", + systemPrompt: "system", + tools: [], + messages: [{ role: "user", content: "read both" }], + }, + "", + ), + ); + expect( + events + .filter((event) => event.kind === "tool_ready") + .map((event) => event.call), + ).toEqual([ + { + id: "call_1", + name: "read", + input: { path: "one.txt" }, + signature: "signed-call", + signatureProvider: "chat", + }, + { id: "call_2", name: "read", input: { path: "two.txt" } }, + ]); + }); + + it("uses the supported output-token field for reasoning models", async () => { + const bodies: Record[] = []; + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as Record); + return new Response( + 'data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', + { status: 200 }, + ); + }) as unknown as typeof fetch; + const reasoning = createOpenAIChatAdapter({ + id: "reasoning", + label: "Reasoning", + baseUrl: "https://models.example/v1", + requiresKey: false, + discoverModels: false, + models: [ + { + id: "gpt-5-test", + supportsThinking: true, + maxOutputTokens: 64, + }, + ], + }); + const legacy = createOpenAIChatAdapter({ + id: "legacy", + label: "Legacy", + baseUrl: "https://models.example/v1", + requiresKey: false, + discoverModels: false, + models: [ + { + id: "legacy-chat", + supportsThinking: false, + maxOutputTokens: 32, + }, + ], + }); + const request = { + systemPrompt: "system", + tools: [], + messages: [{ role: "user" as const, content: "hello" }], + }; + + await collect( + reasoning.stream( + { + ...request, + model: "gpt-5-test", + thinking: { effort: "high" }, + }, + "", + ), + ); + await collect(legacy.stream({ ...request, model: "legacy-chat" }, "")); + + expect(bodies[0]).toMatchObject({ max_completion_tokens: 64 }); + expect(bodies[0]).not.toHaveProperty("max_tokens"); + expect(bodies[1]).toMatchObject({ max_tokens: 32 }); + expect(bodies[1]).not.toHaveProperty("max_completion_tokens"); + }); + + it("replays OpenAI-compatible signed tool calls", () => { + const request = { + model: "gemini-compatible", + systemPrompt: "system", + tools: [], + messages: [ + { + role: "assistant" as const, + content: "", + toolCalls: [ + { + id: "call_1", + name: "read", + input: { path: "proof.txt" }, + signature: "signed-call", + signatureProvider: "gemini-compatible", + }, + ], + }, + ], + }; + expect( + toOpenAIMessages(request, undefined, "gemini-compatible"), + ).toContainEqual({ + role: "assistant", + content: null, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "read", + arguments: '{"path":"proof.txt"}', + }, + extra_content: { + google: { thought_signature: "signed-call" }, + }, + }, + ], + }); + expect( + JSON.stringify(toOpenAIMessages(request, undefined, "other-provider")), + ).not.toContain("thought_signature"); + }); + + it("maps Responses API text, tool calls, usage, and continuation metadata", async () => { + globalThis.fetch = (async () => + new Response( + [ + `data: ${JSON.stringify({ type: "response.output_text.delta", delta: "checking" })}`, + `data: ${JSON.stringify({ type: "response.output_item.added", item: { type: "function_call", call_id: "call_1", name: "read", arguments: "" } })}`, + `data: ${JSON.stringify({ type: "response.function_call_arguments.delta", call_id: "call_1", delta: '{"path":"a.ts"}' })}`, + `data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "reasoning", id: "rs_1" } })}`, + `data: ${JSON.stringify({ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 } } })}`, + "data: [DONE]", + ].join("\n\n"), + { status: 200 }, + )) as unknown as typeof fetch; + const adapter = createOpenAIResponsesAdapter({ + id: "responses", + label: "Responses", + baseUrl: "https://models.example/v1", + requiresKey: false, + discoverModels: false, + models: [{ id: "gpt-x" }], + }); + const events = await collect( + adapter.stream( + { + model: "gpt-x", + systemPrompt: "system", + tools: [], + messages: [{ role: "user", content: "read" }], + }, + "", + ), + ); + expect(events).toContainEqual({ + kind: "assistant_delta", + text: "checking", + }); + expect(events).toContainEqual({ + kind: "tool_ready", + call: { id: "call_1", name: "read", input: { path: "a.ts" } }, + }); + expect(events.at(-1)).toMatchObject({ + kind: "requires_action", + }); + const finalEvent = events.at(-1); + const metadata = + finalEvent?.kind === "requires_action" + ? finalEvent.providerMetadata + : undefined; + expect(metadata ? JSON.parse(metadata) : null).toMatchObject({ + provider: "responses", + items: [{ type: "reasoning", id: "rs_1" }], + }); + const capturedBodies: Record[] = []; + globalThis.fetch = (async ( + _url: string | URL | Request, + init?: RequestInit, + ) => { + capturedBodies.push( + JSON.parse(String(init?.body)) as Record, + ); + return new Response( + [ + `data: ${JSON.stringify({ type: "response.completed", response: { usage: {} } })}`, + "data: [DONE]", + ].join("\n\n"), + { status: 200 }, + ); + }) as unknown as typeof fetch; + const continuation = { + model: "gpt-x", + systemPrompt: "system", + tools: [], + messages: [ + { + role: "assistant" as const, + content: "", + toolCalls: [], + providerMetadata: metadata, + }, + ], + }; + await collect(adapter.stream(continuation, "")); + const foreignAdapter = createOpenAIResponsesAdapter({ + id: "other-responses", + label: "Other Responses", + baseUrl: "https://other.example/v1", + requiresKey: false, + discoverModels: false, + models: [{ id: "gpt-x" }], + }); + await collect(foreignAdapter.stream(continuation, "")); + expect(JSON.stringify(capturedBodies[0]?.input)).toContain("rs_1"); + expect(JSON.stringify(capturedBodies[1]?.input)).not.toContain("rs_1"); + }); + + it("surfaces Responses refusals instead of completing with empty output", async () => { + globalThis.fetch = (async () => + new Response( + [ + `data: ${JSON.stringify({ type: "response.refusal.delta", delta: "I cannot do that." })}`, + `data: ${JSON.stringify({ type: "response.completed", response: { usage: {} } })}`, + "data: [DONE]", + ].join("\n\n"), + { status: 200 }, + )) as unknown as typeof fetch; + const adapter = createOpenAIResponsesAdapter({ + id: "responses", + label: "Responses", + baseUrl: "https://models.example/v1", + requiresKey: false, + discoverModels: false, + models: [{ id: "gpt-x" }], + }); + + const events = await collect( + adapter.stream( + { + model: "gpt-x", + systemPrompt: "system", + tools: [], + messages: [{ role: "user", content: "request" }], + }, + "", + ), + ); + expect(events).toContainEqual({ + kind: "assistant_delta", + text: "I cannot do that.", + }); + expect(events.at(-1)).toMatchObject({ kind: "completed" }); + }); + + it("fails malformed Responses tool arguments without emitting runnable calls", async () => { + globalThis.fetch = (async () => + new Response( + [ + `data: ${JSON.stringify({ type: "response.output_item.done", item: { type: "function_call", call_id: "call_bad", name: "write", arguments: '{"path":' } })}`, + `data: ${JSON.stringify({ type: "response.completed", response: { usage: {} } })}`, + "data: [DONE]", + ].join("\n\n"), + { status: 200 }, + )) as unknown as typeof fetch; + const adapter = createOpenAIResponsesAdapter({ + id: "responses", + label: "Responses", + baseUrl: "https://models.example/v1", + requiresKey: false, + discoverModels: false, + models: [{ id: "gpt-x" }], + }); + + const events = await collect( + adapter.stream( + { + model: "gpt-x", + systemPrompt: "system", + tools: [], + messages: [{ role: "user", content: "write" }], + }, + "", + ), + ); + expect(events.some((event) => event.kind === "tool_ready")).toBe(false); + const failed = events.at(-1); + expect(failed?.kind).toBe("failed"); + if (failed?.kind === "failed") { + expect(failed.error).toContain("invalid JSON"); + } + }); + + it("keeps Responses-native Codex models in discovered catalogs", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ + data: [ + { id: "gpt-5.6-sol" }, + { id: "gpt-5.3-codex-spark" }, + { id: "text-embedding-3-large" }, + ], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + )) as unknown as typeof fetch; + const adapter = createOpenAIResponsesAdapter({ + id: "responses", + label: "Responses", + baseUrl: "https://models.example/v1", + requiresKey: false, + }); + + expect((await adapter.listModels("")).map((model) => model.name)).toEqual([ + "gpt-5.6-sol", + "gpt-5.3-codex-spark", + ]); + }); + + it("extracts tool screenshots into Responses image input blocks", async () => { + let body: Record | undefined; + globalThis.fetch = (async (_url: string, init?: RequestInit) => { + body = JSON.parse(String(init?.body)) as Record; + return new Response( + [ + `data: ${JSON.stringify({ type: "response.completed", response: { usage: {} } })}`, + "data: [DONE]", + ].join("\n\n"), + { status: 200 }, + ); + }) as unknown as typeof fetch; + const adapter = createOpenAIResponsesAdapter({ + id: "responses", + label: "Responses", + baseUrl: "https://models.example/v1", + requiresKey: false, + discoverModels: false, + models: [{ id: "gpt-x" }], + }); + await collect( + adapter.stream( + { + model: "gpt-x", + systemPrompt: "system", + tools: [], + messages: [ + { + role: "assistant", + content: "", + toolCalls: [{ id: "call_1", name: "computer", input: {} }], + }, + { + role: "tool", + results: [ + { + id: "call_1", + name: "computer", + output: JSON.stringify({ + screen: { + __image_base64: "aGVsbG8=", + __image_media_type: "image/png", + }, + }), + }, + ], + }, + ], + }, + "", + ), + ); + + const serialized = JSON.stringify(body?.input); + expect(serialized).not.toContain("__image_base64"); + expect(serialized).toContain("data:image/png;base64,aGVsbG8="); + }); + + it("preserves Anthropic model endpoint query parameters", async () => { + const urls: string[] = []; + globalThis.fetch = (async (url: string) => { + urls.push(String(url)); + return new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }) as unknown as typeof fetch; + const adapter = createAnthropicAdapter({ + id: "anthropic-proxy", + label: "Anthropic Proxy", + baseUrl: "https://models.example/v1", + modelsPath: "models?api-version=2024-06-01", + requiresKey: false, + }); + + await adapter.validateKey(""); + await adapter.listModels(""); + expect(urls).toEqual([ + "https://models.example/v1/models?api-version=2024-06-01&limit=1", + "https://models.example/v1/models?api-version=2024-06-01&limit=1000", + ]); + }); + + it("builds all configured protocol adapters in one registry", () => { + const registry = new ProviderRegistry([ + { + id: "chat", + name: "Chat", + protocol: "openai-chat", + baseUrl: "https://example.test/v1", + auth: { type: "none" }, + }, + { + id: "responses", + name: "Responses", + protocol: "openai-responses", + baseUrl: "https://example.test/v1", + auth: { type: "none" }, + }, + { + id: "messages", + name: "Messages", + protocol: "anthropic-messages", + baseUrl: "https://example.test/v1", + auth: { type: "none" }, + }, + ]); + expect(registry.get("chat")).not.toBeNull(); + expect(registry.get("responses")).not.toBeNull(); + expect(registry.get("messages")).not.toBeNull(); + }); + + it("lists manual models from a keyless provider through ByokClient", async () => { + const registry = new ProviderRegistry([ + { + id: "local", + name: "Local", + protocol: "openai-chat", + baseUrl: "http://localhost:1234/v1", + auth: { type: "none" }, + discoverModels: false, + models: [{ id: "local-model" }], + }, + ]); + const client = new ByokClient( + (provider) => (provider === "local" ? "" : null), + undefined, + undefined, + () => registry, + ); + + expect(await client.listModels()).toMatchObject({ + total: 1, + models: [{ provider: "local", name: "local-model" }], + }); + expect( + await client.getModelThinkingMetadata("local", "local-model"), + ).toMatchObject({ + provider: "local", + model: "local-model", + supports_thinking: true, + thinking_controls: { + allowed_fields: ["effort"], + defaults_only: false, + }, + }); + }); + + it("treats environment authentication as credential-required", () => { + const previous = process.env.CUSTOM_PROVIDER_TEST_KEY; + const previousModelsUrl = process.env.CUSTOM_PROVIDER_TEST_MODELS_URL; + process.env.CUSTOM_PROVIDER_TEST_KEY = "test-key"; + process.env.CUSTOM_PROVIDER_TEST_MODELS_URL = + " http://models.example/models"; + try { + const registry = new ProviderRegistry([ + { + id: "env-provider", + name: "Environment Provider", + protocol: "openai-chat", + baseUrl: "https://example.test/v1", + auth: { type: "env", variable: "CUSTOM_PROVIDER_TEST_KEY" }, + discoverModels: false, + models: [{ id: "test-model" }], + }, + { + id: "insecure-env-endpoint", + name: "Insecure Environment Endpoint", + protocol: "openai-chat", + baseUrl: "https://example.test/v1", + auth: { type: "env", variable: "CUSTOM_PROVIDER_TEST_KEY" }, + modelsPath: "$" + "{CUSTOM_PROVIDER_TEST_MODELS_URL}", + }, + ]); + expect(registry.get("env-provider")?.requiresKey).toBe(true); + expect(registry.get("insecure-env-endpoint")).toBeNull(); + expect(registry.error("insecure-env-endpoint")?.message).toContain( + "must use HTTPS", + ); + } finally { + if (previous === undefined) { + delete process.env.CUSTOM_PROVIDER_TEST_KEY; + } else { + process.env.CUSTOM_PROVIDER_TEST_KEY = previous; + } + if (previousModelsUrl === undefined) { + delete process.env.CUSTOM_PROVIDER_TEST_MODELS_URL; + } else { + process.env.CUSTOM_PROVIDER_TEST_MODELS_URL = previousModelsUrl; + } + } + }); +}); diff --git a/tests/OpenAIToolSchemas.test.ts b/tests/OpenAIToolSchemas.test.ts new file mode 100644 index 0000000..dbc8a70 --- /dev/null +++ b/tests/OpenAIToolSchemas.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import type { OpenAITool } from "../src/core/tools/schema.ts"; +import { compatibleOpenAITools } from "../src/providers/byok/openAIToolSchemas.ts"; + +describe("OpenAI-compatible tool schemas", () => { + it("inlines local references and safely breaks recursive loops", () => { + const tools: OpenAITool[] = [ + { + type: "function", + function: { + name: "agent", + description: "Run an agent", + parameters: { + type: "object", + properties: { + payload: { $ref: "#/$defs/JsonValue" }, + }, + required: ["payload"], + $defs: { + JsonValue: { + anyOf: [ + { type: "string" }, + { + type: "object", + additionalProperties: { + $ref: "#/$defs/JsonValue", + }, + }, + ], + }, + }, + }, + }, + }, + ]; + + const compatible = compatibleOpenAITools(tools); + const serialized = JSON.stringify(compatible); + expect(serialized).not.toContain('"$ref"'); + expect(serialized).not.toContain('"$defs"'); + expect(compatible[0]?.function.parameters).toMatchObject({ + type: "object", + required: ["payload"], + properties: { + payload: { + anyOf: [ + { type: "string" }, + { + type: "object", + additionalProperties: {}, + }, + ], + }, + }, + }); + expect(JSON.stringify(tools)).toContain('"$ref"'); + }); +}); diff --git a/tests/ProviderKeyStore.test.ts b/tests/ProviderKeyStore.test.ts index 84eeb9f..d4b4177 100644 --- a/tests/ProviderKeyStore.test.ts +++ b/tests/ProviderKeyStore.test.ts @@ -92,19 +92,20 @@ describe("provider key store", () => { expect(readProviderKeys(home)).toEqual({}); }); - it("ignores unknown providers and malformed entries", async () => { + it("preserves custom providers and ignores malformed ids and entries", async () => { const home = await tempHome(); await Bun.write( providerKeysPath(home), JSON.stringify({ anthropic: { key: "sk-ant-ok", enabled: true }, bogus: { key: "sk-nope", enabled: true }, + "not a provider": { key: "sk-invalid", enabled: true }, openai: { enabled: true }, google: "not-an-object", }), ); - expect(Object.keys(readProviderKeys(home))).toEqual(["anthropic"]); + expect(Object.keys(readProviderKeys(home))).toEqual(["anthropic", "bogus"]); }); it("never writes the secret in readable form", async () => { diff --git a/tests/Thinking.test.ts b/tests/Thinking.test.ts index f15b3ea..fb78a54 100644 --- a/tests/Thinking.test.ts +++ b/tests/Thinking.test.ts @@ -79,6 +79,23 @@ describe("thinking config", () => { ).toEqual({ max_tokens: 8192 }); }); + it("uses provider-supplied budget policies for custom provider ids", () => { + expect( + resolveThinking({ + intent: { kind: "level", level: "medium" }, + model: { provider: "custom-anthropic", model: "claude-sonnet-4-5" }, + metadata: metadata(["budget_tokens"], { + thinking_controls: { + supported: true, + allowed_fields: ["budget_tokens"], + defaults_only: false, + budget_policy: "anthropicLegacy", + }, + }), + }), + ).toEqual({ budget_tokens: 8192 }); + }); + it("routes numeric budgets only to token fields", () => { expect( resolveThinking({ diff --git a/tests/UICommands.test.ts b/tests/UICommands.test.ts index 21989ec..2b05776 100644 --- a/tests/UICommands.test.ts +++ b/tests/UICommands.test.ts @@ -40,6 +40,12 @@ describe("parseCommand", () => { expect(parseCommand("/settings")).toEqual({ type: "settings" }); }); + it("parses providers and keeps keys as an alias", () => { + expect(parseCommand("/providers")).toEqual({ type: "providers" }); + expect(parseCommand("/keys")).toEqual({ type: "providers" }); + expect(parseCommand("/apikeys")).toEqual({ type: "providers" }); + }); + it("resolves /config as an alias of settings", () => { expect(parseCommand("/config")).toEqual({ type: "settings" }); }); @@ -115,6 +121,7 @@ describe("parseCommand", () => { "cua", "browser", "discover", + "providers", ] as const) { expect(canRunCommandAfterSessionEnd(type)).toBe(true); }