feat: finish donor-led custom endpoint integration
This commit is contained in:
@@ -1,71 +0,0 @@
|
||||
/** 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<string, ProviderStreams> = {
|
||||
"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<string> = new Set(Object.keys(apis));
|
||||
|
||||
function resolveHeaders(headers: Record<string, string> | undefined, lookup: (name: string) => string | undefined): Record<string, string> | undefined {
|
||||
const result: Record<string, string> = {};
|
||||
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,
|
||||
});
|
||||
}
|
||||
+16
-5
@@ -9,7 +9,7 @@ import {
|
||||
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 { lazyApi, 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";
|
||||
@@ -82,7 +82,14 @@ export function configureProviderSource(source: ProviderSource): void {
|
||||
export function piModels() {
|
||||
if (!collection) {
|
||||
collection = createModels({ identity: APP_IDENTITY });
|
||||
for (const provider of builtinProviders()) collection.setProvider(provider);
|
||||
for (const provider of builtinProviders()) {
|
||||
// Keep the native Bedrock implementation reachable by standalone tracing.
|
||||
// Pi's default Node-only loader hides this import from bundlers. Only
|
||||
// replace its lazy forwarding; preserve the native factory's auth/catalog.
|
||||
collection.setProvider(provider.id === "amazon-bedrock"
|
||||
? { ...provider, ...lazyApi(() => import("@earendil-works/pi-ai/api/bedrock-converse-stream")) }
|
||||
: provider);
|
||||
}
|
||||
collection.setProvider(createClaudeCodeProvider(streamClaudeCode));
|
||||
}
|
||||
providerSource?.sync(collection);
|
||||
@@ -124,7 +131,12 @@ function defaultThinkingLevel(model: Model<Api>): ThinkingLevel {
|
||||
export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (() => boolean) = true, lifecycle?: ChatGPTDispatchLifecycle): PiEngine {
|
||||
const models: Models = piModels();
|
||||
const nativeId = nativeSubscriptionId(spec.provider);
|
||||
const model = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : nativeId ? nativeSubscriptionModel(spec.provider, spec.id) : models.getModel(spec.provider, spec.id);
|
||||
const registeredModel = spec.provider === "chatgpt" ? chatGPTModel(spec.id) : nativeId ? nativeSubscriptionModel(spec.provider, spec.id) : models.getModel(spec.provider, spec.id);
|
||||
const custom = providerSource?.isCustom(spec.provider) ?? false;
|
||||
// Custom endpoint owners authorize image attempts independently of sparse
|
||||
// discovery metadata. Never promote the registered/catalog capabilities.
|
||||
const permitImages = (value: Model<Api>): Model<Api> => ({ ...value, input: ["text", "image"] });
|
||||
const model = custom && registeredModel ? permitImages(registeredModel) : registeredModel;
|
||||
if (!model) {
|
||||
throw new Error(`${spec.provider} catalog has no model ${spec.id}`);
|
||||
}
|
||||
@@ -137,7 +149,6 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (
|
||||
: 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,
|
||||
@@ -156,7 +167,7 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (
|
||||
const currentLevel = thinkingPreference
|
||||
? clampThinkingLevel(currentModel, thinkingPreference)
|
||||
: defaultThinkingLevel(currentModel);
|
||||
return current.streamSimple(currentModel, context, { ...options, reasoning: currentLevel === "off" ? undefined : currentLevel });
|
||||
return current.streamSimple(permitImages(currentModel), context, { ...options, reasoning: currentLevel === "off" ? undefined : currentLevel });
|
||||
}
|
||||
const requestOptions = { ...options, reasoning: level === "off" ? undefined : level };
|
||||
const purpose = typeof background === "function" ? background : () => background;
|
||||
|
||||
@@ -75,8 +75,6 @@ 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. */
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Private custom credential authorization; no credential-derived public value. */
|
||||
import { normalizeBaseUrl, type CustomProviderSettings } from "./settings.ts";
|
||||
|
||||
export const CUSTOM_AUTH_BINDING = "__learnharness_custom_provider_binding";
|
||||
export interface CustomAuthBinding {
|
||||
version: 1;
|
||||
id: string;
|
||||
baseUrl?: string;
|
||||
authMode: "apiKey" | "keyless" | "disabled";
|
||||
/** A shadowed file key keeps its own destination when an env key moves. */
|
||||
fileKeyBaseUrl?: string;
|
||||
}
|
||||
export function parseCustomAuthBinding(value: unknown): CustomAuthBinding | undefined {
|
||||
try {
|
||||
const b = typeof value === "string" ? JSON.parse(value) : undefined;
|
||||
if (!b || b.version !== 1 || typeof b.id !== "string" || !/^[a-f0-9]{32}$/.test(b.id)) return;
|
||||
if (!["apiKey", "keyless", "disabled"].includes(b.authMode)) return;
|
||||
if (b.authMode !== "disabled" && b.baseUrl === undefined) return;
|
||||
if (b.baseUrl !== undefined && (typeof b.baseUrl !== "string" || !b.baseUrl || normalizeBaseUrl(b.baseUrl) !== b.baseUrl)) return;
|
||||
if (b.fileKeyBaseUrl !== undefined && (typeof b.fileKeyBaseUrl !== "string" || !b.fileKeyBaseUrl || normalizeBaseUrl(b.fileKeyBaseUrl) !== b.fileKeyBaseUrl)) return;
|
||||
return { version: 1, id: b.id, authMode: b.authMode, ...(b.baseUrl !== undefined ? { baseUrl: b.baseUrl } : {}), ...(b.fileKeyBaseUrl !== undefined ? { fileKeyBaseUrl: b.fileKeyBaseUrl } : {}) };
|
||||
} catch { return; }
|
||||
}
|
||||
export function customBindingMatches(cp: CustomProviderSettings | undefined, binding: CustomAuthBinding | undefined): boolean {
|
||||
return Boolean(cp?.baseUrl && binding && binding.authMode !== "disabled" && cp.credentialBindingId === binding.id && cp.baseUrl === binding.baseUrl && cp.authMode === binding.authMode);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
/** 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<typeof ProviderSchema>;
|
||||
export interface CustomProviderConfig {
|
||||
id: string;
|
||||
definition: CustomProviderDefinition;
|
||||
models: Model<Api>[];
|
||||
}
|
||||
|
||||
/** 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<Api>["compat"], override: Model<Api>["compat"]): Model<Api>["compat"] {
|
||||
if (!base && !override) return undefined;
|
||||
const merged = { ...base, ...override } as Record<string, unknown>;
|
||||
for (const name of ["openRouterRouting", "vercelGatewayRouting", "chatTemplateKwargs", "chatTemplateArgs"]) {
|
||||
const a = (base as Record<string, unknown> | undefined)?.[name];
|
||||
const b = (override as Record<string, unknown> | undefined)?.[name];
|
||||
if (a || b) merged[name] = { ...(a as object | undefined), ...(b as object | undefined) };
|
||||
}
|
||||
return merged as Model<Api>["compat"];
|
||||
}
|
||||
|
||||
export function parseCustomProviders(text: string, reserved: ReadonlySet<string>, supportedApis: ReadonlySet<string>): 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<string>();
|
||||
const models = definition.models.map((model): Model<Api> => {
|
||||
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 };
|
||||
});
|
||||
}
|
||||
+13
-9
@@ -215,15 +215,12 @@ export interface ModelsSettings {
|
||||
// secret in the auth store, never here. Registered into pi-ai as a normal
|
||||
// provider (server/custom-provider.ts).
|
||||
|
||||
/** Defaults for a custom model — self-hosted endpoints are free. */
|
||||
/** Budget fallbacks only; custom endpoint pricing is unknown. */
|
||||
export const CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW = 8192;
|
||||
export const CUSTOM_MODEL_DEFAULT_MAX_TOKENS = 4096;
|
||||
|
||||
/** Floor for a user-pinned token count (owner, 2026-10-27): the read side
|
||||
* clamps up, the write side rejects below — the compaction-floor discipline.
|
||||
* Pins exist because endpoints that publish no limits (LM Studio) otherwise
|
||||
* sit on the 8192 default with no way to say better. */
|
||||
export const CUSTOM_MODEL_PIN_FLOOR = 2_048;
|
||||
/** Custom budgets accept every positive integer, including small output caps. */
|
||||
export const CUSTOM_MODEL_PIN_FLOOR = 1;
|
||||
|
||||
/** One discovered custom model. `image`/`reasoning`/`context`/`maxTokens` are
|
||||
* detected from the endpoint's /models metadata; anything it omits falls back
|
||||
@@ -244,8 +241,8 @@ export interface CustomModel {
|
||||
/** A user pin for one custom model: it beats what the endpoint publishes
|
||||
* (owner ruling — explicit user intent wins; a local server's number is
|
||||
* often wrong or absent). Absent field = no pin. `reasoning` is tri-state:
|
||||
* true forces thinking on, false forces it off (a server that advertises
|
||||
* reasoning but rejects reasoning_effort), absent = what discovery found. */
|
||||
* true declares reasoning support, false removes that declaration, absent
|
||||
* uses discovery. This is NOT a backend execution/disable switch. */
|
||||
export interface CustomModelOverride {
|
||||
context?: number;
|
||||
maxTokens?: number;
|
||||
@@ -254,6 +251,10 @@ export interface CustomModelOverride {
|
||||
|
||||
/** The single custom endpoint's stored config (delta section). */
|
||||
export interface CustomProviderSettings {
|
||||
/** Server-owned random authorization nonce; never a credential hash. */
|
||||
credentialBindingId?: string;
|
||||
/** Explicit authentication intent; never inferred from a saved key. */
|
||||
authMode?: "keyless" | "apiKey";
|
||||
/** Normalized base URL (see normalizeBaseUrl). Empty/absent = not configured. */
|
||||
baseUrl?: string;
|
||||
/** Models this endpoint offers: advertised ids merged with the typed
|
||||
@@ -287,6 +288,7 @@ export function normalizeBaseUrl(raw: string): string {
|
||||
return "";
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") return "";
|
||||
if (url.username || url.password || url.search || url.hash || value.includes("?") || value.includes("#")) return "";
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
@@ -625,7 +627,7 @@ export function sanitizeCustomModelOverrides(
|
||||
const id = key.trim();
|
||||
if (!id || (live && !live.has(id))) continue;
|
||||
const pin = sanitizeCustomModelOverride(value);
|
||||
if (pin) out[id] = pin;
|
||||
if (pin) Object.defineProperty(out, id, { value: pin, enumerable: true, configurable: true, writable: true });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -635,6 +637,8 @@ export function sanitizeCustomModelOverrides(
|
||||
* true; token counts only as positive ints). Clamp-never-reject. */
|
||||
function sanitizeCustomProvider(raw: Record<string, unknown>): CustomProviderSettings {
|
||||
const out: CustomProviderSettings = {};
|
||||
if (typeof raw.credentialBindingId === "string" && /^[a-f0-9]{32}$/.test(raw.credentialBindingId)) out.credentialBindingId = raw.credentialBindingId;
|
||||
if (raw.authMode === "keyless" || raw.authMode === "apiKey") out.authMode = raw.authMode;
|
||||
if (typeof raw.baseUrl === "string") {
|
||||
const base = normalizeBaseUrl(raw.baseUrl);
|
||||
if (base) out.baseUrl = base;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/** Custom-only credential composition. File credentials and their authorization
|
||||
* are read from the same atomic record, never a stale fill-gapped env copy. */
|
||||
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
|
||||
import { authEnvRecord } from "../core/auth-env.ts";
|
||||
import { customBindingMatches, type CustomAuthBinding } from "../core/custom-provider-auth.ts";
|
||||
import type { CustomProviderSettings } from "../core/settings.ts";
|
||||
import { readCustomAuth, writeCustomAuth } from "../store/auth.ts";
|
||||
|
||||
export interface CustomAuthState {
|
||||
readable: boolean;
|
||||
binding?: CustomAuthBinding;
|
||||
key?: string;
|
||||
fileKey?: string;
|
||||
source: "env" | "file" | "none";
|
||||
}
|
||||
/** requiredConfig prevents even credential resolution until startup/runtime
|
||||
* authorization matches. Explicit UI inspection/probe authorization omits it. */
|
||||
export function customAuthState(inspectKey = false, requiredConfig?: CustomProviderSettings): CustomAuthState {
|
||||
try {
|
||||
const snapshot = readCustomAuth();
|
||||
const result: CustomAuthState = { readable: true, binding: snapshot.binding, source: "none" };
|
||||
if (!inspectKey || (requiredConfig && !customBindingMatches(requiredConfig, snapshot.binding))) return result;
|
||||
result.fileKey = snapshot.fileKey();
|
||||
const record = authEnvRecord();
|
||||
const envKey = process.env.LH_IGNORE_ENV_KEYS !== "1" && record.realEnv.has(CUSTOM_PROVIDER.envVar) ? process.env[CUSTOM_PROVIDER.envVar] : undefined;
|
||||
result.source = envKey !== undefined ? "env" : result.fileKey !== undefined ? "file" : "none";
|
||||
result.key = (envKey ?? result.fileKey)?.trim() || undefined;
|
||||
return result;
|
||||
} catch { return { readable: false, source: "none" }; }
|
||||
}
|
||||
export function customKeyAuthorized(cp: CustomProviderSettings | undefined, state: CustomAuthState): boolean {
|
||||
return cp?.authMode === "apiKey" && state.readable && customBindingMatches(cp, state.binding) && Boolean(state.key) && (state.source === "env" || state.binding?.fileKeyBaseUrl === cp?.baseUrl);
|
||||
}
|
||||
/** Reflect only after the atomic pair is committed. Runtime file resolution
|
||||
* does not rely on this compatibility mirror, including after a failed write. */
|
||||
export function persistCustomAuth(binding: CustomAuthBinding | undefined, fileKey: string | undefined): void {
|
||||
writeCustomAuth(binding, fileKey);
|
||||
const record = authEnvRecord();
|
||||
if (!record.realEnv.has(CUSTOM_PROVIDER.envVar)) {
|
||||
if (fileKey === undefined) { delete process.env[CUSTOM_PROVIDER.envVar]; record.fillGap.delete(CUSTOM_PROVIDER.envVar); }
|
||||
else { process.env[CUSTOM_PROVIDER.envVar] = fileKey; record.fillGap.add(CUSTOM_PROVIDER.envVar); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Byte-bounded JSON for the custom endpoint's request/discovery surfaces. */
|
||||
export class CustomBodyLimitError extends Error {}
|
||||
|
||||
export async function readBoundedJson(message: Request | Response, limit: number, allowEmptyObject = false): Promise<unknown> {
|
||||
if (Number(message.headers.get("content-length")) > limit) {
|
||||
await message.body?.cancel();
|
||||
throw new CustomBodyLimitError("JSON body exceeds the allowed byte limit");
|
||||
}
|
||||
const reader = message.body?.getReader();
|
||||
if (!reader) {
|
||||
if (allowEmptyObject) return {};
|
||||
throw new SyntaxError("Missing JSON body");
|
||||
}
|
||||
const decoder = new TextDecoder();
|
||||
let text = "";
|
||||
let bytes = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
if (bytes > limit) throw new CustomBodyLimitError("JSON body exceeds the allowed byte limit");
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
if (bytes === 0 && allowEmptyObject) return {};
|
||||
return JSON.parse(text + decoder.decode());
|
||||
} finally {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/** PR #62's connect/manual-model/offline-pin workflow, with host request,
|
||||
* credential-binding and optimistic-write boundaries. No secret reaches GET. */
|
||||
import { createHmac, randomBytes } from "node:crypto";
|
||||
import { normalizeBaseUrl, parseModelIds, sanitizeCustomModelOverrides, type CustomProviderSettings } from "../core/settings.ts";
|
||||
import { readSettings, writeSettings } from "../store/settings.ts";
|
||||
import { readCustomAuth } from "../store/auth.ts";
|
||||
import { customAuthState, customKeyAuthorized, persistCustomAuth } from "./custom-provider-auth.ts";
|
||||
import type { CustomAuthBinding } from "../core/custom-provider-auth.ts";
|
||||
import { desktopAPIAuthorized } from "./desktop-access.ts";
|
||||
import { subscriptionRequestOrigin } from "./web-subscription-http.ts";
|
||||
import { discoverModels, mergeManualModels, syncCustomProvider } from "./custom-provider.ts";
|
||||
import { parseCustomModelOverridesBody } from "./settings-body.ts";
|
||||
import { CustomBodyLimitError, readBoundedJson } from "./custom-provider-body.ts";
|
||||
|
||||
const revisionKey = Symbol.for("learnharness.custom-provider-revision");
|
||||
const globals = globalThis as typeof globalThis & { [revisionKey]?: Buffer };
|
||||
const secret = globals[revisionKey] ??= randomBytes(32);
|
||||
const failure = (error: string, status = 400) => Response.json({ error }, { status });
|
||||
const stale = () => failure("Custom configuration or credentials changed. Reload configuration and retry.", 409);
|
||||
|
||||
function snapshot(inspectKey = false) {
|
||||
const record = readSettings() ?? { version: 1 };
|
||||
const cp = record.customProvider;
|
||||
// Keyless never looks up a key for authentication or revision generation.
|
||||
// An explicit API-key inspection is used only after the user selects that mode.
|
||||
const auth = customAuthState(inspectKey);
|
||||
const key = auth.key;
|
||||
// Public revisions contain configuration only, never credential material.
|
||||
// Effective credentials are compared privately across the awaited probe.
|
||||
const etag = `"${createHmac("sha256", secret).update(JSON.stringify([cp ?? null, auth.binding ?? null, auth.readable])).digest("base64url")}"`;
|
||||
return { record, cp, auth, key, etag };
|
||||
}
|
||||
|
||||
function response(body: object) {
|
||||
return Response.json(body, { headers: { etag: snapshot().etag, "cache-control": "no-store" } });
|
||||
}
|
||||
|
||||
export function customProviderGet(request: Request): Response {
|
||||
const inspect = new URL(request.url).searchParams.get("authMode") === "apiKey";
|
||||
const current = snapshot();
|
||||
const s = snapshot(inspect || current.cp?.authMode === "apiKey");
|
||||
return response({
|
||||
baseUrl: s.cp?.baseUrl ?? "", authMode: s.cp?.authMode ?? "keyless",
|
||||
models: s.cp?.models ?? [], overrides: s.cp?.overrides ?? {}, manualModels: s.cp?.manualModels ?? [],
|
||||
keyPresent: Boolean(s.key), keySource: s.auth.source,
|
||||
keyReuseRequired: Boolean(s.key) && !customKeyAuthorized(s.cp, s.auth),
|
||||
});
|
||||
}
|
||||
|
||||
function authorized(request: Request): boolean {
|
||||
const desktop = process.env.LH_DESKTOP_SERVER === "1" || Boolean(process.env.LH_DESKTOP_API_SECRET);
|
||||
if (desktop) return desktopAPIAuthorized(request.headers);
|
||||
const origin = request.headers.get("origin");
|
||||
if (!origin || origin === "null") return false;
|
||||
try {
|
||||
// Next's standalone Request URL can use its internal hostname. Match
|
||||
// the actual addressed Host through the app's existing origin seam;
|
||||
// never trust forwarded-origin headers.
|
||||
return origin === subscriptionRequestOrigin(request)
|
||||
&& (!request.headers.has("sec-fetch-site") || request.headers.get("sec-fetch-site") === "same-origin");
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** Auth pair FIRST, matching config nonce SECOND. Any interrupted/failed
|
||||
* cross-file publication is durably unusable, including with a real env key.
|
||||
* Best-effort rollback restores the entire old pair, never just its secret. */
|
||||
function commit(cp: CustomProviderSettings | undefined, binding?: CustomAuthBinding, fileKey?: string): void {
|
||||
const latest = readSettings() ?? { version: 1 };
|
||||
const oldAuth = binding ? readCustomAuth() : undefined;
|
||||
let published = false;
|
||||
try {
|
||||
if (binding) { persistCustomAuth(binding, fileKey); published = true; }
|
||||
const { customProvider: _old, ...rest } = latest;
|
||||
writeSettings(cp ? { ...rest, customProvider: cp } : rest);
|
||||
} catch {
|
||||
if (published && oldAuth) {
|
||||
try { persistCustomAuth(oldAuth.binding, oldAuth.fileKey()); } catch { /* durable nonce mismatch fails closed after restart */ }
|
||||
}
|
||||
throw new Error("Unable to save custom configuration. Reload configuration and retry.");
|
||||
}
|
||||
syncCustomProvider();
|
||||
}
|
||||
|
||||
export async function customProviderMutation(request: Request): Promise<Response> {
|
||||
if (!authorized(request)) return failure("Custom configuration requires a trusted same-origin request.", 403);
|
||||
if (request.headers.get("content-type")?.split(";")[0]?.trim().toLowerCase() !== "application/json") return failure("JSON content-type is required.", 415);
|
||||
try {
|
||||
// Next can represent an empty DELETE as a zero-byte stream rather
|
||||
// than null. Bound it either way; only DELETE permits an empty body.
|
||||
const body = await readBoundedJson(request, 64 * 1024, request.method === "DELETE");
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) return failure("Invalid JSON body");
|
||||
const raw = body as Record<string, unknown>;
|
||||
const before = snapshot(raw.authMode === "apiKey");
|
||||
if (!before.auth.readable) return failure("Custom authorization storage is unavailable. No changes were saved.", 500);
|
||||
if (request.headers.get("if-match") !== before.etag) return stale();
|
||||
if (request.method === "DELETE") {
|
||||
commit(undefined, { version: 1, id: randomBytes(16).toString("hex"), authMode: "disabled" });
|
||||
return response({ ok: true });
|
||||
}
|
||||
if (request.method === "PATCH") {
|
||||
const parsed = parseCustomModelOverridesBody(raw);
|
||||
if (!parsed.ok) return failure(parsed.error);
|
||||
if (!before.cp?.baseUrl) return failure("No custom endpoint configured", 409);
|
||||
const overrides = sanitizeCustomModelOverrides(parsed.settings, before.cp.models?.map((m) => m.id));
|
||||
commit({ ...before.cp, overrides });
|
||||
return response({ ok: true, overrides });
|
||||
}
|
||||
if (request.method !== "POST") return failure("Unsupported method", 405);
|
||||
const base = normalizeBaseUrl(typeof raw.baseUrl === "string" ? raw.baseUrl : "");
|
||||
if (!base) return failure("Base URL must be http(s), with no credentials, query or fragment.");
|
||||
if (raw.authMode !== "keyless" && raw.authMode !== "apiKey") return failure("Choose Keyless or API key authentication.");
|
||||
const authMode = raw.authMode;
|
||||
if (raw.key !== undefined && typeof raw.key !== "string") return failure("API key must be a string.");
|
||||
const supplied = typeof raw.key === "string" ? raw.key.trim() : undefined;
|
||||
if (authMode === "apiKey" && supplied === "") return failure("Choose Keyless to clear authentication.");
|
||||
const envOwned = before.auth.source === "env";
|
||||
if (envOwned && supplied) return failure("Environment owns the effective key. Change the environment or explicitly reuse that key.");
|
||||
// A shadowed file key is not the key that will run. Require consent for
|
||||
// the actual environment key when authorizing a different endpoint.
|
||||
const hasNewEffectiveKey = Boolean(supplied) && !envOwned;
|
||||
const changedTarget = before.cp?.baseUrl !== base;
|
||||
if (authMode === "apiKey" && (changedTarget || !customKeyAuthorized(before.cp, before.auth)) && !hasNewEffectiveKey && raw.reuseKey !== true) {
|
||||
return failure("Enter a new key or explicitly allow reuse of the effective existing key for this URL.");
|
||||
}
|
||||
const effectiveKey = authMode === "apiKey" ? (envOwned ? before.key : supplied || before.key) : undefined;
|
||||
if (authMode === "apiKey" && !effectiveKey) return failure("API key mode requires an effective key.");
|
||||
const manualModels = parseModelIds(raw.manualModels);
|
||||
if (manualModels.length > 10_000) return failure("Model list exceeds 10000 entries");
|
||||
const found = await discoverModels(base, effectiveKey);
|
||||
if (!found.ok && (found.fatal || found.status === 401 || found.status === 403)) return failure(found.error, 502);
|
||||
if (!found.ok && !manualModels.length) return failure(`${found.error}. If this endpoint has no /models list, add model IDs manually.`, 502);
|
||||
// Discovery yields; both the custom record and the credential must still
|
||||
// match. Unrelated settings edits are preserved by commit's fresh merge.
|
||||
const now = snapshot(authMode === "apiKey");
|
||||
if (before.etag !== now.etag || JSON.stringify(before.auth) !== JSON.stringify(now.auth)) return stale();
|
||||
const discovered = found.ok ? found.models : !changedTarget ? before.cp?.models ?? [] : [];
|
||||
const models = mergeManualModels(discovered, manualModels);
|
||||
if (models.length > 10_000) return failure("Model list exceeds 10000 entries");
|
||||
const overrides = sanitizeCustomModelOverrides(!changedTarget ? before.cp?.overrides : undefined, models.map((m) => m.id));
|
||||
const id = randomBytes(16).toString("hex");
|
||||
const fileKey = authMode === "keyless" ? undefined : supplied && !envOwned ? supplied : now.auth.fileKey;
|
||||
const fileKeyBaseUrl = authMode === "apiKey" ? envOwned ? now.auth.binding?.fileKeyBaseUrl : base : undefined;
|
||||
const binding: CustomAuthBinding = { version: 1, id, baseUrl: base, authMode, ...(fileKey && fileKeyBaseUrl ? { fileKeyBaseUrl } : {}) };
|
||||
commit({ baseUrl: base, authMode, credentialBindingId: id, models, overrides, ...(manualModels.length ? { manualModels } : {}) }, binding, fileKey);
|
||||
return response({ ok: true, models });
|
||||
} catch (error) {
|
||||
return failure(error instanceof CustomBodyLimitError ? "Configuration body exceeds 64 KiB." : error instanceof SyntaxError ? "Invalid JSON body." : "Unable to save custom configuration. Reload configuration and retry.", error instanceof CustomBodyLimitError ? 413 : error instanceof SyntaxError ? 400 : 500);
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,14 @@
|
||||
* local servers work via a placeholder key resolved at request time.
|
||||
*/
|
||||
|
||||
import { createProvider, type ApiKeyAuth, type Api, type Model, type Provider } from "@earendil-works/pi-ai";
|
||||
import { createProvider, type Api, type Model, type Provider } from "@earendil-works/pi-ai";
|
||||
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
||||
import { piModels } from "../agent/models.ts";
|
||||
import { configureProviderSource, piModels } from "../agent/models.ts";
|
||||
import { readSettings } from "../store/settings.ts";
|
||||
import { CustomBodyLimitError, readBoundedJson } from "./custom-provider-body.ts";
|
||||
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
|
||||
import { customBindingMatches } from "../core/custom-provider-auth.ts";
|
||||
import { customAuthState, customKeyAuthorized } from "./custom-provider-auth.ts";
|
||||
import {
|
||||
CUSTOM_MODEL_DEFAULT_CONTEXT_WINDOW,
|
||||
CUSTOM_MODEL_DEFAULT_MAX_TOKENS,
|
||||
@@ -16,9 +20,8 @@ import {
|
||||
normalizeBaseUrl,
|
||||
type CustomModel,
|
||||
type CustomModelOverride,
|
||||
type CustomProviderSettings,
|
||||
} from "../core/settings.ts";
|
||||
import { keyValue } from "./keys.ts";
|
||||
|
||||
|
||||
/** Placeholder bearer for keyless endpoints (the OpenAI client needs *a* key). */
|
||||
const KEYLESS_PLACEHOLDER = "not-needed";
|
||||
@@ -27,14 +30,32 @@ const KEYLESS_PLACEHOLDER = "not-needed";
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
/** Auth: the reserved key via the keys seam, else the keyless placeholder. */
|
||||
const customEndpointAuth: ApiKeyAuth = {
|
||||
name: `${CUSTOM_PROVIDER.label} API key`,
|
||||
resolve: async () => {
|
||||
const key = keyValue(CUSTOM_PROVIDER.envVar)?.trim();
|
||||
return { auth: { apiKey: key || KEYLESS_PLACEHOLDER }, source: key ? CUSTOM_PROVIDER.envVar : "keyless" };
|
||||
},
|
||||
};
|
||||
/** Install the sole custom source: donor settings + explicit authentication.
|
||||
* Publication captures a consistent snapshot; an already-started operation
|
||||
* cannot acquire a later endpoint's key. No file configuration source exists. */
|
||||
export function installCustomProviderSource(readAuth = customAuthState): void {
|
||||
let fingerprint: string | undefined;
|
||||
configureProviderSource({
|
||||
isCustom: (id) => id === CUSTOM_PROVIDER.id,
|
||||
sync(models) {
|
||||
const cp = readSettings()?.customProvider;
|
||||
// Validate the durable nonce/URL/mode before resolving ANY credential.
|
||||
// Keyless reads only the nonsecret marker, never a file/env key.
|
||||
const auth = readAuth(cp?.authMode === "apiKey", cp);
|
||||
const key = auth.key;
|
||||
const ready = customProviderUsable(cp) && auth.readable && customBindingMatches(cp, auth.binding) && (cp.authMode === "keyless" || customKeyAuthorized(cp, auth));
|
||||
const next = JSON.stringify([cp, auth]); // Private in-memory fingerprint, never a public revision.
|
||||
if (next === fingerprint) return;
|
||||
if (ready) models.setProvider(buildOpenAiCompatibleProvider({ ...cp, baseUrl: cp.baseUrl, models: cp.models ?? [], apiKey: key }));
|
||||
else models.deleteProvider(CUSTOM_PROVIDER.id);
|
||||
fingerprint = next;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function customProviderAvailable(): boolean {
|
||||
return piModels().getProvider(CUSTOM_PROVIDER.id) !== undefined;
|
||||
}
|
||||
|
||||
/** Map a discovered model to a pi-ai entry: image→input, reasoning→thinking
|
||||
* levels, context/maxTokens→limits. Resolution ladder (owner, 2026-10-27):
|
||||
@@ -72,13 +93,18 @@ export function buildOpenAiCompatibleProvider(input: {
|
||||
models: readonly CustomModel[];
|
||||
/** User pins by model id (see CustomModelOverride) — absent = none. */
|
||||
overrides?: Record<string, CustomModelOverride>;
|
||||
/** Already resolved for this publication; absent means explicit keyless. */
|
||||
apiKey?: string;
|
||||
}): Provider<Api> {
|
||||
const overrides = input.overrides ?? {};
|
||||
return createProvider<Api>({
|
||||
id: CUSTOM_PROVIDER.id,
|
||||
name: CUSTOM_PROVIDER.label,
|
||||
baseUrl: input.baseUrl,
|
||||
auth: { apiKey: customEndpointAuth },
|
||||
auth: { apiKey: {
|
||||
name: `${CUSTOM_PROVIDER.label} API key`,
|
||||
resolve: async () => ({ auth: { apiKey: input.apiKey ?? KEYLESS_PLACEHOLDER }, source: input.apiKey ? CUSTOM_PROVIDER.envVar : "keyless" }),
|
||||
} },
|
||||
models: input.models.map((m) => toPiModel(m, input.baseUrl, overrides)),
|
||||
api: openAICompletionsApi(),
|
||||
});
|
||||
@@ -86,19 +112,8 @@ export function buildOpenAiCompatibleProvider(input: {
|
||||
|
||||
/** Re-sync the provider into piModels() (idempotent): usable → rebuild;
|
||||
* not usable → remove. Never throws. */
|
||||
export function syncCustomProvider(cp: CustomProviderSettings | undefined): void {
|
||||
const models = piModels();
|
||||
if (!customProviderUsable(cp)) {
|
||||
models.deleteProvider(CUSTOM_PROVIDER.id);
|
||||
return;
|
||||
}
|
||||
models.setProvider(
|
||||
buildOpenAiCompatibleProvider({
|
||||
baseUrl: normalizeBaseUrl(cp.baseUrl),
|
||||
models: cp.models ?? [],
|
||||
overrides: cp.overrides ?? {},
|
||||
}),
|
||||
);
|
||||
export function syncCustomProvider(): void {
|
||||
piModels();
|
||||
}
|
||||
|
||||
/** Outcome of a model-discovery probe. `status` is the endpoint's HTTP status
|
||||
@@ -106,7 +121,7 @@ export function syncCustomProvider(cp: CustomProviderSettings | undefined): void
|
||||
* model list. */
|
||||
export type DiscoverResult =
|
||||
| { ok: true; models: CustomModel[] }
|
||||
| { ok: false; error: string; status?: number };
|
||||
| { ok: false; error: string; status?: number; fatal?: boolean };
|
||||
|
||||
/** Probe GET {baseUrl}/models for model ids. Scheme validated (http/https
|
||||
* only); keyless endpoints answer without a bearer. Non-2xx or unparseable
|
||||
@@ -114,22 +129,26 @@ export type DiscoverResult =
|
||||
export async function discoverModels(baseUrl: string, apiKey?: string): Promise<DiscoverResult> {
|
||||
const base = normalizeBaseUrl(baseUrl);
|
||||
if (!base) return { ok: false, error: "Base URL must be a valid http(s) URL" };
|
||||
const key = apiKey?.trim() || keyValue(CUSTOM_PROVIDER.envVar)?.trim();
|
||||
const key = apiKey?.trim();
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15_000);
|
||||
try {
|
||||
const res = await fetch(`${base}/models`, {
|
||||
headers: key ? { authorization: `Bearer ${key}` } : {},
|
||||
signal: controller.signal,
|
||||
redirect: "manual",
|
||||
});
|
||||
if (!res.ok) return { ok: false, error: `Endpoint returned ${res.status} ${res.statusText}`, status: res.status };
|
||||
const body = (await res.json().catch(() => null)) as unknown;
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
await res.body?.cancel();
|
||||
return { ok: false, error: "Discovery redirects are not allowed", fatal: true };
|
||||
}
|
||||
if (!res.ok) { await res.body?.cancel(); return { ok: false, error: `Endpoint returned HTTP ${res.status}`, status: res.status }; }
|
||||
const body = await readBoundedJson(res, 8 * 1024 * 1024);
|
||||
const list = extractModels(body);
|
||||
if (list === null) return { ok: false, error: "Endpoint response was not a model list" };
|
||||
return { ok: true, models: list };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? (err.name === "AbortError" ? "Endpoint timed out" : err.message) : "Request failed";
|
||||
return { ok: false, error: msg };
|
||||
return { ok: false, error: err instanceof CustomBodyLimitError ? err.message : controller.signal.aborted ? "Endpoint timed out" : "Discovery request failed", fatal: err instanceof CustomBodyLimitError };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
@@ -210,6 +229,7 @@ const MAX_TOKEN_KEYS = ["max_output_tokens"] as const;
|
||||
function extractModels(body: unknown): CustomModel[] | null {
|
||||
const rows = modelRows(body);
|
||||
if (rows === null) return null;
|
||||
if (rows.length > 10_000) throw new CustomBodyLimitError("Model list exceeds 10000 entries");
|
||||
const byId = new Map<string, CustomModel>();
|
||||
for (const row of rows) {
|
||||
if (typeof row === "string") {
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/** 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 { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts";
|
||||
import { readCustomProvidersFile } from "../store/custom-providers.ts";
|
||||
|
||||
const reserved = new Set([...builtinProviders().map((p) => p.id), "chatgpt", CLAUDE_CODE_PROVIDER, ...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<string>();
|
||||
private ready = new Map<string, boolean>();
|
||||
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<string, string | undefined>();
|
||||
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(); }
|
||||
@@ -49,3 +49,4 @@ export { claudeCodeInstallationResponse } from "./claude-code.ts";
|
||||
// The custom endpoint module (registers into piModels() + probes for models).
|
||||
// No import side effects; the boot sync lives in stores.ts.
|
||||
export * from "./custom-provider.ts";
|
||||
export { customProviderGet, customProviderMutation } from "./custom-provider-http.ts";
|
||||
|
||||
+16
-16
@@ -54,19 +54,19 @@ 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";
|
||||
import { customProviderAvailable, installCustomProviderSource } from "./custom-provider.ts";
|
||||
import { claudeCodeAvailable } from "./claude-code-config.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "../agent/claude-code/constants.ts";
|
||||
import { CUSTOM_AUTH_BINDING } from "../core/custom-provider-auth.ts";
|
||||
|
||||
// Shared HMR-stable provenance lets external CLIs exclude file-only injections
|
||||
// without importing this store-backed module into the agent layer.
|
||||
import { authEnvRecord as boot } from "../core/auth-env.ts";
|
||||
|
||||
/** Is `id` a WRITABLE key name — curated catalog, model-provider env var, or
|
||||
* the custom endpoint's reserved key (writable through the same seam). */
|
||||
/** Ordinary writable key names. Custom auth writes must use the atomic
|
||||
* endpoint-binding seam, never the generic single-key API. */
|
||||
export function isWritableAuthKey(id: string): boolean {
|
||||
return isKnownAuthKey(id) || isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar;
|
||||
return id !== CUSTOM_AUTH_BINDING && id !== CUSTOM_PROVIDER.envVar && (isKnownAuthKey(id) || isProviderEnvVar(id));
|
||||
}
|
||||
|
||||
/** The settings page's provider dropdown: every pi-ai provider that takes
|
||||
@@ -92,7 +92,7 @@ export function envKeysIgnored(): boolean {
|
||||
*/
|
||||
export function initAuthEnv(): void {
|
||||
const record = boot();
|
||||
const fileVars = authStore.authVars();
|
||||
const fileVars = Object.fromEntries(Object.entries(authStore.authVars()).filter(([name]) => name !== CUSTOM_AUTH_BINDING));
|
||||
const managed = new Set<string>([
|
||||
...Object.keys(fileVars),
|
||||
...AUTH_KEYS.map((k) => k.id),
|
||||
@@ -100,6 +100,7 @@ export function initAuthEnv(): void {
|
||||
// an env-exported OPENAI_API_KEY with a file copy gets honest shadow
|
||||
// semantics, exactly like the curated vars.
|
||||
...AUTH_PROVIDER_KEYS.map((p) => p.envVar),
|
||||
CUSTOM_PROVIDER.envVar,
|
||||
]);
|
||||
if (envKeysIgnored()) {
|
||||
// THE FRESH-USER SWITCH: delete every managed env var (re-run on
|
||||
@@ -127,7 +128,7 @@ export function initAuthEnv(): void {
|
||||
/** Is this env-var name one the keys system manages (curated, provider,
|
||||
* or held in the file)? */
|
||||
function isManagedName(name: string): boolean {
|
||||
return isWritableAuthKey(name) || name in authStore.authVars();
|
||||
return name !== CUSTOM_AUTH_BINDING && (isWritableAuthKey(name) || name in authStore.authVars());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,6 +150,7 @@ export function resolveKeyStatuses(
|
||||
}
|
||||
const rows: AuthKeyStatus[] = [];
|
||||
for (const id of ids) {
|
||||
if (id === CUSTOM_AUTH_BINDING) continue;
|
||||
const envValue = env[id];
|
||||
const fileValue = fileVars[id];
|
||||
const isRealEnv = realEnv.has(id);
|
||||
@@ -170,7 +172,7 @@ export function resolveKeyStatuses(
|
||||
|
||||
/** The GET view: every curated key (present or not) + unknown file vars. */
|
||||
export function keyStatuses(): AuthKeyStatus[] {
|
||||
return resolveKeyStatuses(authStore.authVars(), process.env, boot().realEnv, AUTH_PROVIDER_ENV_VARS);
|
||||
return resolveKeyStatuses(authStore.authVars(), process.env, boot().realEnv, new Set([...AUTH_PROVIDER_ENV_VARS, CUSTOM_PROVIDER.envVar]));
|
||||
}
|
||||
|
||||
/** The live value for one var, env-first (the env-wins rule) — the canonical
|
||||
@@ -178,6 +180,7 @@ export function keyStatuses(): AuthKeyStatus[] {
|
||||
* STT route was the one cold-boot hole: a first-ever request to it would
|
||||
* otherwise read env before any fill-gap ran). */
|
||||
export function keyValue(id: string): string | undefined {
|
||||
if (id === CUSTOM_AUTH_BINDING) return undefined;
|
||||
// The fresh-user switch: ONLY the file store counts (belt-and-
|
||||
// suspenders — the boot deletion already emptied the env; this guards
|
||||
// any env key reintroduced after boot).
|
||||
@@ -192,9 +195,7 @@ 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 (provider === CUSTOM_PROVIDER.id) return customProviderAvailable();
|
||||
// Explicit opt-in + installation only, never authentication readiness.
|
||||
// Consulted at admission, not inside already-running agent loops.
|
||||
if (provider === CLAUDE_CODE_PROVIDER) return claudeCodeAvailable();
|
||||
@@ -210,10 +211,8 @@ export function providerKeyPresent(provider: string): boolean {
|
||||
* reports shadowedByEnv so the page can explain it).
|
||||
*/
|
||||
export function saveKey(id: string, value: string): { ok: true; status: AuthKeyStatus } | { ok: false; error: string } {
|
||||
const shape =
|
||||
isProviderEnvVar(id) || id === CUSTOM_PROVIDER.envVar
|
||||
? genericProviderKeyGuard(id, value)
|
||||
: validateKeyShape(id, value);
|
||||
if (id === CUSTOM_PROVIDER.envVar || id === CUSTOM_AUTH_BINDING) return { ok: false, error: "Use the custom endpoint editor" };
|
||||
const shape = isProviderEnvVar(id) ? genericProviderKeyGuard(id, value) : validateKeyShape(id, value);
|
||||
if (!shape.ok) return { ok: false, error: shape.error };
|
||||
const trimmed = value.trim();
|
||||
authStore.setAuthVar(id, trimmed);
|
||||
@@ -232,6 +231,7 @@ export function saveKey(id: string, value: string): { ok: true; status: AuthKeyS
|
||||
* wins) and the status shows the key still present from env.
|
||||
*/
|
||||
export function deleteKey(id: string): AuthKeyStatus {
|
||||
if (id === CUSTOM_PROVIDER.envVar || id === CUSTOM_AUTH_BINDING) throw new Error("Use the custom endpoint editor");
|
||||
authStore.deleteAuthVar(id);
|
||||
const record = boot();
|
||||
if (record.fillGap.has(id) && !record.realEnv.has(id)) {
|
||||
@@ -244,4 +244,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);
|
||||
installCustomProviderSource();
|
||||
|
||||
@@ -20,7 +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 { customProviderAvailable } from "./custom-provider.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";
|
||||
@@ -155,11 +155,10 @@ export function catalogProviders(): CatalogProvider[] {
|
||||
{ id: "chatgpt", envVar: "", keyPresent: chatGPTConnected() },
|
||||
{ id: CLAUDE_CODE_PROVIDER, envVar: "", keyPresent: claudeCodeAvailable() },
|
||||
...NATIVE_SUBSCRIPTIONS.map((p) => ({ id: p.logicalId, envVar: "", keyPresent: nativeSubscriptionConnected(p.providerId) })),
|
||||
...customProviderRows(),
|
||||
// The custom endpoint: a row only when usable (base URL set). keyPresent
|
||||
// reflects usability, not key presence (keyless local servers are allowed).
|
||||
...(customProviderUsable(effectiveSettings().customProvider)
|
||||
? [{ id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, keyPresent: true }]
|
||||
? [{ id: CUSTOM_PROVIDER.id, envVar: CUSTOM_PROVIDER.envVar, keyPresent: customProviderAvailable() }]
|
||||
: []),
|
||||
]).sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
@@ -180,7 +179,7 @@ export function toModelDto(model: Model<Api>): ModelDto {
|
||||
reasoning: model.reasoning,
|
||||
input: [...model.input],
|
||||
supportedLevels: getSupportedThinkingLevels(model),
|
||||
cost: model.provider === CLAUDE_CODE_PROVIDER ? null : {
|
||||
cost: model.provider === CLAUDE_CODE_PROVIDER || model.provider === CUSTOM_PROVIDER.id ? null : {
|
||||
input: model.cost.input,
|
||||
output: model.cost.output,
|
||||
cacheRead: model.cost.cacheRead,
|
||||
@@ -231,7 +230,6 @@ export function modelCatalog(): ModelCatalogResponse {
|
||||
rolePins: catalogRolePins(),
|
||||
slots: catalogSlots(),
|
||||
providers: catalogProviders(),
|
||||
...(customProvidersError() ? { customProvidersError: customProvidersError() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { calculateCost, type Usage } from "@earendil-works/pi-ai";
|
||||
import { piModels } from "../agent/models.ts";
|
||||
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
|
||||
import { CLAUDE_CODE_PROVIDER } from "../core/contracts/claude-code.ts";
|
||||
import type { CallUsage, ChatRecord, JobRecord, TurnUsage } from "../core/contracts.ts";
|
||||
import { isSubscriptionModel, sessionCost as foldSessionCost, turnCost as foldTurnCost, jobCost as foldJobCost } from "../core/prices.ts";
|
||||
@@ -12,7 +13,7 @@ function catalogModel(id: string) {
|
||||
const colon = id.indexOf(":");
|
||||
// Native CLI routing determines billing. Adapter zero-rate placeholders
|
||||
// must never become an apparent $0 estimate or subscription classification.
|
||||
if (colon < 0 || isSubscriptionModel(id) || id.slice(0, colon) === CLAUDE_CODE_PROVIDER) return undefined;
|
||||
if (colon < 0 || isSubscriptionModel(id) || [CLAUDE_CODE_PROVIDER, CUSTOM_PROVIDER.id].includes(id.slice(0, colon))) return undefined;
|
||||
return piModels().getModel(id.slice(0, colon), id.slice(colon + 1));
|
||||
}
|
||||
|
||||
|
||||
@@ -400,7 +400,7 @@ export function parseDictationSettingsBody(body: unknown): SettingsBodyResult<Di
|
||||
* whole desired map, replace-whole like every section. Strict write side — a
|
||||
* token count must be a whole number at or above the floor (the read side
|
||||
* clamps, this rejects — the compaction discipline). `reasoning` is the
|
||||
* tri-state pin: true forces thinking on, false forces it off. */
|
||||
* tri-state capability pin, not a backend execution toggle. */
|
||||
export function parseCustomModelOverridesBody(
|
||||
body: unknown,
|
||||
): SettingsBodyResult<Record<string, CustomModelOverride>> {
|
||||
@@ -430,7 +430,7 @@ export function parseCustomModelOverridesBody(
|
||||
if (typeof entry.reasoning !== "boolean") return { ok: false, error: `reasoning for ${id} must be true or false` };
|
||||
pin.reasoning = entry.reasoning;
|
||||
}
|
||||
out[id] = pin;
|
||||
Object.defineProperty(out, id, { value: pin, enumerable: true, configurable: true, writable: true });
|
||||
}
|
||||
return { ok: true, settings: out };
|
||||
}
|
||||
|
||||
@@ -36,4 +36,4 @@ export function effectiveSettings(): EffectiveSettings {
|
||||
|
||||
// Boot: register the custom endpoint (when configured) into piModels(); the
|
||||
// POST /api/custom-provider route re-syncs after every connect.
|
||||
syncCustomProvider(effectiveSettings().customProvider);
|
||||
syncCustomProvider();
|
||||
|
||||
+43
-4
@@ -7,9 +7,10 @@
|
||||
*
|
||||
* The server (src/server/keys.ts) is the only intended writer: it applies
|
||||
* shape validation before setAuthVar and env precedence on top of
|
||||
* authVars(). This adapter persists what it is given, never throws
|
||||
* (absent/corrupt file = {} = "no keys yet", the documented first-boot
|
||||
* state, matching settings doctrine), and preserves unknown vars verbatim
|
||||
* authVars(). Ordinary reads never throw (absent/corrupt file = {} =
|
||||
* "no keys yet"); custom-only pair reads/writes below are deliberately
|
||||
* strict so an unreadable private record is never overwritten as {}.
|
||||
* Ordinary writes preserve unknown string vars verbatim
|
||||
* (forward-compat: future entries — e.g. a plans-login token — ride along
|
||||
* untouched until a catalog entry adopts them).
|
||||
*/
|
||||
@@ -18,6 +19,8 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { AUTH_FILE } from "../../config.ts";
|
||||
import type { AuthStore } from "../core/ports.ts";
|
||||
import { CUSTOM_PROVIDER } from "../core/auth-keys.ts";
|
||||
import { CUSTOM_AUTH_BINDING, parseCustomAuthBinding, type CustomAuthBinding } from "../core/custom-provider-auth.ts";
|
||||
|
||||
/** The flat string→string map, or {} for absent/corrupt/non-object. */
|
||||
function readMap(): Record<string, string> {
|
||||
@@ -36,7 +39,7 @@ function readMap(): Record<string, string> {
|
||||
* leave a 0644 file (secrets) or a truncated JSON (looks like "no keys").
|
||||
* tmp-in-same-dir + rename keeps the swap atomic; chmod BEFORE the
|
||||
* rename so the mode never exists relaxed on the final path. */
|
||||
function writeMap(map: Record<string, string>): void {
|
||||
function writeMap(map: Record<string, unknown>): void {
|
||||
const dir = path.dirname(AUTH_FILE);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = path.join(dir, `.auth.json.tmp-${process.pid}`);
|
||||
@@ -50,14 +53,50 @@ export const authStore: AuthStore = {
|
||||
return readMap();
|
||||
},
|
||||
setAuthVar(name, value) {
|
||||
assertOrdinaryVar(name);
|
||||
const map = readMap();
|
||||
map[name] = value;
|
||||
writeMap(map);
|
||||
},
|
||||
deleteAuthVar(name) {
|
||||
assertOrdinaryVar(name);
|
||||
const map = readMap();
|
||||
if (!(name in map)) return; // absent = silent success, idempotent
|
||||
delete map[name];
|
||||
writeMap(map);
|
||||
},
|
||||
};
|
||||
|
||||
/** Custom writes must never turn an unreadable/corrupt private file into {}.
|
||||
* Other providers retain their existing adapter semantics. Unknown JSON values
|
||||
* survive custom-only updates, not just the ordinary string projection. */
|
||||
function readCustomMap(): Record<string, unknown> {
|
||||
let text: string;
|
||||
try { text = fs.readFileSync(AUTH_FILE, "utf8"); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; throw new Error("Custom authorization storage unavailable"); }
|
||||
let raw: unknown;
|
||||
try { raw = JSON.parse(text); } catch { throw new Error("Custom authorization storage unavailable"); }
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("Custom authorization storage unavailable");
|
||||
const map = raw as Record<string, unknown>;
|
||||
if (Object.hasOwn(map, CUSTOM_AUTH_BINDING) && !parseCustomAuthBinding(map[CUSTOM_AUTH_BINDING])) throw new Error("Invalid custom authorization record");
|
||||
if (Object.hasOwn(map, CUSTOM_PROVIDER.envVar) && typeof map[CUSTOM_PROVIDER.envVar] !== "string") throw new Error("Invalid custom credential record");
|
||||
return map;
|
||||
}
|
||||
function assertOrdinaryVar(name: string): void {
|
||||
if (name === CUSTOM_AUTH_BINDING || name === CUSTOM_PROVIDER.envVar) throw new Error("Use the custom endpoint editor");
|
||||
}
|
||||
/** One immutable file snapshot. Keyless callers never invoke fileKey(). */
|
||||
export function readCustomAuth() {
|
||||
const map = readCustomMap();
|
||||
return { binding: parseCustomAuthBinding(map[CUSTOM_AUTH_BINDING]), fileKey: () => map[CUSTOM_PROVIDER.envVar] as string | undefined };
|
||||
}
|
||||
/** Replace just the custom pair in ONE existing atomic record write. */
|
||||
export function writeCustomAuth(binding: CustomAuthBinding | undefined, fileKey: string | undefined): void {
|
||||
const map = readCustomMap();
|
||||
if (binding && !parseCustomAuthBinding(JSON.stringify(binding))) throw new Error("Invalid custom authorization record");
|
||||
if (fileKey === undefined) delete map[CUSTOM_PROVIDER.envVar];
|
||||
else Object.defineProperty(map, CUSTOM_PROVIDER.envVar, { value: fileKey, enumerable: true, configurable: true, writable: true });
|
||||
if (binding === undefined) delete map[CUSTOM_AUTH_BINDING];
|
||||
else Object.defineProperty(map, CUSTOM_AUTH_BINDING, { value: JSON.stringify(binding), enumerable: true, configurable: true, writable: true });
|
||||
writeMap(map);
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/** 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." };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user