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, });