import type { CcProvider, ModelMetaOverride, PiApi } from "./types.ts"; import { API_MODEL_META, DEFAULT_MODEL_META } from "./types.ts"; import { applyAnyrouterHeaders } from "./headers/anyrouter.ts"; import { applyCodexWindowId } from "./headers/codex.ts"; import { mergeHeaders } from "./headers/merge.ts"; import type { HeaderRule } from "./types.ts"; import { isSwitchable } from "./parse/index.ts"; import type { ModelsDevCapabilities } from "./capabilities/models-dev.ts"; import { ccMetaFrom } from "./capabilities/layers.ts"; import { resolveRegistrationCapability, type RegistrationCapabilityDecision, } from "./capabilities/registration.ts"; import { resolveModelTupleCompat, tupleCompatForRegistration, type ModelTupleCompat, type ResolvedModelTupleCompat, } from "./model-tuple-compat.ts"; import { isOfficialOpenAiChatEndpoint } from "./endpoints.ts"; import { providerWireCompatForRegistration, resolveProviderWireCompat, type ProviderWireCompat, type ResolvedProviderWireCompat, } from "./provider-wire-compat.ts"; /** Minimal ExtensionAPI surface used by register helpers. */ export interface PiRegisterApi { registerProvider: (name: string, config: Record) => void; // optional in some pi versions unregisterProvider?: (name: string) => void; setModel: (model: unknown) => boolean | Promise; } /** * Model config with per-api tiered meta (SPEC review #4). * Flat pi-switch modelMeta is reshaped to Pi's modern layout: * - top-level thinkingLevelMap * - nested OpenAI/provider compatibility fields * Legacy top-level thinkingFormat is never emitted. */ export function toModelConfig( modelId: string, api?: PiApi | null, /** Per-provider overrides from pi-switch.json providerOverrides[dbId].modelMeta. */ meta?: ModelMetaOverride, /** Exact-model tuple wire dialect (Chat #64 / Anthropic #67). */ tupleCompat?: ResolvedModelTupleCompat, ) { const tier = api ? API_MODEL_META[api] : undefined; const compat: Record = {}; // Tuple registration wins; legacy flat thinkingFormat still flows via // resolveChatTupleCompat → tupleCompatForRegistration when no tuple set. const registrationTuple = tupleCompatForRegistration(tupleCompat); if (registrationTuple) { Object.assign(compat, registrationTuple); } else { if (meta?.thinkingFormat) compat.thinkingFormat = meta.thinkingFormat; if (typeof meta?.requiresReasoningContentOnAssistantMessages === "boolean") { compat.requiresReasoningContentOnAssistantMessages = meta.requiresReasoningContentOnAssistantMessages; } if (typeof meta?.supportsDeveloperRole === "boolean") { compat.supportsDeveloperRole = meta.supportsDeveloperRole; } } // Issue #63: never invent protocol maxTokens; require an explicit resolved value. // reasoning defaults to conservative false (not protocol true) when absent. if (typeof meta?.maxTokens !== "number" || meta.maxTokens <= 0) { throw new Error( `toModelConfig: maxTokens unresolved for ${modelId} (issue #63 — refuse protocol floor)`, ); } return { id: modelId, name: modelId, reasoning: meta?.reasoning ?? false, input: (tier?.input ?? ["text"]) as ("text" | "image")[], cost: DEFAULT_MODEL_META.cost, contextWindow: meta?.contextWindow ?? tier?.contextWindow ?? DEFAULT_MODEL_META.contextWindow, maxTokens: meta.maxTokens, ...(meta?.thinkingLevelMap ? { thinkingLevelMap: meta.thinkingLevelMap } : {}), ...(Object.keys(compat).length ? { compat } : {}), }; } export type BuiltModelConfig = ReturnType; export type BuiltProviderConfig = { name: string; baseUrl: string; apiKey: string; api: PiApi; authHeader: boolean; headers?: Record; models: BuiltModelConfig[]; }; /** * Options for building/registering one provider's config. * One shared type: buildProviderConfig and registerProvider used to declare * two structurally-identical copies — a field added to one never reached the * other until a call site broke. */ export interface ProviderRegistrationOpts { rules: HeaderRule[]; overrideHeaders?: Record; /** fingerprint:"none" — skip defaults/provider-headers rules */ skipRules?: boolean; vars?: Record; debug?: boolean; onReject?: (name: string, reason: string) => void; /** Preferred deep seam: registration and diagnostics share this decision. */ registrationDecisionFor?: ( modelId: string, ) => RegistrationCapabilityDecision; /** Legacy direct-build fallback when registrationDecisionFor is absent. */ modelMeta?: ModelMetaOverride; /** * Legacy per-model resolver; wins over `modelMeta` when it returns a value. * Lets one provider register models with different reasoning/ctx settings. */ modelMetaFor?: (modelId: string) => ModelMetaOverride | undefined; /** * Read-only models.dev cache lookup by exact model id (no network). * Stale last-good entries are still used; missing key = layer absent. */ modelsDevFor?: (modelId: string) => ModelsDevCapabilities | undefined; /** * Pre-resolved Provider Chat wire fact (preferred). */ providerWireCompat?: ResolvedProviderWireCompat; /** Raw Provider-scoped wire override when providerWireCompat omitted. */ providerWireOverride?: ProviderWireCompat; /** * Exact-model tuple compat resolver (Chat #64 / Anthropic #67). * Independent of model capability and Provider wire compat. */ tupleCompatFor?: (modelId: string) => | { tuple?: ModelTupleCompat; legacyFlat?: ModelMetaOverride } | undefined; } export function buildProviderConfig( provider: CcProvider, modelIds: string[], opts: ProviderRegistrationOpts, ): BuiltProviderConfig | undefined { if (!isSwitchable(provider) || !provider.api) return undefined; const ids = modelIds.length ? modelIds : provider.configModels; const resolvedWire = opts.providerWireCompat ?? resolveProviderWireCompat({ provider: { api: provider.api, baseUrl: provider.baseUrl }, override: opts.providerWireOverride, }); const registrationWire = providerWireCompatForRegistration(resolvedWire); const models = ids .filter(Boolean) .map((raw) => raw.trim()) .flatMap((id) => { const userMeta = opts.registrationDecisionFor ? undefined : opts.modelMetaFor?.(id) ?? opts.modelMeta; const decision = opts.registrationDecisionFor?.(id) ?? resolveRegistrationCapability({ modelId: id, api: provider.api, baseUrl: provider.baseUrl, userMeta, modelsDev: opts.modelsDevFor?.(id), ccMeta: ccMetaFrom(provider.meta), }); // Issue #63: skip models with no trusted maxTokens authority. if (decision.maxTokensUnresolved || !decision.meta) return []; const tupleInput = opts.tupleCompatFor?.(id); // Dialect fields live on decision.meta after user > built-in merge (#71). // Prefer that over raw userMeta so deepseek*/qwen* profiles reach tuple. const tupleResolved = resolveModelTupleCompat({ modelId: id, providerApi: provider.api, tuple: tupleInput?.tuple, legacyFlat: tupleInput?.legacyFlat ?? decision.meta, officialOpenAi: provider.api === "openai-completions" && isOfficialOpenAiChatEndpoint(provider.baseUrl), }); // Strip wire-dialect fields from capability meta so they only flow via tuple. const { thinkingFormat: _tf, requiresReasoningContentOnAssistantMessages: _rr, supportsDeveloperRole: _sd, ...capabilityMeta } = decision.meta; const model = toModelConfig(id, provider.api, capabilityMeta, tupleResolved); if (!registrationWire) return [model]; return [ { ...model, compat: { ...(model.compat ?? {}), ...registrationWire, }, }, ]; }); if (!models.length) return undefined; let headers = mergeHeaders({ api: provider.api, rules: opts.rules, overrideHeaders: opts.overrideHeaders, skipRules: opts.skipRules, vars: opts.vars, debug: opts.debug, onReject: opts.onReject, }); // anyrouter.top anthropic: merge official context-1m beta (Claude Code parity). // fingerprint:"none" is an explicit request to keep only user headers. if (!opts.skipRules) { headers = applyCodexWindowId(headers, opts.vars?.codexWindowId); headers = applyAnyrouterHeaders(provider.api, provider.baseUrl, headers); } return { name: provider.displayName, baseUrl: provider.baseUrl, apiKey: provider.apiKey, api: provider.api, authHeader: provider.authHeader, headers: Object.keys(headers).length ? headers : undefined, models, }; } export type RegisterProviderResult = | { kind: "registered"; providerName: string; modelIds: string[] } | { kind: "skipped"; error: string }; export function registerProvider( pi: PiRegisterApi, provider: CcProvider, modelIds: string[], opts: ProviderRegistrationOpts, ): RegisterProviderResult { const config = buildProviderConfig(provider, modelIds, opts); if (!config) { return { kind: "skipped", error: provider.parseError ?? `no registerable models for ${provider.piName}`, }; } pi.registerProvider(provider.piName, config); return { kind: "registered", providerName: provider.piName, modelIds: config.models.map((model) => model.id), }; }