/** * Code Agent BYOK provider register (MIN-46). Shared client + * server (NO import server-only): config wizard (UI), route * ai-keys, key resolution (`lib/server/agent/model.ts`), and loop * (`agent-loop.ts`) all rely on this. * * Principle: providers are addressed via the OpenAI-compatible * `/chat/completions` endpoint with `Authorization: Bearer `, * except Anthropic, whose compatibility layer cannot carry Claude's reasoning: * its calls go to the native `/messages` and are converted back to * the chat shape (`lib/server/anthropic-messages.ts`). OpenAI and Gemini expose * such a layer; the “generic” provider * covers any OpenAI-compatible server via a URL base entered by * the user. The particularities per provider (attribution headers, * usage count, API version) are carried by `requestProfile` and * applied in the loop. */ export type AgentProviderId = | "openrouter" | "openai" | "anthropic" | "google" /** OpenCode Go subscription, served by the OpenCode Zen gateway (MIN-544). */ | "opencode-go" /** OpenCode Zen pay-as-you-go gateway — same API as Go, other base path (MIN-544). */ | "opencode-zen" | "generic" /** Endpoint OpenAI-compatible atteint seulement depuis l'app de bureau. */ | "local_openai" /** Ollama, via sa couche OpenAI-compatible `/v1`. */ | "ollama"; /** Adjustments to the chat request by provider (OpenAI compat variable). */ export interface ProviderRequestProfile { /** Sends `usage: { include: true }` (OpenRouter cost count). */ usageAccounting?: boolean; /** Sends `stream_options: { include_usage: true }` (tokens in the stream). */ streamUsage?: boolean; /** Wire name of the output ceiling. Surfaces speak `maxOutputTokens`. */ outputTokenField: "max_tokens" | "max_completion_tokens"; /** Default ceiling when a surface does not set one. */ defaultMaxOutputTokens?: number; /** Adds OpenRouter attribution headers (HTTP-Referer / X-Title). */ attribution?: boolean; /** * Marks the system prompt with a cache breakpoint `cache_control:{ephemeral}`. * Reserved for providers who accept it: OpenRouter transmits it to models which * support the caching prompt (big gain on Claude) and ignores it without error * elsewhere. NOT to be activated for direct OpenAI/Anthropic/Gemini * compat layers (risk of field rejection). */ promptCaching?: boolean; /** * Form of the reasoning field accepted by this endpoint (MIN-122): the ONLY * gate of the feature — a provider without this field never sends any. * `reasoning` → `reasoning: { effort }` (OpenRouter). * `reasoning_effort` → `reasoning_effort` flat (OpenAI and Gemini). * `thinking` → adaptive/manual model-aware form (Anthropic). * The generic remains silent: its base URL is an unknown server, and a field * refused returns to 400. The levels and their translation: lib/agent-reasoning.ts. */ reasoningField?: "reasoning" | "reasoning_effort" | "thinking"; } export interface AgentProviderDef { id: AgentProviderId; label: string; /** Base URL OpenAI-compatible (sans `/chat/completions`). Absent = base saisie (generic). */ baseUrl?: string; /** true → the user MUST provide a base URL (generic). */ requiresBaseUrl?: boolean; /** * URL offered when installing a local endpoint. This is a draft UI, * never a server-side fallback: an explicitly registered URL remains * required to launch a run. */ localDefaultBaseUrl?: string; /** * Default model (border) of this provider in BYOK — pre-filled in the * selector and used as a resolution fallback (see `resolveAgentModel`). * Absent for OpenRouter (takes the root default = quota minddy) and for the * generic (namespace unknown → the user must choose). * * FALLBACK only: an admin changes it from /admin without redeployment (key * `byok_default_model_`, cf. `resolveProviderDefaultModel`). What * really works is read there, not here. */ defaultModel?: string; /** * “Sample” template id to derive the logo via `providerFromModel` * (reuses @lobehub/icons mapping). "" = no logo (generic → Cpu). */ logoModel: string; /** Model listing strategy (see /api/agent/models route). */ listStrategy: "openrouter" | "openai" | "anthropic" | "generic" | "none"; /** * The address designates the user's machine or network. It should * never be probed by the server nor be chosen for a cloud microVM. */ localOnly?: boolean; /** OpenAI-compatible segment added when the local server is waiting for a root. */ localBaseUrlSuffix?: string; requestProfile: ProviderRequestProfile; /** Link to the key generation page (wizard help). */ keysUrl?: string; } export const AGENT_PROVIDERS: AgentProviderDef[] = [ { id: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1", // No own default: takes over the platform settings of each call type, // exactly like the minddy quota (same OpenRouter endpoint). logoModel: "openrouter/x", listStrategy: "openrouter", requestProfile: { usageAccounting: true, streamUsage: true, outputTokenField: "max_completion_tokens", defaultMaxOutputTokens: 8192, attribution: true, promptCaching: true, reasoningField: "reasoning", }, keysUrl: "https://openrouter.ai/keys", }, { id: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1", defaultModel: "gpt-5.6-sol", logoModel: "openai/x", listStrategy: "openai", requestProfile: { streamUsage: true, outputTokenField: "max_completion_tokens", reasoningField: "reasoning_effort", }, keysUrl: "https://platform.openai.com/api-keys", }, { id: "anthropic", label: "Anthropic", baseUrl: "https://api.anthropic.com/v1", defaultModel: "claude-sonnet-5", logoModel: "anthropic/x", listStrategy: "anthropic", // Native Messages API (MIN-826): the ceiling is `max_tokens`, usage is // always reported in the stream, and reasoning keeps the Anthropic form. requestProfile: { outputTokenField: "max_tokens", defaultMaxOutputTokens: 8192, reasoningField: "thinking", }, keysUrl: "https://console.anthropic.com/settings/keys", }, { id: "google", label: "Google (Gemini)", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", defaultModel: "gemini-3.5-flash", logoModel: "google/x", listStrategy: "openai", requestProfile: { streamUsage: true, outputTokenField: "max_completion_tokens", reasoningField: "reasoning_effort", }, keysUrl: "https://aistudio.google.com/apikey", }, { // OpenCode Go is a $10/month subscription of the OpenCode Zen gateway: // same console, same API, another base path (`/zen/go/v1`). Every model // the gateway lists is served through OpenAI-compatible // `/chat/completions` (models.dev declares both providers on // @ai-sdk/openai-compatible), so the uniform loop/VM transport works as-is. // The `/models` listing is PUBLIC: it feeds the catalog but cannot probe // a key — see `probeRequestFor` (byok-validate.ts). id: "opencode-go", label: "OpenCode Go", baseUrl: "https://opencode.ai/zen/go/v1", defaultModel: "glm-5.3-flash", logoModel: "opencode/x", listStrategy: "openai", // Conservative profile: the gateway tolerance for reasoning fields is not // verified, so direct calls send none (the agent harness is unaffected: // the opencode VM path nests `reasoning: {effort}` for every provider). requestProfile: { streamUsage: true, outputTokenField: "max_tokens", }, keysUrl: "https://opencode.ai/auth", }, { // OpenCode Zen is the pay-as-you-go twin of Go: same console, same API // shape, base path `/zen/v1`. id: "opencode-zen", label: "OpenCode Zen", baseUrl: "https://opencode.ai/zen/v1", defaultModel: "glm-5.3-flash", logoModel: "opencode/x", listStrategy: "openai", requestProfile: { streamUsage: true, outputTokenField: "max_tokens", }, keysUrl: "https://opencode.ai/auth", }, { id: "generic", label: "Générique (OpenAI-compatible)", requiresBaseUrl: true, logoModel: "", listStrategy: "generic", // A generic server can only implement the old Chat contract // Completions. No provider extensions are sent there. requestProfile: { outputTokenField: "max_tokens" }, }, { id: "local_openai", label: "Endpoint local (OpenAI-compatible)", requiresBaseUrl: true, // Default port of LM Studio, the local OpenAI-compatible implementation // more widespread. The user can of course replace it. localDefaultBaseUrl: "http://127.0.0.1:1234/v1", logoModel: "", listStrategy: "none", localOnly: true, // No optional field: an unknown local endpoint is the most // conservatrice du contrat Chat Completions. requestProfile: { outputTokenField: "max_tokens" }, }, { id: "ollama", label: "Ollama (local)", requiresBaseUrl: true, localDefaultBaseUrl: "http://127.0.0.1:11434", logoModel: "", listStrategy: "none", localOnly: true, // Ollama exposes its Chat Completions under `/v1`, while its usual URL // is the root `http://127.0.0.1:11434`. localBaseUrlSuffix: "/v1", requestProfile: { outputTokenField: "max_tokens" }, }, ]; /** Default Provider when no BYOK: OpenRouter platform key. */ export const DEFAULT_AGENT_PROVIDER: AgentProviderId = "openrouter"; export function getAgentProvider(id: string | null | undefined): AgentProviderDef | undefined { return AGENT_PROVIDERS.find((p) => p.id === id); } export function isKnownAgentProvider(id: string): id is AgentProviderId { return AGENT_PROVIDERS.some((p) => p.id === id); } /** A local endpoint can only be reached by the desktop app harness. */ export function isLocalAgentProvider(id: string | null | undefined): boolean { return getAgentProvider(id)?.localOnly === true; } /** Normalizes a URL base: trim, removes the final `/` and a pasted `/chat/completions`. */ export function normalizeBaseUrl(raw: string): string { return raw .trim() .replace(/\/+$/, "") .replace(/\/chat\/completions$/i, ""); } /** * Effective URL base of a provider: that of the registry, or the custom (generic). * Returns null if generic without URL base, or unknown provider. */ export function resolveProviderBaseUrl( providerId: string, customBaseUrl?: string | null, ): string | null { const def = getAgentProvider(providerId); if (!def) return null; const raw = def.requiresBaseUrl ? (customBaseUrl ?? "").trim() : (def.baseUrl ?? ""); if (!raw) return null; const normalized = normalizeBaseUrl(raw); if (!def.localBaseUrlSuffix) return normalized; const suffix = def.localBaseUrlSuffix.replace(/^\/+/, ""); return normalized.toLowerCase().endsWith(`/${suffix.toLowerCase()}`) ? normalized : `${normalized}/${suffix}`; } /** OpenAI-compatible chat completion URL for a given database. */ export function chatCompletionsUrl(baseUrl: string): string { return `${normalizeBaseUrl(baseUrl)}/chat/completions`; }