Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions cli/src/backends/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,22 +218,38 @@ async function baseModelCheck(config: QmConfig, secrets: Map<string, string>): 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<ModelProvider, { url: string; headers: (key: string) => Record<string, string> }>
Record<ModelProvider, { url: string; headers: (key: string) => Record<string, string>; body?: string }>
> = {
anthropic: {
url: "https://api.anthropic.com/v1/models?limit=1",
headers: (key) => ({ "x-api-key": key, "anthropic-version": "2023-06-01" }),
},
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<void> {
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`,
Expand Down
6 changes: 6 additions & 0 deletions cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
readConfigOrgId,
EMAIL_TRANSPORTS,
MODEL_PROVIDERS,
MODEL_PROVIDER_HARNESSES,
type EmailTransport,
type ModelProvider,
} from "./config.ts";
Expand Down Expand Up @@ -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;
}

Expand Down
6 changes: 6 additions & 0 deletions cli/src/commands/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ const PLAYBOOKS: Readonly<Record<string, readonly string[]>> = {
"(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.",
Expand Down Expand Up @@ -117,6 +122,7 @@ const FORMAT_HINTS: Readonly<Record<string, { prefix: string; label: string }>>
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-" },
};
Expand Down
9 changes: 7 additions & 2 deletions cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<ModelProvider, string>> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
openrouter: "OPENROUTER_API_KEY",
nexforce: "NEXFORCE_API_KEY",
};

export const MODEL_PROVIDER_HARNESSES: Readonly<Record<ModelProvider, readonly string[]>> = {
anthropic: ["pi", "opencode", "claude", "mock"],
openai: ["pi", "opencode", "codex", "mock"],
openrouter: ["pi", "mock"],
nexforce: [],
};

export const isModelProvider = (value: unknown): value is ModelProvider =>
Expand Down Expand Up @@ -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.`,
);
}
}
Expand Down
7 changes: 7 additions & 0 deletions cli/src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 10 additions & 1 deletion cli/templates/deployment/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions cli/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions cli/test/secrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
1 change: 1 addition & 0 deletions plugins/admin/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6920,6 +6920,7 @@ <h2 id="governance-review-title">Confirm governance change</h2>
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]) =>
Expand Down
2 changes: 1 addition & 1 deletion src/api/app-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
45 changes: 30 additions & 15 deletions src/api/routes/admin/model-providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModelProvider, { url: string; headers: (apiKey: string) => Record<string, 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}` }),
},
};
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<string, string>; 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);
Expand All @@ -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;
Expand Down
5 changes: 4 additions & 1 deletion src/api/routes/admin/scope-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,10 @@ export async function getScopeConfig(ctx: ApiCtx): Promise<void> {
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) => {
Expand Down
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export interface Config {
anthropicApiKey?: string;
openaiApiKey?: string;
openrouterApiKey?: string;
nexforceApiKey?: string;
modelProvider?: ModelProvider;
piCaptureRequests: boolean;
piSystemCacheSplit: boolean;
Expand Down Expand Up @@ -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),
};
}

Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/deployment/secret-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ type SecretGate =
| "linear-oauth"
| "model-anthropic"
| "model-openai"
| "model-openrouter";
| "model-openrouter"
| "model-nexforce";

export interface RuntimeSecretSpec {
name: string;
Expand All @@ -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" },
Expand All @@ -53,6 +55,7 @@ const GATE_PREDICATES: Readonly<Record<SecretGate, (env: NodeJS.ProcessEnv) => 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[] {
Expand Down
4 changes: 2 additions & 2 deletions src/model/model-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -24,7 +24,7 @@ const cache = new WeakMap<typeof fetch, CacheEntry>();
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 }]
: [];
});
Expand Down
1 change: 1 addition & 0 deletions src/model/model-credential-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
},
};
Expand Down
15 changes: 11 additions & 4 deletions src/model/pi-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -193,6 +193,7 @@ export interface ModelProviderAvailability {
anthropic: boolean;
openai: boolean;
openrouter: boolean;
nexforce: boolean;
}

export function modelServiceable(id: string, providers: ModelProviderAvailability): boolean {
Expand All @@ -201,28 +202,34 @@ 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;
}

export function serviceableModelIds(ids: readonly string[], providers: ModelProviderAvailability): string[] {
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,
configKeys: ModelProviderAvailability,
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 {
Expand Down
Loading