From 4efa33dfd82d51c121657deeb9e35e7799aa8cbb Mon Sep 17 00:00:00 2001 From: vitti Date: Tue, 4 Aug 2026 08:59:04 -0300 Subject: [PATCH] model providers: register the Nexforce Router (declarative entry) Add nexforce as a recognized model provider across core and CLI so its NEXFORCE_API_KEY can be stored and validated from the Admin page, listed in deployment secrets, and probed by the CLI doctor. The provider has no servable base model yet (the pinned pi-ai runtime has no nexforce provider), so MODEL_PROVIDER=nexforce is refused at config load and the CLI refuses --model-provider nexforce; key validation probes a 1-token chat completion because the /v1/models catalog is public and ignores auth. --- cli/src/backends/doctor.ts | 20 ++++++++- cli/src/cli.ts | 6 +++ cli/src/commands/setup.ts | 6 +++ cli/src/config.ts | 9 +++- cli/src/secrets.ts | 7 +++ cli/templates/deployment/deployment.md | 11 ++++- cli/test/config.test.ts | 7 +++ cli/test/secrets.test.ts | 1 + plugins/admin/public/index.html | 1 + src/api/app-turn.ts | 2 +- src/api/routes/admin/model-providers.ts | 45 ++++++++++++------- src/api/routes/admin/scope-config.ts | 5 ++- src/config.ts | 3 ++ src/deployment/secret-schema.ts | 5 ++- src/model/model-catalog.ts | 4 +- src/model/model-credential-store.ts | 1 + src/model/pi-models.ts | 15 +++++-- src/wiring.ts | 1 + test/admin-observability.test.ts | 2 +- test/config.test.ts | 7 ++- test/model-credential-route.test.ts | 28 ++++++++++++ test/model-registry.test.ts | 57 +++++++++++++++++-------- test/pi-models.test.ts | 16 ++++--- test/secret-schema-drift.test.ts | 1 + test/webui-model-allowlist.test.ts | 2 +- 25 files changed, 208 insertions(+), 54 deletions(-) diff --git a/cli/src/backends/doctor.ts b/cli/src/backends/doctor.ts index 37d1a204f..816ebd6ff 100644 --- a/cli/src/backends/doctor.ts +++ b/cli/src/backends/doctor.ts @@ -218,8 +218,14 @@ async function baseModelCheck(config: QmConfig, secrets: Map): P step(`base model provider ${provider}: ${name} accepted`); } +const NEXFORCE_KEY_PROBE = JSON.stringify({ + model: "deepseek/deepseek-v4-flash", + max_tokens: 1, + messages: [{ role: "user", content: "ping" }], +}); + const MODEL_PROVIDER_PROBES: Readonly< - Record Record }> + Record Record; body?: string }> > = { anthropic: { url: "https://api.anthropic.com/v1/models?limit=1", @@ -227,13 +233,23 @@ const MODEL_PROVIDER_PROBES: Readonly< }, openai: { url: "https://api.openai.com/v1/models", headers: (key) => ({ authorization: `Bearer ${key}` }) }, openrouter: { url: "https://openrouter.ai/api/v1/key", headers: (key) => ({ authorization: `Bearer ${key}` }) }, + nexforce: { + url: "https://router.nexforce.ai/v1/chat/completions", + headers: (key) => ({ authorization: `Bearer ${key}`, "content-type": "application/json" }), + body: NEXFORCE_KEY_PROBE, + }, }; async function modelProviderCheck(provider: ModelProvider, apiKey: string): Promise { const probe = MODEL_PROVIDER_PROBES[provider]; let res: Response; try { - res = await fetch(probe.url, { headers: probe.headers(apiKey), signal: AbortSignal.timeout(10_000) }); + res = await fetch(probe.url, { + method: probe.body ? "POST" : "GET", + headers: probe.headers(apiKey), + ...(probe.body ? { body: probe.body } : {}), + signal: AbortSignal.timeout(10_000), + }); } catch (e) { throw new CliError( `could not reach the ${provider} API: ${errMessage(e)} — check network access (and any proxy) and retry`, diff --git a/cli/src/cli.ts b/cli/src/cli.ts index 148761850..fd8fb44f5 100644 --- a/cli/src/cli.ts +++ b/cli/src/cli.ts @@ -11,6 +11,7 @@ import { readConfigOrgId, EMAIL_TRANSPORTS, MODEL_PROVIDERS, + MODEL_PROVIDER_HARNESSES, type EmailTransport, type ModelProvider, } from "./config.ts"; @@ -88,6 +89,11 @@ function modelProviderFlag(flags: Flags): ModelProvider | undefined { if (provider !== undefined && !isModelProvider(provider)) { throw new CliError(`--model-provider must be ${MODEL_PROVIDERS.join(" | ")}`, { clause: "cli.invocation" }); } + if (provider !== undefined && MODEL_PROVIDER_HARNESSES[provider].length === 0) { + throw new CliError(`--model-provider ${provider} cannot serve a base model on any harness yet`, { + clause: "cli.invocation", + }); + } return provider; } diff --git a/cli/src/commands/setup.ts b/cli/src/commands/setup.ts index c475f3a26..75e127298 100644 --- a/cli/src/commands/setup.ts +++ b/cli/src/commands/setup.ts @@ -43,6 +43,11 @@ const PLAYBOOKS: Readonly> = { "(the key starts with sk-or-).", "Only one provider key is needed — set the one whose model you want as the base model.", ], + NEXFORCE_API_KEY: [ + "Create an API key at https://marketplace.nexforce.ai/workspace/ai-gateway/ai-gateway-keys", + "(the key starts with nfc_).", + "Only one provider key is needed — set the one whose model you want as the base model.", + ], SLACK_BOT_TOKEN: [ "Create the Slack app from the scaffolded manifest:", " 1. Open https://api.slack.com/apps -> Create New App -> From a manifest.", @@ -117,6 +122,7 @@ const FORMAT_HINTS: Readonly> ANTHROPIC_API_KEY: { prefix: "sk-ant-", label: "Anthropic keys usually start with sk-ant-" }, OPENAI_API_KEY: { prefix: "sk-", label: "OpenAI keys usually start with sk-" }, OPENROUTER_API_KEY: { prefix: "sk-or-", label: "OpenRouter keys usually start with sk-or-" }, + NEXFORCE_API_KEY: { prefix: "nfc_", label: "Nexforce keys usually start with nfc_" }, SLACK_BOT_TOKEN: { prefix: "xoxb-", label: "bot tokens start with xoxb-" }, SLACK_APP_TOKEN: { prefix: "xapp-", label: "app-level tokens start with xapp-" }, }; diff --git a/cli/src/config.ts b/cli/src/config.ts index 2277e65cf..93bf3fbb1 100644 --- a/cli/src/config.ts +++ b/cli/src/config.ts @@ -104,19 +104,21 @@ export function awsWorkloadArchitecture(config: QmConfig, workload: string): "ar return service.architecture ?? "arm64"; } -export const MODEL_PROVIDERS = ["anthropic", "openai", "openrouter"] as const; +export const MODEL_PROVIDERS = ["anthropic", "openai", "openrouter", "nexforce"] as const; export type ModelProvider = (typeof MODEL_PROVIDERS)[number]; export const MODEL_PROVIDER_KEYS: Readonly> = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", openrouter: "OPENROUTER_API_KEY", + nexforce: "NEXFORCE_API_KEY", }; export const MODEL_PROVIDER_HARNESSES: Readonly> = { anthropic: ["pi", "opencode", "claude", "mock"], openai: ["pi", "opencode", "codex", "mock"], openrouter: ["pi", "mock"], + nexforce: [], }; export const isModelProvider = (value: unknown): value is ModelProvider => @@ -731,8 +733,11 @@ function validateModelProvider(config: QmConfig, path: string): void { if (!provider) return; const harness = configuredHarness(config); if (!MODEL_PROVIDER_HARNESSES[provider].includes(harness)) { + const servable = MODEL_PROVIDER_HARNESSES[provider]; throw new CliError( - `${path}: model provider "${provider}" cannot serve a base model on env.core.HARNESS "${harness}" — that harness runs no ${provider} model, so every agent turn would be refused. Use ${MODEL_PROVIDER_HARNESSES[provider].join(", ")}, or pick a provider that harness can bill.`, + servable.length === 0 + ? `${path}: model provider "${provider}" cannot serve a base model on any harness — no ${provider} model is available yet, so every agent turn would be refused.` + : `${path}: model provider "${provider}" cannot serve a base model on env.core.HARNESS "${harness}" — that harness runs no ${provider} model, so every agent turn would be refused. Use ${servable.join(", ")}, or pick a provider that harness can bill.`, ); } } diff --git a/cli/src/secrets.ts b/cli/src/secrets.ts index ed061840b..e7b211d86 100644 --- a/cli/src/secrets.ts +++ b/cli/src/secrets.ts @@ -53,6 +53,13 @@ export const FIRST_PARTY_SECRET_SPECS: readonly SecretSpec[] = [ description: 'OpenRouter API key: bills the base model when modelProvider is "openrouter", an optional deployment fallback otherwise.', }, + { + name: "NEXFORCE_API_KEY", + service: "core", + required: { when: { kind: "model-provider", provider: "nexforce" }, optionalOtherwise: true }, + description: + 'Nexforce Router API key: bills the base model when modelProvider is "nexforce", an optional deployment fallback otherwise.', + }, { name: "OPENAI_API_KEY", service: "core", diff --git a/cli/templates/deployment/deployment.md b/cli/templates/deployment/deployment.md index cc33e60e2..a1a27e9e2 100644 --- a/cli/templates/deployment/deployment.md +++ b/cli/templates/deployment/deployment.md @@ -31,7 +31,9 @@ it in place. If the repository has not been initialized, collect: required secret. Collect the key in the same pass as the other credentials — a deployment that cannot answer one message is not finished. An operator who genuinely wants to defer omits `modelProvider` and adds the key from the - Admin page later, but do not offer that as the default; + Admin page later, but do not offer that as the default. (The Nexforce Router + is registered as a provider too, but only for Admin-page key management — it + cannot yet serve a base model, so it is never a `modelProvider` choice.) - model; - region and provider account or organization; - whether the provider hostname is acceptable; @@ -190,6 +192,13 @@ startup rather than at the first message. The same rule covers the harness: `HARNESS` `codex` runs OpenAI models alone, `claude` runs Anthropic models alone, and `openrouter` needs the default `pi` harness. +The Nexforce Router is registered as a model provider — its key can be stored +and validated from the Admin page (`NEXFORCE_API_KEY`, minted at +`https://marketplace.nexforce.ai/workspace/ai-gateway/ai-gateway-keys`) — but +it cannot yet serve a base model, so it is never a `modelProvider` choice. +Store the key from the Admin page now; do not promise a Nexforce-served +deployment. + An operator may still prefer to hold the key centrally and rotate it from the Admin page. That is a deliberate choice, not the default: drop `modelProvider` from `qm.config.jsonc`, note in the handoff that the deployment has no base model diff --git a/cli/test/config.test.ts b/cli/test/config.test.ts index 7d62298de..997a11305 100644 --- a/cli/test/config.test.ts +++ b/cli/test/config.test.ts @@ -1153,6 +1153,13 @@ test("modelProvider must name a vendor the configured harness can bill", () => { withConfig({ modelProvider: "openai", env: { core: { HARNESS: "codex" } } }, ({ path }) => { assert.equal(loadConfigAt(path).config.modelProvider, "openai"); }); + withConfig({ modelProvider: "nexforce", env: { core: { HARNESS: "pi" } } }, ({ path }) => { + assert.throws( + () => loadConfigAt(path), + /model provider "nexforce" cannot serve a base model/, + "nexforce has no servable base model yet, so it cannot be declared as the base provider", + ); + }); withConfig({ modelProvider: "openrouter" }, ({ path }) => { assert.equal( loadConfigAt(path).config.modelProvider, diff --git a/cli/test/secrets.test.ts b/cli/test/secrets.test.ts index e44035be3..330d441da 100644 --- a/cli/test/secrets.test.ts +++ b/cli/test/secrets.test.ts @@ -197,6 +197,7 @@ test("naming a base model provider makes that provider's key a required deployme ["anthropic", "ANTHROPIC_API_KEY"], ["openai", "OPENAI_API_KEY"], ["openrouter", "OPENROUTER_API_KEY"], + ["nexforce", "NEXFORCE_API_KEY"], ] as const) { const config = makeConfig({ modelProvider: provider }); assert.equal(secretByName(config, key).required, true, `${provider} requires ${key}`); diff --git a/plugins/admin/public/index.html b/plugins/admin/public/index.html index f019ed3b7..291f0c5be 100644 --- a/plugins/admin/public/index.html +++ b/plugins/admin/public/index.html @@ -6920,6 +6920,7 @@

Confirm governance change

anthropic: "Anthropic", openai: "OpenAI", openrouter: "OpenRouter — hosted open models", + nexforce: "Nexforce Router — one key for Anthropic, OpenAI, Google, DeepSeek & more", }; function onboardingProviderForModel(modelId) { const owning = Object.entries(onboardingModels).find(([, models]) => diff --git a/src/api/app-turn.ts b/src/api/app-turn.ts index 17d835f0e..3a00df58d 100644 --- a/src/api/app-turn.ts +++ b/src/api/app-turn.ts @@ -152,7 +152,7 @@ export function createTurnMethods( return { status: "refused", reason: `runtime ${req.harness} is not approved` }; } const configuredKeys = deps.providerKeys ?? - deps.modelProviders ?? { anthropic: false, openai: false, openrouter: false }; + deps.modelProviders ?? { anthropic: false, openai: false, openrouter: false, nexforce: false }; let providers = deps.modelProviders; if (deps.modelCredentials) { providers = modelProviderAvailabilityFor( diff --git a/src/api/routes/admin/model-providers.ts b/src/api/routes/admin/model-providers.ts index 66a2e0987..450e35e1d 100644 --- a/src/api/routes/admin/model-providers.ts +++ b/src/api/routes/admin/model-providers.ts @@ -4,21 +4,34 @@ import { sendJson } from "../../http.ts"; import type { ApiCtx } from "../route.ts"; import { audit, authorizeAdmin, orgScope } from "../shared.ts"; -const VALIDATION_REQUESTS: Record Record }> = - { - anthropic: { - url: "https://api.anthropic.com/v1/models", - headers: (apiKey) => ({ "x-api-key": apiKey, "anthropic-version": "2023-06-01" }), - }, - openai: { - url: "https://api.openai.com/v1/models", - headers: (apiKey) => ({ authorization: `Bearer ${apiKey}` }), - }, - openrouter: { - url: "https://openrouter.ai/api/v1/key", - headers: (apiKey) => ({ authorization: `Bearer ${apiKey}` }), - }, - }; +const NEXFORCE_KEY_PROBE = JSON.stringify({ + model: "deepseek/deepseek-v4-flash", + max_tokens: 1, + messages: [{ role: "user", content: "ping" }], +}); + +const VALIDATION_REQUESTS: Record< + ModelProvider, + { url: string; headers: (apiKey: string) => Record; body?: string } +> = { + anthropic: { + url: "https://api.anthropic.com/v1/models", + headers: (apiKey) => ({ "x-api-key": apiKey, "anthropic-version": "2023-06-01" }), + }, + openai: { + url: "https://api.openai.com/v1/models", + headers: (apiKey) => ({ authorization: `Bearer ${apiKey}` }), + }, + openrouter: { + url: "https://openrouter.ai/api/v1/key", + headers: (apiKey) => ({ authorization: `Bearer ${apiKey}` }), + }, + nexforce: { + url: "https://router.nexforce.ai/v1/chat/completions", + headers: (apiKey) => ({ authorization: `Bearer ${apiKey}`, "content-type": "application/json" }), + body: NEXFORCE_KEY_PROBE, + }, +}; async function actor(ctx: ApiCtx) { const scope = orgScope(ctx.deps); @@ -29,7 +42,9 @@ async function validate(ctx: ApiCtx, provider: ModelProvider, apiKey: string): P const request = VALIDATION_REQUESTS[provider]; try { const response = await (ctx.deps.modelCredentialFetch ?? fetch)(request.url, { + method: request.body ? "POST" : "GET", headers: request.headers(apiKey), + ...(request.body ? { body: request.body } : {}), signal: AbortSignal.timeout(5_000), }); return response.ok; diff --git a/src/api/routes/admin/scope-config.ts b/src/api/routes/admin/scope-config.ts index 22acc4f7e..412e47c3d 100644 --- a/src/api/routes/admin/scope-config.ts +++ b/src/api/routes/admin/scope-config.ts @@ -250,7 +250,10 @@ export async function getScopeConfig(ctx: ApiCtx): Promise { const currentModel = runtime && typeof runtime.modelId === "string" && - (currentProvider === "anthropic" || currentProvider === "openai" || currentProvider === "openrouter") + (currentProvider === "anthropic" || + currentProvider === "openai" || + currentProvider === "openrouter" || + currentProvider === "nexforce") ? ({ id: runtime.modelId, name: resolvedCurrent!.name, provider: currentProvider } satisfies ModelCatalogEntry) : null; const modelsFor = (harnessId: string) => { diff --git a/src/config.ts b/src/config.ts index 534134b09..8a2ee7acf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -49,6 +49,7 @@ export interface Config { anthropicApiKey?: string; openaiApiKey?: string; openrouterApiKey?: string; + nexforceApiKey?: string; modelProvider?: ModelProvider; piCaptureRequests: boolean; piSystemCacheSplit: boolean; @@ -155,6 +156,7 @@ export function providerKeysPresent(config: Config): ModelProviderAvailability { anthropic: Boolean(config.anthropicApiKey), openai: Boolean(config.openaiApiKey), openrouter: Boolean(config.openrouterApiKey), + nexforce: Boolean(config.nexforceApiKey), }; } @@ -727,6 +729,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config { ...(env.ANTHROPIC_API_KEY ? { anthropicApiKey: env.ANTHROPIC_API_KEY } : {}), ...(env.OPENAI_API_KEY ? { openaiApiKey: env.OPENAI_API_KEY } : {}), ...(env.OPENROUTER_API_KEY ? { openrouterApiKey: env.OPENROUTER_API_KEY } : {}), + ...(env.NEXFORCE_API_KEY ? { nexforceApiKey: env.NEXFORCE_API_KEY } : {}), ...(modelProvider ? { modelProvider } : {}), ...(env.ADMIN_GRANTS ? { adminGrants: env.ADMIN_GRANTS } : {}), piCaptureRequests: boolEnvStrict("PI_CAPTURE_REQUESTS", env.PI_CAPTURE_REQUESTS) ?? true, diff --git a/src/deployment/secret-schema.ts b/src/deployment/secret-schema.ts index 47e2e86fa..bf9241f66 100644 --- a/src/deployment/secret-schema.ts +++ b/src/deployment/secret-schema.ts @@ -13,7 +13,8 @@ type SecretGate = | "linear-oauth" | "model-anthropic" | "model-openai" - | "model-openrouter"; + | "model-openrouter" + | "model-nexforce"; export interface RuntimeSecretSpec { name: string; @@ -29,6 +30,7 @@ export const CORE_SECRET_SPECS: readonly RuntimeSecretSpec[] = [ { name: "OPENAI_API_KEY", requiredWhen: ["codex", "model-openai"] }, { name: "ANTHROPIC_API_KEY", requiredWhen: "model-anthropic" }, { name: "OPENROUTER_API_KEY", requiredWhen: "model-openrouter" }, + { name: "NEXFORCE_API_KEY", requiredWhen: "model-nexforce" }, { name: "DATABASE_URL", requiredWhen: "postgres" }, { name: "SPRITES_TOKEN", requiredWhen: "sprites" }, { name: "FLY_API_TOKEN", requiredWhen: "fly-sandbox" }, @@ -53,6 +55,7 @@ const GATE_PREDICATES: Readonly b "model-anthropic": (env) => env.MODEL_PROVIDER?.trim() === "anthropic", "model-openai": (env) => env.MODEL_PROVIDER?.trim() === "openai", "model-openrouter": (env) => env.MODEL_PROVIDER?.trim() === "openrouter", + "model-nexforce": (env) => env.MODEL_PROVIDER?.trim() === "nexforce", }; export function validateCoreSecretEnv(env: NodeJS.ProcessEnv): string[] { diff --git a/src/model/model-catalog.ts b/src/model/model-catalog.ts index 812d69c92..7705cc3cd 100644 --- a/src/model/model-catalog.ts +++ b/src/model/model-catalog.ts @@ -3,7 +3,7 @@ import { modelSupportedByHarness, resolveModel, SELECTABLE_BASE_MODELS } from ". export interface ModelCatalogEntry { id: string; name: string; - provider: "anthropic" | "openai" | "openrouter"; + provider: "anthropic" | "openai" | "openrouter" | "nexforce"; } const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models?supported_parameters=tools&sort=most-popular"; @@ -24,7 +24,7 @@ const cache = new WeakMap(); export function builtInModelCatalog(): ModelCatalogEntry[] { return SELECTABLE_BASE_MODELS.flatMap((model) => { const provider = resolveModel(model.id)?.provider; - return provider === "anthropic" || provider === "openai" || provider === "openrouter" + return provider === "anthropic" || provider === "openai" || provider === "openrouter" || provider === "nexforce" ? [{ ...model, provider }] : []; }); diff --git a/src/model/model-credential-store.ts b/src/model/model-credential-store.ts index 5ec2cfb50..85589cf9f 100644 --- a/src/model/model-credential-store.ts +++ b/src/model/model-credential-store.ts @@ -101,6 +101,7 @@ export function createModelCredentialStore(input: { anthropic: statuses.find((status) => status.provider === "anthropic")!.configured, openai: statuses.find((status) => status.provider === "openai")!.configured, openrouter: statuses.find((status) => status.provider === "openrouter")!.configured, + nexforce: statuses.find((status) => status.provider === "nexforce")!.configured, }; }, }; diff --git a/src/model/pi-models.ts b/src/model/pi-models.ts index a57462ef8..0f7936bcd 100644 --- a/src/model/pi-models.ts +++ b/src/model/pi-models.ts @@ -9,7 +9,7 @@ export const THINKING_LEVELS = ["auto", "low", "medium", "high", "xhigh", "max", export const HARNESS_IDS = ["pi", "opencode", "codex", "claude", "mock"] as const; export type HarnessId = (typeof HARNESS_IDS)[number]; -export const MODEL_PROVIDERS = ["anthropic", "openai", "openrouter"] as const; +export const MODEL_PROVIDERS = ["anthropic", "openai", "openrouter", "nexforce"] as const; export type ModelProvider = (typeof MODEL_PROVIDERS)[number]; export function isModelProvider(value: unknown): value is ModelProvider { @@ -193,6 +193,7 @@ export interface ModelProviderAvailability { anthropic: boolean; openai: boolean; openrouter: boolean; + nexforce: boolean; } export function modelServiceable(id: string, providers: ModelProviderAvailability): boolean { @@ -201,6 +202,7 @@ export function modelServiceable(id: string, providers: ModelProviderAvailabilit if (provider === "openai") return providers.openai; if (provider === "anthropic") return providers.anthropic; if (provider === "openrouter") return providers.openrouter; + if (provider === "nexforce") return providers.nexforce; return true; } @@ -208,7 +210,12 @@ export function serviceableModelIds(ids: readonly string[], providers: ModelProv return ids.filter((id) => modelServiceable(id, providers)); } -export const ALL_PROVIDERS_AVAILABLE: ModelProviderAvailability = { anthropic: true, openai: true, openrouter: true }; +export const ALL_PROVIDERS_AVAILABLE: ModelProviderAvailability = { + anthropic: true, + openai: true, + openrouter: true, + nexforce: true, +}; export function modelProviderAvailabilityFor( harness: string, @@ -216,13 +223,13 @@ export function modelProviderAvailabilityFor( managedKeys: ModelProviderAvailability = configKeys, ): ModelProviderAvailability { if (harness === "pi") return managedKeys; - if (harness === "opencode") return { ...configKeys, openrouter: false }; + if (harness === "opencode") return { ...configKeys, openrouter: false, nexforce: false }; if (harness === "codex") return configKeys; return ALL_PROVIDERS_AVAILABLE; } export function onlyProvider(provider: ModelProvider): ModelProviderAvailability { - return { anthropic: false, openai: false, openrouter: false, [provider]: true }; + return { anthropic: false, openai: false, openrouter: false, nexforce: false, [provider]: true }; } export function defaultModelForProvider(harness: string, provider: ModelProvider): string | undefined { diff --git a/src/wiring.ts b/src/wiring.ts index f2a93aded..6ed6b4619 100644 --- a/src/wiring.ts +++ b/src/wiring.ts @@ -401,6 +401,7 @@ export function buildApp( ...(config.anthropicApiKey ? { anthropic: config.anthropicApiKey } : {}), ...(config.openaiApiKey ? { openai: config.openaiApiKey } : {}), ...(config.openrouterApiKey ? { openrouter: config.openrouterApiKey } : {}), + ...(config.nexforceApiKey ? { nexforce: config.nexforceApiKey } : {}), }, }); const identity = createIdentityService(artifactMap("deactivated_principals")); diff --git a/test/admin-observability.test.ts b/test/admin-observability.test.ts index 529a498b0..4dc64f684 100644 --- a/test/admin-observability.test.ts +++ b/test/admin-observability.test.ts @@ -1271,7 +1271,7 @@ test("an OpenAI-only deployment still gets a browse model picker, and Anthropic config: built.config, auditLog: built.auditLog, baseModelDefault: "gpt-5.6-sol", - providerKeys: { anthropic: false, openai: true, openrouter: false }, + providerKeys: { anthropic: false, openai: true, openrouter: false, nexforce: false }, }); server.listen(0); const base = `http://localhost:${(server.address() as AddressInfo).port}`; diff --git a/test/config.test.ts b/test/config.test.ts index ee5ba307f..c4fb3b43e 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -395,12 +395,17 @@ test("MODEL_PROVIDER is refused when the harness can never run that vendor's mod "openai", "the one combination Codex can bill is accepted", ); + assert.throws( + () => loadConfig({ MODEL_PROVIDER: "nexforce", HARNESS: "pi", NEXFORCE_API_KEY: "k" }), + /cannot serve a base model on HARNESS=pi/, + "nexforce has no servable base model yet, so a real harness refuses it", + ); }); test("baseModelProviders constrains the base model only when a provider is declared", () => { assert.deepEqual( baseModelProviders(loadConfig({ MODEL_PROVIDER: "openrouter", OPENROUTER_API_KEY: "k", ANTHROPIC_API_KEY: "k" })), - { anthropic: false, openai: false, openrouter: true }, + { anthropic: false, openai: false, openrouter: true, nexforce: false }, "the declaration outranks a stray key from another vendor", ); assert.equal( diff --git a/test/model-credential-route.test.ts b/test/model-credential-route.test.ts index e127025ab..44038d5bb 100644 --- a/test/model-credential-route.test.ts +++ b/test/model-credential-route.test.ts @@ -38,6 +38,7 @@ function start( anthropic: Boolean(config.anthropicApiKey), openai: Boolean(config.openaiApiKey), openrouter: Boolean(config.openrouterApiKey), + nexforce: Boolean(config.nexforceApiKey), }, admin: built.admin, auditLog: built.auditLog, @@ -60,6 +61,7 @@ test("admin model credentials are encrypted, write-only, live, and removable", a { provider: "anthropic", configured: true, source: "environment" }, { provider: "openai", configured: false, source: "absent" }, { provider: "openrouter", configured: false, source: "absent" }, + { provider: "nexforce", configured: false, source: "absent" }, ], models: [ { id: "claude-fable-5", name: "Claude Fable 5", provider: "anthropic" }, @@ -135,6 +137,32 @@ test("OpenRouter validation uses an authenticated endpoint", async () => { } }); +test("Nexforce validation probes a chat completion, not the public catalog", async () => { + let requested = ""; + let method = ""; + let body = ""; + const srv = start({}, async (input, init) => { + requested = String(input); + method = init?.method ?? ""; + body = typeof init?.body === "string" ? init.body : ""; + return new Response(null, { status: 401 }); + }); + try { + const response = await fetch(`${srv.base}/v1/admin/model-providers/nexforce`, { + method: "PUT", + headers: ADMIN, + body: JSON.stringify({ apiKey: "not-a-real-key" }), + }); + assert.equal(response.status, 400); + assert.equal(requested, "https://router.nexforce.ai/v1/chat/completions"); + assert.equal(method, "POST"); + assert.match(body, /"model":"deepseek\/deepseek-v4-flash"/); + assert.equal(await srv.built.modelCredentials.resolve("nexforce"), null); + } finally { + await srv.close(); + } +}); + test("OpenRouter catalog exposes runtime-supported tool models as selectable base models", async () => { let requested = ""; let catalogRequests = 0; diff --git a/test/model-registry.test.ts b/test/model-registry.test.ts index 9b389acfa..4c00c228b 100644 --- a/test/model-registry.test.ts +++ b/test/model-registry.test.ts @@ -55,45 +55,68 @@ test("FAST_MODE_MODEL_IDS derives from the registry — the web-ui client reads }); test("exposure is provider-key-aware: a model whose provider is unconfigured is not serviceable", () => { - const noOpenai = { anthropic: true, openai: false, openrouter: false }; + const noOpenai = { anthropic: true, openai: false, openrouter: false, nexforce: false }; assert.equal(modelServiceable("gpt-5.6-sol", noOpenai), false); assert.equal(modelServiceable("claude-opus-4-8", noOpenai), true); assert.deepEqual(serviceableModelIds(["claude-opus-4-8", "gpt-5.6-sol"], noOpenai), ["claude-opus-4-8"]); }); test("provider-key gating applies only to key-authed harnesses (no over-hiding on CLI-auth harnesses)", () => { - const noKeys = { anthropic: false, openai: false, openrouter: false }; + const noKeys = { anthropic: false, openai: false, openrouter: false, nexforce: false }; assert.deepEqual(modelProviderAvailabilityFor("pi", noKeys), noKeys); assert.deepEqual(modelProviderAvailabilityFor("opencode", noKeys), noKeys); - assert.deepEqual(modelProviderAvailabilityFor("pi", noKeys, { anthropic: false, openai: true, openrouter: true }), { - anthropic: false, - openai: true, - openrouter: true, - }); + assert.deepEqual( + modelProviderAvailabilityFor("pi", noKeys, { + anthropic: false, + openai: true, + openrouter: true, + nexforce: true, + }), + { anthropic: false, openai: true, openrouter: true, nexforce: true }, + ); assert.deepEqual( modelProviderAvailabilityFor( "opencode", - { anthropic: true, openai: true, openrouter: true }, - { anthropic: false, openai: false, openrouter: false }, + { anthropic: true, openai: true, openrouter: true, nexforce: true }, + { anthropic: false, openai: false, openrouter: false, nexforce: false }, ), - { anthropic: true, openai: true, openrouter: false }, + { anthropic: true, openai: true, openrouter: false, nexforce: false }, ); assert.deepEqual(modelProviderAvailabilityFor("codex", noKeys), noKeys); - assert.deepEqual(modelProviderAvailabilityFor("codex", { anthropic: false, openai: true, openrouter: false }), { - anthropic: false, + assert.deepEqual( + modelProviderAvailabilityFor("codex", { + anthropic: false, + openai: true, + openrouter: false, + nexforce: false, + }), + { anthropic: false, openai: true, openrouter: false, nexforce: false }, + ); + assert.deepEqual(modelProviderAvailabilityFor("claude", noKeys), { + anthropic: true, openai: true, - openrouter: false, + openrouter: true, + nexforce: true, + }); + assert.deepEqual(modelProviderAvailabilityFor("mock", noKeys), { + anthropic: true, + openai: true, + openrouter: true, + nexforce: true, }); - assert.deepEqual(modelProviderAvailabilityFor("claude", noKeys), { anthropic: true, openai: true, openrouter: true }); - assert.deepEqual(modelProviderAvailabilityFor("mock", noKeys), { anthropic: true, openai: true, openrouter: true }); }); test("web-turn gate refuses a keyless model cleanly, accepts it once the provider is configured", () => { - const noOpenai = { anthropic: true, openai: false, openrouter: false }; + const noOpenai = { anthropic: true, openai: false, openrouter: false, nexforce: false }; const refused = validateWebTurnModelOptions({ model: "gpt-5.6-sol" }, null, noOpenai); assert.match(refused ?? "", /provider isn't configured/); assert.equal( - validateWebTurnModelOptions({ model: "gpt-5.6-sol" }, null, { anthropic: true, openai: true, openrouter: false }), + validateWebTurnModelOptions({ model: "gpt-5.6-sol" }, null, { + anthropic: true, + openai: true, + openrouter: false, + nexforce: false, + }), null, ); }); diff --git a/test/pi-models.test.ts b/test/pi-models.test.ts index 5b03e44bc..4cf4f0672 100644 --- a/test/pi-models.test.ts +++ b/test/pi-models.test.ts @@ -50,6 +50,7 @@ test("native harnesses reject cross-provider pins and choose their own defaults" test("the default base model follows the providers a deployment can actually bill", () => { for (const provider of MODEL_PROVIDERS) { + if (provider === "nexforce") continue; const only = onlyProvider(provider); const chosen = defaultModelForHarness("pi", undefined, only); assert.equal( @@ -60,6 +61,11 @@ test("the default base model follows the providers a deployment can actually bil } assert.equal(defaultModelForHarness("pi", undefined, onlyProvider("openrouter")), "openrouter/auto"); assert.equal(defaultModelForHarness("pi", undefined, onlyProvider("openai")), "gpt-5.6-sol"); + assert.equal( + defaultModelForHarness("pi", undefined, onlyProvider("nexforce")), + "claude-opus-5", + "a nexforce-only deployment has no servable base model yet, so the shipped default stands", + ); }); test("provider-blind callers and explicit pins keep the shipped default", () => { @@ -71,7 +77,7 @@ test("provider-blind callers and explicit pins keep the shipped default", () => "an explicit pin is never silently swapped — the mismatch is rejected at config load instead", ); assert.equal( - defaultModelForHarness("pi", undefined, { anthropic: false, openai: false, openrouter: false }), + defaultModelForHarness("pi", undefined, { anthropic: false, openai: false, openrouter: false, nexforce: false }), "claude-opus-5", "with no provider at all the shipped default stands rather than an arbitrary pick", ); @@ -141,10 +147,10 @@ test("auxiliary selection falls back to the base model when its provider has no test("an auxiliary is never less serviceable than the base model it was derived from", () => { const providerSets = [ - { anthropic: true, openai: false, openrouter: false }, - { anthropic: false, openai: true, openrouter: false }, - { anthropic: false, openai: false, openrouter: true }, - { anthropic: false, openai: false, openrouter: false }, + { anthropic: true, openai: false, openrouter: false, nexforce: false }, + { anthropic: false, openai: true, openrouter: false, nexforce: false }, + { anthropic: false, openai: false, openrouter: true, nexforce: false }, + { anthropic: false, openai: false, openrouter: false, nexforce: false }, ]; for (const m of SELECTABLE_BASE_MODELS) { const auxiliary = auxiliaryModelFor(m.id); diff --git a/test/secret-schema-drift.test.ts b/test/secret-schema-drift.test.ts index cb71f8899..69a0a5f38 100644 --- a/test/secret-schema-drift.test.ts +++ b/test/secret-schema-drift.test.ts @@ -46,6 +46,7 @@ test("a declared base model provider is enforced at boot, not just at deploy tim ["anthropic", "ANTHROPIC_API_KEY"], ["openai", "OPENAI_API_KEY"], ["openrouter", "OPENROUTER_API_KEY"], + ["nexforce", "NEXFORCE_API_KEY"], ] as const) { assert.deepEqual(validateCoreSecretEnv({ MODEL_PROVIDER: provider } as NodeJS.ProcessEnv), [key]); assert.deepEqual(validateCoreSecretEnv({ MODEL_PROVIDER: provider, [key]: "real-key" } as NodeJS.ProcessEnv), []); diff --git a/test/webui-model-allowlist.test.ts b/test/webui-model-allowlist.test.ts index 3632557ec..6c25ef6ea 100644 --- a/test/webui-model-allowlist.test.ts +++ b/test/webui-model-allowlist.test.ts @@ -32,7 +32,7 @@ test("the org allowed-models list restricts the runtime-config picker and cleari modelCredentials: built.modelCredentials, modelCredentialFetch, harnessId: "pi", - providerKeys: { anthropic: false, openai: false, openrouter: true }, + providerKeys: { anthropic: false, openai: false, openrouter: true, nexforce: false }, admin: built.admin, auditLog: built.auditLog, });