diff --git a/config.ts b/config.ts index 860b571..daf0b4e 100644 --- a/config.ts +++ b/config.ts @@ -111,6 +111,9 @@ export const GRAPH_FORMAT_FILE = path.join(PATHS.graph, "format.json"); */ export const SETTINGS_FILE = path.join(DATA_ROOT, "settings.json"); +/** Explicit custom-provider/model definitions; never read the user's Pi profile. */ +export const CUSTOM_MODELS_FILE = path.join(DATA_ROOT, "models.json"); + /** * User prompt-override files (settings-storage wave, 2026-09-19): the * per-segment system-prompt overrides mirroring the SOURCE layout diff --git a/docs/custom-providers.md b/docs/custom-providers.md new file mode 100644 index 0000000..bd6901d --- /dev/null +++ b/docs/custom-providers.md @@ -0,0 +1,159 @@ +# Custom model providers + +LearnHarness can register several independently named custom providers using its +existing Pi AI runtime. This is configuration wiring, not another agent runtime: +Pi owns the model protocols, request conversion, streaming, tools, thinking and +usage accounting. There is no additional dependency on Pi coding-agent. + +## Configuration location + +Create `models.json` in the LearnHarness app-data directory (the directory +containing `settings.json`; `LH_DATA_DIR` relocates it). LearnHarness does not read +`~/.pi/agent/models.json`, install a model server, or edit this file for you. +Custom-provider setup UI is not included yet. Once configured, models appear in +the existing model selectors when their credential/header references resolve. + +The file uses Pi's `providers` / `models` structure. For example: + +```json +{ + "providers": { + "my-local-server": { + "baseUrl": "http://127.0.0.1:11434/v1", + "api": "openai-completions", + "apiKey": "unused-local-key", + "compat": { + "supportsDeveloperRole": false, + "supportsReasoningEffort": false, + "maxTokensField": "max_tokens" + }, + "models": [ + { + "id": "your-installed-model", + "name": "My local model", + "contextWindow": 8192, + "maxTokens": 1024, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + } + } + ] + }, + "my-gateway": { + "baseUrl": "https://your-gateway.example/v1", + "api": "openai-completions", + "apiKey": "$MY_GATEWAY_API_KEY", + "headers": { "X-Tenant": "${MY_GATEWAY_TENANT}" }, + "models": [ + { + "id": "your-gateway-model-id", + "contextWindow": 32768, + "maxTokens": 4096, + "cost": { + "input": 1, + "output": 3, + "cacheRead": 0, + "cacheWrite": 0 + } + } + ] + } + } +} +``` + +**Replace all example IDs, limits, prices and compatibility flags with the actual +server/model configuration.** These numbers are examples, not recommendations or +automatically detected capabilities. Rates are USD per million tokens. Explicit +zero rates are allowed; LearnHarness does not assume that custom gateways are +free. Verify the endpoint's billing independently. + +Use the exact API base URL your server documents. LearnHarness does not append +`/v1`, discover models, infer capabilities from names, or download models. Ollama, +vLLM, LM Studio, 9router and other gateways work only to the extent that their +configured endpoint implements the selected Pi protocol and compatibility flags. +For a keyless OpenAI-compatible local server, supply an explicit nonempty dummy +`apiKey` as above: the native Pi OpenAI client requires a key. Omitting `apiKey` +leaves a provider unavailable rather than implying unauthenticated access. + +## Supported fields + +Provider fields: `name`, `baseUrl`, `api`, `apiKey`, `headers`, `authHeader`, +`compat`, and the explicit `models` array. Each model requires `id`, +`contextWindow`, `maxTokens`, and `cost` with all four base rates. Unlike Pi +coding-agent's defaults, LearnHarness never invents limits or prices. + +Optional Pi model fields are `name`, `api`, `baseUrl`, `reasoning`, `input`, +`thinkingLevelMap`, `inputLimits`, `promptCache`, `samplingParams`, +`samplingParamsByThinkingLevel`, `headers`, and `compat`. Cost tiers and Pi's +exported compatibility schemas are supported. A model's `api` and `baseUrl` +override its provider's values; model headers override provider headers. Names +default to model IDs, `reasoning` defaults to false, and `input` defaults to +`["text"]`. Declare image input only when the server/model supports it. Existing +onboarding and vision-slot requirements are unchanged. + +The native chat transports available in the pinned Pi AI 1.4.0 dependency are: +`openai-completions`, `openai-responses`, `anthropic-messages`, +`google-generative-ai`, `azure-openai-responses`, `google-vertex`, +`bedrock-converse-stream`, `mistral-conversations`, `openai-codex-responses`, and +`pi-messages`. Protocol availability does **not** establish account access, +OAuth support, cloud credentials, subscription permission, or vendor approval. +This feature adds no login flows. In particular, an Anthropic Messages endpoint +is not a Claude Agent SDK or Claude Code runtime. + +Use new provider IDs; built-in and subscription-route IDs cannot be replaced. +Provider IDs must be nonempty and cannot contain `:` (the provider/model +coordinate separator). Model IDs must be unique within a provider. Built-in +provider overrides, `modelOverrides`, OAuth definitions, executable plugins, and +automatic discovery are outside this phase. + +## Credentials and trust + +API keys and header values accept literals, `$NAME`, and `${NAME}`. References +use LearnHarness's existing key lookup: environment first, then the existing +flat `auth.json` file, respecting the fresh-user environment-ignore setting. +There is no auth migration or new secret file format. Prefer references over +putting secrets into `models.json`; protect any file containing literal secrets. +Unknown variable references keep the provider unavailable. All required header +references, including model-specific ones, must resolve before a provider becomes +available. An empty key does not configure a provider. + +Pi's `$$` and `$!` literal escapes are supported. `!command` credential/header +values are rejected, never executed; resolved environment/file values are data, +not commands. Optional `authHeader: true` adds `Authorization: Bearer ` in +addition to the selected native transport's normal authentication behavior. + +Only trusted people should edit this file: its endpoints receive model prompts, +attachments, tool results and configured credentials. Only HTTP(S) URLs are +accepted, without embedded URL credentials or fragments. This is not an +arbitrary-endpoint discovery or config-editing API and does not add a network +sandbox, endpoint reachability checks, or vendor policy exemptions. + +## Reloading and failures + +Changes take effect on the next catalog, settings-validation, or inference +operation without restarting. Credential/header reference changes are also +picked up through the existing key lookup. Every upstream model operation +revalidates the same saved provider/model ID against the current Pi catalog, +including later requests inside a long-running agent/tool loop. It uses the +current endpoint, model metadata and resolved credentials. A removed model, +invalid configuration or missing credential blocks the next operation, without +falling back. An HTTP operation already in flight retains its own native Pi +setup and is not implicitly cancelled by a file edit. + +Deleting the file means no custom providers. Invalid JSON, invalid metadata, +reserved IDs, unsupported configuration, or an unreadable file disables the +custom routes, **not** built-in providers. The file accepts Pi's line comments +and trailing commas. `GET /api/models` includes an optional sanitized +`customProvidersError` diagnostic; rendering it in setup/settings UI is deferred. +Correcting the file restores valid providers on the next operation. + +Saved selections and thinking preferences are not erased when a provider/model +is removed or becomes unavailable. New work using an unavailable selection fails +rather than falling back to another model. Select another configured model to +repair the slot. Custom-provider removal does not rewrite historical records; +like existing API-key models, historical cost estimates use the current catalog +and become unknown when the relevant model definition is unavailable. diff --git a/scripts/smoke-provider-runtime.mjs b/scripts/smoke-provider-runtime.mjs index 47c12c6..7cafc1d 100644 --- a/scripts/smoke-provider-runtime.mjs +++ b/scripts/smoke-provider-runtime.mjs @@ -1,7 +1,7 @@ /** - * OFFLINE native Pi Google initialization gate (no generation or provider calls). - * Unlike /api/models, this traverses Pi's lazy adapter and constructs GoogleGenAI. - * The real onPayload hook throws a unique sentinel BEFORE generateContentStream. + * OFFLINE native Pi Google/Bedrock initialization gate (no generation or provider calls). + * Unlike /api/models, this traverses Pi's lazy adapters and constructs real SDK clients. + * The real onPayload hook throws a unique sentinel BEFORE SDK request dispatch. * All file modules must resolve inside the payload, including nested dependencies. * * node scripts/smoke-provider-runtime.mjs [--standalone ] [--runtime ] @@ -81,17 +81,22 @@ async function probe(standalone) { const entry = pathToFileURL(path.join(root, "node_modules/@earendil-works/pi-ai/dist/index.js")); const { createModels } = await import(entry.href); const models = createModels(); - for (const [name, factory] of [["google", "googleProvider"], ["opencode", "opencodeProvider"], ["opencode-go", "opencodeGoProvider"]]) { + for (const [name, factory, api, label] of [ + ["google", "googleProvider", "google-generative-ai", "Google"], + ["opencode", "opencodeProvider", "google-generative-ai", "Google"], + ["opencode-go", "opencodeGoProvider", "google-generative-ai", "Google"], + ["amazon-bedrock", "amazonBedrockProvider", "bedrock-converse-stream", "Bedrock"], + ]) { const provider = (await import(new URL(`providers/${name}.js`, entry).href))[factory](); models.setProvider(provider); - const model = provider.getModels().find((candidate) => candidate.api === "google-generative-ai"); - assert.ok(model, `${name}: missing Google-backed catalog model`); + const model = provider.getModels().find((candidate) => candidate.api === api); + assert.ok(model, `${name}: missing ${api} catalog model`); const sentinel = `LH_OFFLINE_ON_PAYLOAD_${randomUUID()}`; let reached = 0; const result = await models.streamSimple(model, { messages: [{ role: "user", content: "Offline initialization only", timestamp: 0 }] }, { apiKey: "dummy-offline-provider-gate-not-a-key", onPayload(payload, selected) { - assert.equal(selected.api, "google-generative-ai"); + assert.equal(selected.api, api); assert.ok(payload && typeof payload === "object"); reached++; throw new Error(sentinel); @@ -101,7 +106,7 @@ async function probe(standalone) { assert.equal(result.stopReason, "error"); assert.equal(result.errorMessage, sentinel, `${name}: unexpected initialization error`); noNetwork(); - console.log(`[smoke-provider-runtime] ${name}: native lazy Google adapter reached onPayload; generation stopped`); + console.log(`[smoke-provider-runtime] ${name}: native lazy ${label} adapter reached onPayload; generation stopped`); } // Exercise the public lazy OAuth surface offline. toAuth evaluates the // real flow module and its imports but neither logs in nor refreshes. diff --git a/src/agent/custom-providers.ts b/src/agent/custom-providers.ts new file mode 100644 index 0000000..5c82773 --- /dev/null +++ b/src/agent/custom-providers.ts @@ -0,0 +1,71 @@ +/** Native Pi providers assembled from validated configuration. No IO or registry + * here: the server supplies key lookup, and Pi owns model dispatch/streaming. */ +import { createProvider, type Provider, type ProviderStreams } from "@earendil-works/pi-ai"; +import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy"; +import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy"; +import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy"; +import { openAICodexResponsesApi } from "@earendil-works/pi-ai/api/openai-codex-responses.lazy"; +import { azureOpenAIResponsesApi } from "@earendil-works/pi-ai/api/azure-openai-responses.lazy"; +import { googleGenerativeAIApi } from "@earendil-works/pi-ai/api/google-generative-ai.lazy"; +import { googleVertexApi } from "@earendil-works/pi-ai/api/google-vertex.lazy"; +import { lazyApi } from "@earendil-works/pi-ai/api/lazy"; +import { mistralConversationsApi } from "@earendil-works/pi-ai/api/mistral-conversations.lazy"; +import { piMessagesApi } from "@earendil-works/pi-ai/api/pi-messages.lazy"; +import { resolveCustomValue, type CustomProviderConfig } from "../core/custom-providers.ts"; + +const apis: Record = { + "anthropic-messages": anthropicMessagesApi(), + "openai-completions": openAICompletionsApi(), + "openai-responses": openAIResponsesApi(), + "openai-codex-responses": openAICodexResponsesApi(), + "azure-openai-responses": azureOpenAIResponsesApi(), + "google-generative-ai": googleGenerativeAIApi(), + "google-vertex": googleVertexApi(), + // Pi's Bedrock wrapper intentionally hides its Node-only import from + // bundlers. Give this server's standalone tracer a literal public import; + // Pi still owns both lazy forwarding and the unchanged native transport. + "bedrock-converse-stream": lazyApi(() => import("@earendil-works/pi-ai/api/bedrock-converse-stream")), + "mistral-conversations": mistralConversationsApi(), + "pi-messages": piMessagesApi(), +}; +export const customProviderApis: ReadonlySet = new Set(Object.keys(apis)); + +function resolveHeaders(headers: Record | undefined, lookup: (name: string) => string | undefined): Record | undefined { + const result: Record = {}; + for (const [name, value] of Object.entries(headers ?? {})) { + const resolved = resolveCustomValue(value, lookup); + if (resolved === undefined) return undefined; + result[name] = resolved; + } + return result; +} +export function customProviderAuth(config: CustomProviderConfig, lookup: (name: string) => string | undefined) { + const key = config.definition.apiKey === undefined ? undefined : resolveCustomValue(config.definition.apiKey, lookup); + const headers = resolveHeaders(config.definition.headers, lookup); + if (!key || !headers) return undefined; + // A model header is part of that model's request configuration. Unresolved + // references must not silently disappear from a request. + for (const model of config.models) if (!resolveHeaders(model.headers, lookup)) return undefined; + if (config.definition.authHeader) { + for (const name of Object.keys(headers)) if (name.toLowerCase() === "authorization") delete headers[name]; + headers.Authorization = `Bearer ${key}`; + } + return { apiKey: key, headers }; +} + +export function createCustomProvider(config: CustomProviderConfig, lookup: (name: string) => string | undefined): Provider { + const auth = customProviderAuth(config, lookup); + return createProvider({ + id: config.id, + name: config.definition.name ?? config.id, + baseUrl: config.definition.baseUrl, + models: config.models.map((model) => ({ ...model, headers: resolveHeaders(model.headers, lookup) })), + auth: { apiKey: { + name: "API key", + check: async () => auth ? { type: "api_key", source: "custom provider configuration" } : undefined, + resolve: async () => auth ? { auth, source: "custom provider configuration" } : undefined, + } }, + // Direct native implementations: no copied stream or request conversion. + api: apis, + }); +} diff --git a/src/agent/models.ts b/src/agent/models.ts index 75c0b7b..69815cf 100644 --- a/src/agent/models.ts +++ b/src/agent/models.ts @@ -5,9 +5,11 @@ import { type Api, type Model, type Models, + type MutableModels, type StreamFunction, } from "@earendil-works/pi-ai"; import { builtinProviders } from "@earendil-works/pi-ai/providers/all"; +import { lazyStream } from "@earendil-works/pi-ai/api/lazy"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { type ModelRole } from "../../config.ts"; import { DEFAULT_THINKING_ORDER } from "../core/settings.ts"; @@ -41,14 +43,31 @@ export const APP_IDENTITY = Object.freeze({ export interface PiEngine { model: Model; streamFn: StreamFunction; - /** The resolved (config-or-settings, CLAMPED) thinking level — - * observability + tests; the streamFn pins it anyway. */ + /** The initially resolved (config-or-settings, CLAMPED) thinking level. + * Custom streams re-resolve the original preference per operation. */ level?: ThinkingLevel; } export type { ThinkingLevel }; let collection: ReturnType | undefined; +interface ProviderSource { + sync(models: MutableModels): void; + isCustom(id: string): boolean; +} +let providerSource: ProviderSource | undefined; + +/** Configuration IO is injected by the server; the agent never imports stores. */ +export function configureProviderSource(source: ProviderSource): void { + // Server module re-evaluation can replace the source while this collection + // survives. Retire only the previous source's routes, not built-ins. + if (collection && providerSource) { + for (const provider of collection.getProviders()) { + if (providerSource.isCustom(provider.id)) collection.deleteProvider(provider.id); + } + } + providerSource = source; +} /** The process-wide pi-ai Models collection — EVERY builtin provider's * catalog (model slots wave, 2026-09-20): engines and the picker resolve @@ -63,6 +82,7 @@ export function piModels() { collection = createModels({ identity: APP_IDENTITY }); for (const provider of builtinProviders()) collection.setProvider(provider); } + providerSource?.sync(collection); return collection; } @@ -108,19 +128,37 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | ( // UNSET level → the baked default order (first entry the model // supports); SET level → pi's native nearest-clamp (model swaps keep // the user's intent and adapt honestly). - const level = spec.thinkingLevel - ? clampThinkingLevel(model, spec.thinkingLevel) + const thinkingPreference = spec.thinkingLevel; + const level = thinkingPreference + ? clampThinkingLevel(model, thinkingPreference) : defaultThinkingLevel(model); + // A lesson's agent loop can stay alive through many tool calls. Remember + // only that this is a custom route, never its configuration for that run. + const custom = providerSource?.isCustom(spec.provider) ?? false; return { model, level, - // bound streamSimple: the agent loop forwards state.thinkingLevel as - // `reasoning` (undefined = off on pi-ai's scale); the clamp above keeps - // the pin honest even when config or settings drift off the map. + // The agent loop forwards state.thinkingLevel as `reasoning` (undefined + // = off on pi-ai's scale). Built-ins keep the factory clamp; custom + // requests re-resolve thinking against their current metadata below. streamFn: (requestModel, context, options) => { + if (custom) { + // Revalidate before EVERY upstream model operation, including later + // tool-loop steps. Resolve thinking from the ORIGINAL preference (or + // existing default order), not a previous metadata snapshot's clamp. + // Pi retains the setup of an operation already in flight. + const current = piModels(); + const currentModel = current.getModel(spec.provider, spec.id); + if (!currentModel) return lazyStream(requestModel, async () => { throw new Error("The selected custom model is unavailable."); }); + const currentLevel = thinkingPreference + ? clampThinkingLevel(currentModel, thinkingPreference) + : defaultThinkingLevel(currentModel); + return current.streamSimple(currentModel, context, { ...options, reasoning: currentLevel === "off" ? undefined : currentLevel }); + } + const requestOptions = { ...options, reasoning: level === "off" ? undefined : level }; const purpose = typeof background === "function" ? background : () => background; const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models); - return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level }); + return stream(requestModel, context, requestOptions); }, }; } diff --git a/src/core/contracts/models.ts b/src/core/contracts/models.ts index 2819bb5..7b9e9e7 100644 --- a/src/core/contracts/models.ts +++ b/src/core/contracts/models.ts @@ -73,13 +73,15 @@ export interface ModelCatalogResponse { * are simply absent (the Keys page is the discovery surface, same * discipline as the search engines). */ providers: CatalogProvider[]; + /** Sanitized configuration diagnostic; built-in catalogs remain available. */ + customProvidersError?: string; } /** One row of the providers surface. */ export interface CatalogProvider { id: string; - /** The env var whose presence federates this provider in. */ + /** The gating env var; empty for subscription and file-configured routes. */ envVar: string; - /** Whether that var is currently present (env or auth file). */ + /** Whether credentials are present (and custom references resolve). */ keyPresent: boolean; } \ No newline at end of file diff --git a/src/core/custom-providers.ts b/src/core/custom-providers.ts new file mode 100644 index 0000000..9aaed27 --- /dev/null +++ b/src/core/custom-providers.ts @@ -0,0 +1,146 @@ +/** Declarative custom-provider configuration. The envelope/normalization follows + * Pi coding-agent 1.4.0's model-config/provider-composer (MIT, Mario Zechner). + * Pi AI owns the shared model/compatibility schemas and the runtime models. + * Deliberate differences: new provider IDs only, explicit limits/prices, + * and no OAuth, extensions, overrides, or executable credential commands. + * + * Adapted portions: Copyright (c) Mario Zechner. Permission is hereby granted, + * free of charge, to any person obtaining a copy of this software and associated + * documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, + * merge, publish, distribute, sublicense, and/or sell copies of the Software, + * and to permit persons to whom the Software is furnished to do so, subject to + * the following conditions: The above copyright notice and this permission + * notice shall be included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. */ +import { Type, type Static } from "typebox"; +import { Compile } from "typebox/compile"; +import type { Api, Model } from "@earendil-works/pi-ai"; +import { ProviderCompatSchema } from "@earendil-works/pi-ai/providers/compat-schema"; +import { ModelCostSchema, ModelInputLimitsSchema, ModelInputModalitySchema, ModelPromptCacheSchema, ThinkingLevelMapSchema } from "@earendil-works/pi-ai/providers/model-schema"; + +const Headers = Type.Record(Type.String(), Type.String()); +const Sampling = Type.Record(Type.String(), Type.Unknown()); +const ModelSchema = Type.Object({ + id: Type.String({ minLength: 1 }), + name: Type.Optional(Type.String({ minLength: 1 })), + api: Type.Optional(Type.String({ minLength: 1 })), + baseUrl: Type.Optional(Type.String({ minLength: 1 })), + reasoning: Type.Optional(Type.Boolean()), + thinkingLevelMap: Type.Optional(ThinkingLevelMapSchema), + input: Type.Optional(Type.Array(ModelInputModalitySchema)), + inputLimits: Type.Optional(ModelInputLimitsSchema), + cost: ModelCostSchema, + promptCache: Type.Optional(ModelPromptCacheSchema), + contextWindow: Type.Number({ exclusiveMinimum: 0 }), + maxTokens: Type.Number({ exclusiveMinimum: 0 }), + samplingParams: Type.Optional(Sampling), + samplingParamsByThinkingLevel: Type.Optional(Type.Object(Object.fromEntries( + ["off", "minimal", "low", "medium", "high", "xhigh", "max"].map((level) => [level, Type.Optional(Sampling)]), + ))), + headers: Type.Optional(Headers), + compat: Type.Optional(ProviderCompatSchema), +}, { additionalProperties: false }); +const ProviderSchema = Type.Object({ + name: Type.Optional(Type.String({ minLength: 1 })), + baseUrl: Type.Optional(Type.String({ minLength: 1 })), + api: Type.Optional(Type.String({ minLength: 1 })), + apiKey: Type.Optional(Type.String({ minLength: 1 })), + headers: Type.Optional(Headers), + compat: Type.Optional(ProviderCompatSchema), + authHeader: Type.Optional(Type.Boolean()), + models: Type.Array(ModelSchema), +}, { additionalProperties: false }); +const ConfigSchema = Type.Object({ + $schema: Type.Optional(Type.String()), + providers: Type.Record(Type.String(), ProviderSchema), +}, { additionalProperties: false }); +const validator = Compile(ConfigSchema); +export type CustomProviderDefinition = Static; +export interface CustomProviderConfig { + id: string; + definition: CustomProviderDefinition; + models: Model[]; +} + +/** Error messages describe fields only: never include file contents or values. */ +export class CustomProviderConfigError extends Error {} +function invalid(message: string): never { throw new CustomProviderConfigError(message); } + +/** Pi-style values, but never execute commands. Escapes are processed once; + * a literal/variable's resolved value beginning with ! is still just data. */ +export function resolveCustomValue(value: string, lookup: (name: string) => string | undefined): string | undefined { + if (value.startsWith("!")) invalid("Credential commands are not supported in models.json."); + let missing = false; + const resolved = value.replace(/\$\$|\$!|\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, braced: string | undefined, plain: string | undefined) => { + if (match === "$$") return "$"; + if (match === "$!") return "!"; + const found = lookup(braced ?? plain!); + if (found === undefined) missing = true; + return found ?? ""; + }); + return missing ? undefined : resolved; +} + +function endpoint(value: string | undefined): string { + if (!value) invalid("Each custom model needs an api and baseUrl, at provider or model level."); + try { + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash) throw new Error(); + } catch { invalid("Custom baseUrl must be an HTTP(S) URL without embedded credentials or a fragment."); } + return value; +} +function compat(base: Model["compat"], override: Model["compat"]): Model["compat"] { + if (!base && !override) return undefined; + const merged = { ...base, ...override } as Record; + for (const name of ["openRouterRouting", "vercelGatewayRouting", "chatTemplateKwargs", "chatTemplateArgs"]) { + const a = (base as Record | undefined)?.[name]; + const b = (override as Record | undefined)?.[name]; + if (a || b) merged[name] = { ...(a as object | undefined), ...(b as object | undefined) }; + } + return merged as Model["compat"]; +} + +export function parseCustomProviders(text: string, reserved: ReadonlySet, supportedApis: ReadonlySet): CustomProviderConfig[] { + let raw: unknown; + // Match Pi's line comments/trailing commas without touching JSON strings. + const json = text.replace(/^\uFEFF/, "") + .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (match) => match[0] === '"' ? match : "") + .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (match, tail: string | undefined) => tail ?? (match[0] === '"' ? match : "")); + try { raw = JSON.parse(json); } + catch { return invalid("models.json is not valid JSON."); } + if (!validator.Check(raw)) invalid("Invalid models.json structure: check provider/model fields and explicit contextWindow, maxTokens and cost."); + return Object.entries(raw.providers).map(([id, definition]) => { + if (!id.trim() || id.includes(":")) invalid("Custom provider IDs must be nonempty and must not contain a colon."); + if (reserved.has(id)) invalid("Custom provider IDs must not replace built-in or subscription providers."); + for (const value of [definition.apiKey, ...Object.values(definition.headers ?? {}), ...definition.models.flatMap((m) => Object.values(m.headers ?? {}))]) { + if (value !== undefined) resolveCustomValue(value, () => "validation"); + } + const ids = new Set(); + const models = definition.models.map((model): Model => { + if (ids.has(model.id)) invalid("Duplicate model IDs are not supported within a custom provider."); + ids.add(model.id); + const api = model.api ?? definition.api; + if (!api || !supportedApis.has(api)) invalid("A custom model specifies a missing or unsupported Pi API."); + const rates = [model.cost, ...(model.cost.tiers ?? [])]; + if (![model.contextWindow, model.maxTokens].every(Number.isFinite) || rates.some((rate) => Object.values(rate).some((n) => typeof n === "number" && (!Number.isFinite(n) || n < 0)))) invalid("Custom model limits and costs must be finite; limits must be positive and costs nonnegative."); + return { + ...model, + provider: id, + api, + baseUrl: endpoint(model.baseUrl ?? definition.baseUrl), + name: model.name ?? model.id, + reasoning: model.reasoning ?? false, + input: model.input ?? ["text"], + compat: compat(definition.compat, model.compat), + }; + }); + return { id, definition, models }; + }); +} diff --git a/src/server/custom-providers.ts b/src/server/custom-providers.ts new file mode 100644 index 0000000..c597403 --- /dev/null +++ b/src/server/custom-providers.ts @@ -0,0 +1,90 @@ +/** Configuration composition only. Pi's Models collection is the one runtime + * registry. A failed reload removes custom routes, never alters built-ins. */ +import type { MutableModels } from "@earendil-works/pi-ai"; +import { builtinProviders } from "@earendil-works/pi-ai/providers/all"; +import { createCustomProvider, customProviderApis, customProviderAuth } from "../agent/custom-providers.ts"; +import { configureProviderSource } from "../agent/models.ts"; +import { parseCustomProviders, resolveCustomValue, CustomProviderConfigError, type CustomProviderConfig } from "../core/custom-providers.ts"; +import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts"; +import { readCustomProvidersFile } from "../store/custom-providers.ts"; + +const reserved = new Set([...builtinProviders().map((p) => p.id), "chatgpt", ...NATIVE_SUBSCRIPTIONS.map((p) => p.logicalId)]); + +/** Instance-local so every Next server module graph registers into its own Pi + * collection. No process-global registry or personal Pi config is consulted. */ +export class CustomProviderSource { + private text?: string; + private fingerprint?: string; + private configs: CustomProviderConfig[] = []; + private registered = new Set(); + private ready = new Map(); + private error?: string; + + constructor( + private readonly read: typeof readCustomProvidersFile, + private readonly key: (name: string) => string | undefined, + ) {} + + sync(models: MutableModels): void { + try { + const record = this.read(); + if (record.error) throw new CustomProviderConfigError(record.error); + if (record.text !== this.text || this.error) { + this.configs = parseCustomProviders(record.text!, reserved, customProviderApis); + this.text = record.text; + this.fingerprint = undefined; + } + // Resolve only referenced variables through the app's existing key seam. + // Changes in credentials/headers trigger a new immutable provider snapshot + // even when the configuration file itself has not changed. + const values = new Map(); + for (const config of this.configs) { + for (const template of [config.definition.apiKey, ...Object.values(config.definition.headers ?? {}), ...config.models.flatMap((m) => Object.values(m.headers ?? {}))]) { + if (template !== undefined) resolveCustomValue(template, (name) => { + const value = this.key(name); + values.set(name, value); + return value; + }); + } + } + const fingerprint = JSON.stringify([...values]); + if (this.fingerprint === fingerprint) return; + const lookup = (name: string) => values.get(name); + const providers = this.configs.map((config) => createCustomProvider(config, lookup)); + const ready = new Map(this.configs.map((config) => [config.id, customProviderAuth(config, lookup) !== undefined])); + // Everything validates/builds before the synchronous publication begins. + for (const id of this.registered) models.deleteProvider(id); + this.registered = new Set(providers.map((p) => p.id)); + for (const provider of providers) models.setProvider(provider); + this.ready = ready; + this.fingerprint = fingerprint; + this.error = undefined; + } catch (error) { + for (const id of this.registered) models.deleteProvider(id); + this.registered.clear(); + this.ready.clear(); + this.fingerprint = undefined; + this.error = error instanceof CustomProviderConfigError ? error.message : "Unable to configure custom providers from models.json."; + } + } + + configured(provider: string): boolean | undefined { return this.ready.get(provider); } + providers(): { id: string; envVar: string; keyPresent: boolean }[] { + return [...this.ready].map(([id, keyPresent]) => ({ id, envVar: "", keyPresent })); + } + getError(): string | undefined { return this.error; } +} + +let source: CustomProviderSource | undefined; +export function installCustomProviderSource(key: (name: string) => string | undefined): void { + const next = new CustomProviderSource(readCustomProvidersFile, key); + configureProviderSource({ + sync: (models) => next.sync(models), + isCustom: (id) => next.configured(id) !== undefined, + }); + source = next; +} +/** Call piModels() before these reads to synchronize the snapshot. */ +export function customProviderRows() { return source?.providers() ?? []; } +export function customProviderConfigured(provider: string): boolean | undefined { return source?.configured(provider); } +export function customProvidersError(): string | undefined { return source?.getError(); } diff --git a/src/server/keys.ts b/src/server/keys.ts index 706de52..9a74b5c 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -53,6 +53,8 @@ import { } from "../core/auth-keys.ts"; import { authStore } from "../store/auth.ts"; import { isSubscriptionProvider, subscriptionConnected } from "../agent/subscription-connections.ts"; +import { piModels } from "../agent/models.ts"; +import { customProviderConfigured, installCustomProviderSource } from "./custom-providers.ts"; // The once-per-process boot record — globalThis anchor (HMR re-imports the // module; the snapshot must not re-run and misread fill-gapped vars as real @@ -201,6 +203,9 @@ export function keyValue(id: string): string | undefined { * (gate by what would run). Providers absent from AUTH_PROVIDER_KEYS take * no key at all: nothing to gate, present by definition. */ export function providerKeyPresent(provider: string): boolean { + piModels(); // Synchronize hand-edited custom configuration before admission. + const custom = customProviderConfigured(provider); + if (custom !== undefined) return custom; if (isSubscriptionProvider(provider)) return subscriptionConnected(provider); const info = AUTH_PROVIDER_KEYS.find((p) => p.id === provider); return info ? keyValue(info.envVar) !== undefined : true; @@ -244,3 +249,4 @@ export function deleteKey(id: string): AuthKeyStatus { // Reflect the file into the env at first import (idempotent; the module is // imported by every route/handler path that touches keys). initAuthEnv(); +installCustomProviderSource(keyValue); diff --git a/src/server/model-catalog.ts b/src/server/model-catalog.ts index 11fbe0f..f0d8e8d 100644 --- a/src/server/model-catalog.ts +++ b/src/server/model-catalog.ts @@ -20,6 +20,7 @@ import { clampThinkingLevel, getSupportedThinkingLevels, type Api, type Model } from "@earendil-works/pi-ai"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { piModels } from "../agent/models.ts"; +import { customProviderRows, customProvidersError } from "./custom-providers.ts"; import { chatGPTConnected, chatGPTModel, chatGPTModels, refreshChatGPTModels } from "../agent/chatgpt-bridge.ts"; import { NATIVE_SUBSCRIPTIONS } from "../core/contracts/subscriptions.ts"; import { nativeSubscriptionId, nativeSubscriptionConnected, nativeSubscriptionModel, nativeSubscriptionModels, refreshSubscriptionModels } from "../agent/subscription-bridge.ts"; @@ -135,6 +136,7 @@ export function catalogSlots(): SlotPins { * with keyPresent read env-wins through the keys seam, fresh per call — * saving a key immediately federates the provider into the picker. */ export function catalogProviders(): CatalogProvider[] { + piModels(); return AUTH_PROVIDER_KEYS.map((p) => ({ id: p.id, envVar: p.envVar, @@ -142,6 +144,7 @@ export function catalogProviders(): CatalogProvider[] { })).concat([ { id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() }, ...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })), + ...customProviderRows(), ]).sort((a, b) => a.id.localeCompare(b.id)); } @@ -211,6 +214,7 @@ export function modelCatalog(): ModelCatalogResponse { rolePins: catalogRolePins(), slots: catalogSlots(), providers: catalogProviders(), + ...(customProvidersError() ? { customProvidersError: customProvidersError() } : {}), }; } diff --git a/src/store/custom-providers.ts b/src/store/custom-providers.ts new file mode 100644 index 0000000..2245823 --- /dev/null +++ b/src/store/custom-providers.ts @@ -0,0 +1,12 @@ +/** Read-only adapter. Configuration editing/UI is deliberately a later phase. */ +import fs from "node:fs"; +import { CUSTOM_MODELS_FILE } from "../../config.ts"; + +export function readCustomProvidersFile(): { text?: string; error?: string } { + try { return { text: fs.readFileSync(CUSTOM_MODELS_FILE, "utf8") }; } + catch (error) { + return (error as NodeJS.ErrnoException).code === "ENOENT" + ? { text: '{"providers":{}}' } + : { error: "Unable to read models.json." }; + } +} diff --git a/tests/custom-providers.test.ts b/tests/custom-providers.test.ts new file mode 100644 index 0000000..71e9aa3 --- /dev/null +++ b/tests/custom-providers.test.ts @@ -0,0 +1,325 @@ +import assert from "node:assert/strict"; +import { after, describe, it } from "node:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createModels, normalizeContext, type SimpleStreamOptions } from "@earendil-works/pi-ai"; +import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; +import { parseCustomProviders, resolveCustomValue } from "../src/core/custom-providers.ts"; +import { createCustomProvider, customProviderApis } from "../src/agent/custom-providers.ts"; + +const zero = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; +function model(id = "chat", extra = {}) { + return { id, contextWindow: 8192, maxTokens: 1024, cost: zero, ...extra }; +} +function config(extra = {}, models = [model()]) { + return { providers: { gateway: { baseUrl: "http://example.invalid/custom/v1", api: "openai-completions", apiKey: "dummy", models, ...extra } } }; +} +function parse(value: unknown) { return parseCustomProviders(JSON.stringify(value), new Set(["openai", "chatgpt"]), customProviderApis); } + +const temp = fs.mkdtempSync(path.join(os.tmpdir(), "lh-custom-providers-")); +process.env.LH_DATA_DIR = temp; +process.env.LH_AUTH_FILE = path.join(temp, "auth.json"); +process.env.LH_IGNORE_ENV_KEYS = "1"; +fs.writeFileSync(process.env.LH_AUTH_FILE, JSON.stringify({ GW_KEY: "first-key", GW_HEADER: "first-header" })); +const { CUSTOM_MODELS_FILE, SETTINGS_FILE } = await import("../config.ts"); +const { modelCatalog, validateModelsSettings, clampModelsLevels, catalogContextLimits } = await import("../src/server/model-catalog.ts"); +const { piModels, piEngineForSpec } = await import("../src/agent/models.ts"); +const { providerKeyPresent, keyValue } = await import("../src/server/keys.ts"); +const { authStore } = await import("../src/store/auth.ts"); +const { CustomProviderSource, installCustomProviderSource } = await import("../src/server/custom-providers.ts"); +const { callCost } = await import("../src/server/pricing.ts"); +after(() => fs.rmSync(temp, { recursive: true, force: true })); + +function save(value: unknown) { fs.writeFileSync(CUSTOM_MODELS_FILE, JSON.stringify(value)); } +function sseText(text = "ok") { + const chunks = [ + { choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] }, + { choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 } }, + ]; + return new Response(chunks.map((chunk) => `data: ${JSON.stringify({ id: "test", object: "chat.completion.chunk", created: 1, model: "chat", ...chunk })}\n\n`).join("") + "data: [DONE]\n\n", { headers: { "content-type": "text/event-stream" } }); +} + +describe("declarative custom-provider configuration", () => { + it("uses Pi metadata and provider/model inheritance without rewriting base URLs", () => { + const [parsed] = parse(config({ compat: { supportsStore: false, chatTemplateKwargs: { a: true } } }, [model("named", { + name: "Named model", reasoning: true, input: ["text", "image"], thinkingLevelMap: { high: "high", xhigh: null }, + compat: { maxTokensField: "max_tokens", chatTemplateKwargs: { b: false } }, + samplingParams: { temperature: 0.5 }, samplingParamsByThinkingLevel: { high: { top_p: 0.9 } }, + })])); + assert.equal(parsed!.models[0]!.baseUrl, "http://example.invalid/custom/v1"); + assert.equal(parsed!.models[0]!.provider, "gateway"); + assert.equal(parsed!.models[0]!.name, "Named model"); + assert.deepEqual(parsed!.models[0]!.input, ["text", "image"]); + assert.deepEqual(parsed!.models[0]!.compat, { supportsStore: false, maxTokensField: "max_tokens", chatTemplateKwargs: { a: true, b: false } }); + assert.deepEqual(parsed!.models[0]!.samplingParamsByThinkingLevel, { high: { top_p: 0.9 } }); + }); + it("accepts Pi line comments, trailing commas and BOM without corrupting strings", () => { + const text = `\uFEFF${JSON.stringify(config()).replace('"providers":', '// comment\n"providers":').replace(/}$/, ',}')}`; + assert.equal(parseCustomProviders(text, new Set(), customProviderApis)[0]!.models[0]!.baseUrl, "http://example.invalid/custom/v1"); + }); + it("registers multiple providers and mixed native APIs without fixed backend IDs", () => { + const value = config(); + const raw = { providers: { ...value.providers, other: { ...value.providers.gateway, models: [model("chat"), model("anthropic", { api: "anthropic-messages", baseUrl: "https://example.invalid/anthropic" })] } } }; + const models = createModels(); + for (const entry of parse(raw)) models.setProvider(createCustomProvider(entry, () => undefined)); + assert.equal(models.getProviders().length, 2); + assert.equal(models.getModel("other", "anthropic")!.api, "anthropic-messages"); + assert.equal(models.getModel("other", "anthropic")!.baseUrl, "https://example.invalid/anthropic"); + }); + it("requires explicit finite limits and nonnegative costs, instead of guessing", () => { + for (const field of ["contextWindow", "maxTokens", "cost"]) { + const m: Record = model(); delete m[field]; + assert.throws(() => parse(config({}, [m as ReturnType]))); + } + assert.throws(() => parse(config({}, [model("chat", { contextWindow: -1 })]))); + assert.throws(() => parse(config({}, [model("chat", { cost: { ...zero, output: -1 } })]))); + assert.throws(() => parseCustomProviders(JSON.stringify(config()).replace('"contextWindow":8192', '"contextWindow":1e999'), new Set(), customProviderApis)); + }); + it("rejects collisions, duplicate IDs, unsupported protocols, executable fields and invalid URLs", () => { + for (const id of ["openai", "chatgpt", "", "has:colon"]) assert.throws(() => parse({ providers: { [id]: config().providers.gateway } })); + assert.throws(() => parse(config({}, [model(), model()]))); + assert.throws(() => parse(config({ api: "unknown-api" }))); + assert.throws(() => parse(config({ oauth: "radius" }))); + assert.throws(() => parse(config({ modelOverrides: {} }))); + assert.throws(() => parse(config({ apiKey: "!touch /never-execute" }))); + assert.throws(() => parse(config({ headers: { "x-key": "!secret-command" } }))); + assert.throws(() => parse(config({}, [model("chat", { headers: { Authorization: "!command" } })]))); + for (const baseUrl of ["file:///tmp/model", "https://secret@example.invalid", "http://example.invalid/#fragment"]) assert.throws(() => parse(config({ baseUrl }))); + }); + it("matches Pi value interpolation and escapes, without recursive expansion or commands", () => { + const lookup = (key: string) => ({ KEY: "secret", DATA: "!never-executed" })[key as "KEY"]; + assert.equal(resolveCustomValue("Bearer ${KEY}/$KEY", lookup), "Bearer secret/secret"); + assert.equal(resolveCustomValue("$$KEY $!literal", lookup), "$KEY !literal"); + assert.equal(resolveCustomValue("$MISSING", lookup), undefined); + assert.equal(resolveCustomValue("$DATA", lookup), "!never-executed"); + }); +}); + +describe("custom providers in the existing application wiring", () => { + it("is empty before configuration and never creates a configuration file", () => { + assert.equal(fs.existsSync(CUSTOM_MODELS_FILE), false); + assert.equal(modelCatalog().customProvidersError, undefined); + assert.equal(fs.existsSync(CUSTOM_MODELS_FILE), false); + }); + it("projects into existing pickers, validation, context limits and Pi pricing", async () => { + save(config({ apiKey: "$GW_KEY", headers: { "x-gateway": "$GW_HEADER" } }, [model("chat", { cost: { ...zero, input: 2, output: 4 } })])); + const catalog = modelCatalog(); + assert.ok(catalog.providers.find((p) => p.id === "gateway")?.keyPresent); + assert.ok(catalog.models.some((m) => m.provider === "gateway" && m.id === "chat")); + assert.equal(JSON.stringify(catalog).includes("first-key"), false); + assert.equal(JSON.stringify(catalog).includes("first-header"), false); + assert.equal(validateModelsSettings({ strong: { model: { provider: "gateway", id: "chat" } } }).ok, true); + assert.equal(catalogContextLimits().get("gateway:chat"), 8192); + assert.ok(Math.abs(callCost({ inputTokens: 100, outputTokens: 50 }, "gateway:chat")! - 0.0004) < 1e-12); + assert.equal(providerKeyPresent("gateway"), true); + assert.equal((await piModels().getAuth("gateway"))!.auth.apiKey, "first-key"); + }); + it("uses current app key precedence and marks unresolved credentials/headers unavailable", () => { + process.env.LH_IGNORE_ENV_KEYS = "0"; + process.env.GW_KEY = "real-env-key"; + try { assert.equal(keyValue("GW_KEY"), "real-env-key"); } + finally { process.env.LH_IGNORE_ENV_KEYS = "1"; delete process.env.GW_KEY; } + save(config({ apiKey: "$MISSING_KEY" })); + assert.equal(providerKeyPresent("gateway"), false); + assert.equal(modelCatalog().models.some((m) => m.provider === "gateway"), false); + save(config({ headers: { "x-required": "$MISSING_HEADER" } })); + assert.equal(providerKeyPresent("gateway"), false); + save(config({ apiKey: undefined })); + assert.equal(providerKeyPresent("gateway"), false, "Pi-style keyless endpoints need an explicit dummy key"); + }); + it("revalidates every request of one engine while leaving an in-flight HTTP operation alone", async () => { + save(config({ apiKey: "$GW_KEY", headers: { "x-gateway": "$GW_HEADER" }, compat: { supportsStore: false, supportsDeveloperRole: false, supportsReasoningEffort: false, maxTokensField: "max_tokens" } })); + const engine = piEngineForSpec({ provider: "gateway", id: "chat" }); + const context = normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: 1 }] }); + let started!: () => void, release!: (response: Response) => void; + const requestStarted = new Promise((resolve) => { started = resolve; }); + const firstResponse = new Promise((resolve) => { release = resolve; }); + const requests: { url: string; headers: Headers; body: Record }[] = []; + const original = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + requests.push({ url: request.url, headers: request.headers, body: await request.json() as Record }); + if (requests.length === 1) { started(); return firstResponse; } + return sseText("héllo 🌍"); + }; + const pending = engine.streamFn(engine.model, context); + try { + await requestStarted; + authStore.setAuthVar("GW_KEY", "second-key"); + authStore.setAuthVar("GW_HEADER", "second-header"); + save(config({ baseUrl: "https://new.example.invalid/inference/v1", apiKey: "$GW_KEY", headers: { "x-gateway": "$GW_HEADER" }, compat: { supportsStore: false, maxTokensField: "max_tokens" } }, [model("chat", { maxTokens: 512 })])); + const second = await engine.streamFn(engine.model, context).result(); + assert.equal(second.stopReason, "stop"); + assert.deepEqual(second.content, [{ type: "text", text: "héllo 🌍" }]); + assert.equal(second.usage.input, 10); + assert.equal(second.usage.output, 2); + // Credential-only edits must also take effect on this same engine. + authStore.setAuthVar("GW_KEY", "third-key"); + assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "stop"); + // None of these cases may dispatch using the engine's old model/key. + save(config({ apiKey: "$MISSING_KEY" })); + assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error"); + save(config({}, [model("other-model")])); + assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error"); + fs.writeFileSync(CUSTOM_MODELS_FILE, "invalid configuration"); + assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error"); + fs.rmSync(CUSTOM_MODELS_FILE); + assert.equal((await engine.streamFn(engine.model, context).result()).stopReason, "error"); + release(sseText("original request finished")); + const first = await pending.result(); + assert.equal(first.stopReason, "stop"); + assert.deepEqual(first.content, [{ type: "text", text: "original request finished" }]); + } finally { + release(sseText()); + await pending.result(); + globalThis.fetch = original; + } + assert.equal(requests.length, 3, "unavailable configurations must not cause additional transport calls"); + assert.equal(requests[0]!.url, "http://example.invalid/custom/v1/chat/completions"); + assert.equal(requests[0]!.headers.get("authorization"), "Bearer first-key"); + assert.equal(requests[0]!.headers.get("x-gateway"), "first-header"); + assert.equal(requests[1]!.url, "https://new.example.invalid/inference/v1/chat/completions"); + assert.equal(requests[1]!.headers.get("authorization"), "Bearer second-key"); + assert.equal(requests[1]!.headers.get("x-gateway"), "second-header"); + assert.equal(requests[2]!.headers.get("authorization"), "Bearer third-key"); + assert.equal(requests[0]!.body.model, "chat"); + assert.equal("store" in requests[0]!.body, false); + assert.equal(requests[0]!.body.max_tokens, 1024); + assert.equal(requests[1]!.body.max_tokens, 512, "current Pi model metadata, not the stale engine model, controls the request"); + }); + const thinking = (...levels: ThinkingLevel[]) => ({ + reasoning: true, + // Pi treats omitted levels through high as supported; mark every + // unselected level explicitly unsupported for these narrow fixtures. + thinkingLevelMap: { off: null, minimal: null, low: null, medium: null, high: null, xhigh: null, max: null, ...Object.fromEntries(levels.map((level) => [level, level])) }, + }); + const thinkingCases: { name: string; preference?: ThinkingLevel; initial: object; updated: object; before: ThinkingLevel; after: ThinkingLevel }[] = [ + { name: "recovers saved max after high-only metadata expands", preference: "max", initial: thinking("high"), updated: thinking("high", "max"), before: "high", after: "max" }, + { name: "recovers saved max after reasoning-off metadata expands", preference: "max", initial: { reasoning: false }, updated: thinking("high", "max"), before: "off", after: "max" }, + { name: "selects the existing medium-first default after reasoning becomes supported", initial: { reasoning: false }, updated: thinking("low", "medium", "high"), before: "off", after: "medium" }, + { name: "reselects the existing low-before-high default against updated metadata", initial: thinking("high"), updated: thinking("low", "high"), before: "high", after: "low" }, + { name: "clamps saved max when supported thinking contracts", preference: "max", initial: thinking("high", "max"), updated: thinking("high"), before: "max", after: "high" }, + { name: "omits reasoning when the model loses thinking support", preference: "max", initial: thinking("high", "max"), updated: { reasoning: false }, before: "max", after: "off" }, + ]; + for (const scenario of thinkingCases) it(scenario.name, async () => { + const definition = (metadata: object) => config({ compat: { supportsReasoningEffort: true, thinkingFormat: "openai" } }, [model("chat", metadata)]); + save(definition(scenario.initial)); + const engine = piEngineForSpec({ provider: "gateway", id: "chat", thinkingLevel: scenario.preference }); + assert.equal(engine.level, scenario.before); + const requests: Record[] = []; + const original = globalThis.fetch; + globalThis.fetch = async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + requests.push(await request.json() as Record); + return sseText(); + }; + try { + const context = normalizeContext({ messages: [{ role: "user", content: "hello", timestamp: 1 }] }); + // A long-lived agent still forwards its initial thinking setting. + const options: SimpleStreamOptions = { reasoning: engine.level === "off" ? undefined : engine.level }; + assert.equal((await engine.streamFn(engine.model, context, options).result()).stopReason, "stop"); + save(definition(scenario.updated)); + assert.equal((await engine.streamFn(engine.model, context, options).result()).stopReason, "stop"); + } finally { globalThis.fetch = original; } + assert.equal(requests.length, 2); + for (const [index, level] of [scenario.before, scenario.after].entries()) { + assert.equal(requests[index]!.reasoning_effort, level === "off" ? undefined : level); + if (level === "off") assert.equal("reasoning_effort" in requests[index]!, false); + } + }); + it("disables custom routes on malformed edits without removing built-ins or saved choices", () => { + fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ version: 1, models: { strong: { model: { provider: "gateway", id: "chat" }, thinkingLevel: "high" } } })); + const before = fs.readFileSync(SETTINGS_FILE, "utf8"); + fs.writeFileSync(CUSTOM_MODELS_FILE, '{ secret-token-do-not-log'); + const catalog = modelCatalog(); + assert.ok(catalog.customProvidersError); + assert.equal(catalog.customProvidersError!.includes("secret-token"), false); + assert.equal(piModels().getProvider("gateway"), undefined); + assert.ok(piModels().getProvider("openai")); + assert.deepEqual(catalog.slots.strong!.model, { provider: "gateway", id: "chat" }); + const stored = { strong: { model: { provider: "gateway", id: "chat" }, thinkingLevel: "high" as const } }; + const repaired = { strong: { ...stored.strong, thinkingLevel: "max" as const }, cheap: { model: { provider: "openai", id: piModels().getModels("openai")[0]!.id } } }; + assert.equal(validateModelsSettings(repaired, stored).ok, true, "an unavailable custom pin must not prevent independent slot repair"); + assert.equal(clampModelsLevels(repaired).strong!.thinkingLevel, "max", "preserve thinking while custom metadata is unavailable"); + assert.equal(validateModelsSettings({ cheap: stored.strong }, stored).ok, false, "unchanged identity preservation is same-slot only"); + assert.equal(fs.readFileSync(SETTINGS_FILE, "utf8"), before); + assert.throws(() => piEngineForSpec({ provider: "gateway", id: "chat" }), /catalog has no model/); + save(config()); + assert.equal(modelCatalog().customProvidersError, undefined); + assert.ok(piModels().getProvider("gateway")); + fs.rmSync(CUSTOM_MODELS_FILE); + assert.equal(modelCatalog().models.some((m) => m.provider === "gateway"), false); + assert.equal(modelCatalog().customProvidersError, undefined); + assert.equal(fs.readFileSync(SETTINGS_FILE, "utf8"), before); + }); + it("refreshes credentials without a file edit, preserving native header precedence and snapshots", async () => { + const values = new Map([["KEY", "one"], ["HEADER", "first"]]); + const source = new CustomProviderSource(() => ({ text: JSON.stringify(config({ apiKey: "$KEY", authHeader: true, headers: { "x-shared": "$HEADER" } }, [model("chat", { headers: { "x-shared": "model-${HEADER}" } })])) }), (name) => values.get(name)); + const models = createModels(); + source.sync(models); + const captured = createModels(); + captured.setProvider(models.getProvider("gateway")!); + values.set("KEY", "two"); values.set("HEADER", "second"); + source.sync(models); + const current = (await models.getAuth(models.getModel("gateway", "chat")!))!.auth; + const previous = (await captured.getAuth(captured.getModel("gateway", "chat")!))!.auth; + assert.equal(current.apiKey, "two"); + assert.equal(current.headers!["x-shared"], "model-second"); + assert.equal(current.headers!.Authorization, "Bearer two"); + assert.equal(previous.apiKey, "one"); + assert.equal(previous.headers!["x-shared"], "model-first"); + }); + it("rejects built-in and subscription-route collisions without replacing their providers", () => { + for (const id of ["openai", "github-copilot", "chatgpt", "github-copilot-subscription", "xai-subscription"]) { + const existing = piModels().getProvider("openai")!; + const models = createModels(); models.setProvider(existing); + const source = new CustomProviderSource(() => ({ text: JSON.stringify({ providers: { [id]: config().providers.gateway } }) }), () => undefined); + source.sync(models); + assert.ok(source.getError()); + assert.equal(models.getProvider("openai"), existing); + assert.equal(models.getProviders().length, 1); + } + }); + it("fails closed across partial publication and recovers without leaking internal errors", () => { + const text = JSON.stringify({ providers: { ...config().providers, second: config().providers.gateway } }); + const source = new CustomProviderSource(() => ({ text }), () => undefined); + const models = createModels(); + const builtin = piModels().getProvider("openai")!; + models.setProvider(builtin); + const publish = models.setProvider.bind(models); + models.setProvider = (provider) => { + if (provider.id === "second") throw new Error("private-value-do-not-log"); + publish(provider); + }; + source.sync(models); + assert.equal(models.getProvider("gateway"), undefined); + assert.equal(models.getProvider("second"), undefined); + assert.equal(models.getProvider("openai"), builtin); + assert.equal(source.getError()!.includes("private-value"), false); + models.setProvider = publish; + source.sync(models); + assert.ok(models.getProvider("gateway")); + assert.ok(models.getProvider("second")); + assert.equal(source.getError(), undefined); + }); + it("retires old custom routes when server configuration bootstrap is reinstalled", () => { + save(config()); + assert.ok(piModels().getProvider("gateway")); + fs.rmSync(CUSTOM_MODELS_FILE); + installCustomProviderSource(keyValue); + assert.equal(piModels().getProvider("gateway"), undefined); + assert.ok(piModels().getProvider("openai")); + }); + it("treats unreadable config as unavailable and recovers without a restart", () => { + let unreadable = true; + const source = new CustomProviderSource(() => unreadable ? { error: "Unable to read models.json." } : { text: JSON.stringify(config()) }, () => undefined); + const models = createModels(); + source.sync(models); + assert.equal(source.getError(), "Unable to read models.json."); + unreadable = false; + source.sync(models); + assert.ok(models.getProvider("gateway")); + assert.equal(source.getError(), undefined); + }); +}); diff --git a/tests/provider-runtime-payload.test.ts b/tests/provider-runtime-payload.test.ts index dec3213..85c9b8b 100644 --- a/tests/provider-runtime-payload.test.ts +++ b/tests/provider-runtime-payload.test.ts @@ -1,4 +1,4 @@ -/** Real installed Pi/Google SDK only; no provider stubs, keys, or generation. */ +/** Real installed Pi/Google/Bedrock SDKs only; no provider stubs, keys, or generation. */ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import * as fs from "node:fs"; @@ -12,11 +12,12 @@ const checkout = fileURLToPath(new URL("..", import.meta.url)); const run = (payload: string) => spawnSync(process.execPath, [script, "--standalone", payload], { encoding: "utf8", timeout: 40_000 }); describe("offline native Pi Google initialization gate", () => { - it("reaches the sentinel for direct Google and Google-backed OpenCode adapters using the real SDK", () => { + it("reaches the sentinel for Google, Google-backed OpenCode and Bedrock adapters using the real SDKs", () => { // This is an unbundled positive control, NOT proof of standalone completeness. const result = run(checkout); assert.equal(result.status, 0, String(result.stderr)); for (const provider of ["google", "opencode", "opencode-go"]) assert.ok(String(result.stdout).includes(`${provider}: native lazy Google adapter reached onPayload`)); + assert.ok(String(result.stdout).includes("amazon-bedrock: native lazy Bedrock adapter reached onPayload")); assert.match(String(result.stdout), /zero network attempts/); }); it("rejects a missing Pi entry instead of treating any error as success", () => {