Skip to content
Merged
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
15 changes: 8 additions & 7 deletions cli/src/backends/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { existsSync, readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { MODEL_PROVIDER_KEYS, validatePortalTrust, type ModelProvider, type QmConfig } from "../config.ts";
import {
MODEL_PROVIDER_BASE_MODELS,
MODEL_PROVIDER_KEYS,
validatePortalTrust,
type ModelProvider,
type QmConfig,
} from "../config.ts";
import { CliError, errMessage, step, warn } from "../log.ts";
import { capture, deploymentSecretValue, flyBin, isInvalidSecret, readEnvFile, which } from "../util.ts";
import { computedSecrets } from "../secrets.ts";
Expand Down Expand Up @@ -192,11 +198,6 @@ export async function doctorCommon(
await baseModelCheck(config, secrets);
}

/**
* Prove the base-model key is accepted before the deployment is called finished. The Admin
* page validates a key on entry; a deployment that ships its key from `.env` gets no such
* feedback, and an unusable key would otherwise surface as a failed first chat message.
*/
async function baseModelCheck(config: QmConfig, secrets: Map<string, string>): Promise<void> {
const provider = config.modelProvider;
if (!provider) {
Expand All @@ -210,7 +211,7 @@ async function baseModelCheck(config: QmConfig, secrets: Map<string, string>): P
return;
}
await modelProviderCheck(provider, key);
step(`base model provider ${provider}: ${name} accepted`);
step(`base model provider ${provider}: ${name} accepted, serving ${MODEL_PROVIDER_BASE_MODELS[provider]}`);
}

const MODEL_PROVIDER_PROBES: Readonly<
Expand Down
41 changes: 34 additions & 7 deletions cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,22 +104,27 @@ export function awsWorkloadArchitecture(config: QmConfig, workload: string): "ar
return service.architecture ?? "arm64";
}

/**
* The vendor supplying the deployment's base model. Selecting one makes that vendor's
* API key a required deployment secret, so `qm setup` collects it and `qm up` refuses
* to deploy a stack that cannot run a single agent turn. Leaving it unset preserves the
* older flow, where an administrator supplies the key from the Admin page after deploy.
*/
export const MODEL_PROVIDERS = ["anthropic", "openai", "openrouter"] as const;
export type ModelProvider = (typeof MODEL_PROVIDERS)[number];

/** The API key each provider's base model is billed against. */
export const MODEL_PROVIDER_KEYS: Readonly<Record<ModelProvider, string>> = {
anthropic: "ANTHROPIC_API_KEY",
openai: "OPENAI_API_KEY",
openrouter: "OPENROUTER_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"],
};

export const MODEL_PROVIDER_BASE_MODELS: Readonly<Record<ModelProvider, string>> = {
anthropic: "claude-opus-5",
openai: "gpt-5.6-sol",
openrouter: "openrouter/auto",
};

export const isModelProvider = (value: unknown): value is ModelProvider =>
typeof value === "string" && (MODEL_PROVIDERS as readonly string[]).includes(value);

Expand Down Expand Up @@ -674,6 +679,7 @@ function validate(raw: unknown, path: string): QmConfig {
out.aws = validateAws(o["aws"], path, runnableServices(services), configuredSecretNames);
}
if (target === "aws" && !out.aws) throw new CliError(`${path}: target "aws" requires an "aws" block`);
validateModelProvider(out, path);
validatePortalTrust(out, path);
if (target === "aws") {
validateAwsFrontDoor(out, path);
Expand Down Expand Up @@ -704,6 +710,27 @@ function validate(raw: unknown, path: string): QmConfig {
return out;
}

function configuredHarness(config: QmConfig): string {
return config.env.core?.HARNESS?.trim() || (config.target === "fly" ? "pi" : "mock");
}

function validateModelProvider(config: QmConfig, path: string): void {
const override = config.env.core?.MODEL_PROVIDER?.trim();
if (override !== undefined && override !== "" && !isModelProvider(override)) {
throw new CliError(
`${path}: env.core.MODEL_PROVIDER must be one of ${MODEL_PROVIDERS.join(", ")}, or unset to use "modelProvider"`,
);
}
const provider = isModelProvider(override) ? override : config.modelProvider;
if (!provider) return;
const harness = configuredHarness(config);
if (!MODEL_PROVIDER_HARNESSES[provider].includes(harness)) {
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.`,
);
}
}

const validEmailDomain = (value: string): boolean => {
if (value.length > 253 || !value.includes(".")) return false;
return value
Expand Down
12 changes: 7 additions & 5 deletions cli/src/provider-scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,16 @@ function renderConfig(orgId: string, values: ConfigValues): string {

// The vendor supplying the base model: "anthropic", "openai", or "openrouter"
// (one key, many models). Naming one makes that vendor's API key a required
// deployment secret: \`qm setup\` collects it, \`qm doctor\` proves the provider
// accepts it, and \`qm up\` refuses a stack that cannot serve an agent turn.
// Delete this line to leave the base model unset and have an administrator add
// the key from the Admin page after deploy instead.
// deployment secret and points the base model at that vendor: \`qm setup\`
// collects the key, \`qm doctor\` proves the provider accepts it, and \`qm up\`
// refuses a stack that cannot serve an agent turn. Delete this line to leave
// the base model unset and have an administrator add the key from the Admin
// page after deploy instead.
"modelProvider": ${JSON.stringify(values.modelProvider)},

// Optional base model id, passed to the harness (e.g. "claude-opus-4-6").
// Omit it to use the harness default for the provider above.
// Omit it and the deployment uses the default model for the provider above.
// It must be a model that provider can bill.
// "model": "",
${values.providerFields}
// First-party services to run. The full set: "core" (the agent runtime and API,
Expand Down
82 changes: 33 additions & 49 deletions cli/src/secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ type SecretCondition =
| { kind: "service-enabled"; service: DeclaredServiceName }
| { kind: "service-absent"; service: DeclaredServiceName }
| { kind: "all"; conditions: SecretCondition[] }
| { kind: "any"; conditions: SecretCondition[] }
| { kind: "target"; target: QmConfig["target"] }
| { kind: "model-provider"; provider: ModelProvider };

export interface SecretSpec {
name: string;
service: DeclaredServiceName;
envName?: string;
required: boolean | { when: SecretCondition };
required: boolean | { when: SecretCondition; optionalOtherwise?: true };
description: string;
generate?: string;
managedBy?: "operator" | "terraform";
Expand All @@ -38,52 +39,34 @@ export const MINT_JWK =
"node -e \"const {generateKeyPairSync}=require('node:crypto');process.stdout.write(JSON.stringify(generateKeyPairSync('ec',{namedCurve:'P-256'}).privateKey.export({format:'jwk'})))\"";

export const FIRST_PARTY_SECRET_SPECS: readonly SecretSpec[] = [
// The base-model key is required whenever the deployment names its provider, so that
// `qm setup` collects it and `qm up` cannot produce a stack that fails its first agent
// turn. Anthropic and OpenRouter keep an unconditional twin further down: omitting
// `modelProvider` leaves the key an optional fallback an administrator supplies from the
// Admin page, which is how deployments predating `modelProvider` behave. Those two stay
// ahead of their twins — `computedSecrets` describes a secret from the first spec whose
// condition matches, and the base-model wording is the accurate one once it applies.
{
name: "ANTHROPIC_API_KEY",
service: "core",
required: { when: { kind: "model-provider", provider: "anthropic" } },
description: 'Anthropic API key for the deployment base model (modelProvider "anthropic").',
required: { when: { kind: "model-provider", provider: "anthropic" }, optionalOtherwise: true },
description:
'Anthropic API key: bills the base model when modelProvider is "anthropic", an optional deployment fallback otherwise.',
},
{
name: "OPENROUTER_API_KEY",
service: "core",
required: { when: { kind: "model-provider", provider: "openrouter" } },
description: 'OpenRouter API key for the deployment base model (modelProvider "openrouter").',
},
{
name: "ANTHROPIC_API_KEY",
service: "core",
required: false,
description: "Optional deployment fallback for Pi; admins can configure the base model key after deploy.",
},
// OPENAI_API_KEY keeps the Codex rule ahead of the base-model rule: it is the more
// descriptive of the two, and `.env.example` documents a dormant secret from its first
// spec. Whichever rule fires, `computedSecrets` collapses them to one required secret.
{
name: "OPENAI_API_KEY",
service: "core",
required: { when: { kind: "env-equals", service: "core", name: "HARNESS", value: "codex" } },
required: { when: { kind: "model-provider", provider: "openrouter" }, optionalOtherwise: true },
description:
"OpenAI API key used by the Codex harness (its CLI cannot do browser OAuth in a container); an optional deployment fallback for Pi otherwise.",
'OpenRouter API key: bills the base model when modelProvider is "openrouter", an optional deployment fallback otherwise.',
},
{
name: "OPENAI_API_KEY",
service: "core",
required: { when: { kind: "model-provider", provider: "openai" } },
description: 'OpenAI API key for the deployment base model (modelProvider "openai").',
},
{
name: "OPENROUTER_API_KEY",
service: "core",
required: false,
description: "Optional deployment fallback for Pi; admins can configure the base model key after deploy.",
required: {
when: {
kind: "any",
conditions: [
{ kind: "env-equals", service: "core", name: "HARNESS", value: "codex" },
{ kind: "model-provider", provider: "openai" },
],
},
},
description:
'OpenAI API key: the Codex harness needs it (its CLI cannot do browser OAuth in a container), and it bills the base model when modelProvider is "openai".',
},
{
name: "PUBLIC_API_URL",
Expand Down Expand Up @@ -387,6 +370,7 @@ function conditionMatches(config: QmConfig, condition: SecretCondition): boolean
if (condition.kind === "service-enabled") return config.services.includes(condition.service);
if (condition.kind === "service-absent") return !config.services.includes(condition.service);
if (condition.kind === "all") return condition.conditions.every((nested) => conditionMatches(config, nested));
if (condition.kind === "any") return condition.conditions.some((nested) => conditionMatches(config, nested));
if (condition.kind === "target") return config.target === condition.target;
if (condition.kind === "model-provider") return config.modelProvider === condition.provider;
if (condition.kind === "env-all-absent") {
Expand Down Expand Up @@ -418,12 +402,18 @@ function targetEnvDefault(config: QmConfig, service: string, name: string): stri
return rendered;
}

function requirementFor(config: QmConfig, spec: SecretSpec): boolean | null {
if (typeof spec.required === "boolean") return spec.required;
if (conditionMatches(config, spec.required.when)) return true;
return spec.required.optionalOtherwise ? false : null;
}

export function computedSecrets(config: QmConfig): ComputedSecret[] {
const byName = new Map<string, ComputedSecret>();
for (const spec of FIRST_PARTY_SECRET_SPECS) {
if (!config.services.includes(spec.service)) continue;
if (typeof spec.required !== "boolean" && !conditionMatches(config, spec.required.when)) continue;
const required = spec.required !== false;
const required = requirementFor(config, spec);
if (required === null) continue;
const current = byName.get(spec.name);
if (current) {
if (spec.envName) {
Expand Down Expand Up @@ -558,6 +548,7 @@ function conditionClause(condition: SecretCondition): string {
if (condition.kind === "service-enabled") return `the ${condition.service} service is enabled`;
if (condition.kind === "service-absent") return `the ${condition.service} service is not enabled`;
if (condition.kind === "all") return condition.conditions.map(conditionClause).join(" and ");
if (condition.kind === "any") return condition.conditions.map(conditionClause).join(" or ");
if (condition.kind === "target") return `the target is ${condition.target}`;
if (condition.kind === "env-all-absent")
return `none of env.${condition.service}.{${condition.names.join(", ")}} are set`;
Expand Down Expand Up @@ -594,22 +585,15 @@ export function renderEnvExample(config: QmConfig): string {
}
const activeNames = new Set(active.map((secret) => secret.name));
const inactive = FIRST_PARTY_SECRET_SPECS.filter(
(spec, i, all) => !activeNames.has(spec.name) && all.findIndex((s) => s.name === spec.name) === i,
(spec, i, all) => !activeNames.has(spec.name) && all.findIndex((other) => other.name === spec.name) === i,
);
const clauseFor = (spec: SecretSpec): string =>
[
for (const spec of inactive) {
const clauses = [
...(config.services.includes(spec.service) ? [] : [`the ${spec.service} service is enabled`]),
...(typeof spec.required === "boolean" ? [] : [conditionClause(spec.required.when)]),
].join(" and ");
for (const spec of inactive) {
// One secret can answer to several independent rules — an OpenAI base model or the
// Codex harness, say. List them as alternatives so the catalog does not imply the
// first rule is the only way the secret becomes required.
const rules = FIRST_PARTY_SECRET_SPECS.filter((other) => other.name === spec.name);
const clauses = [...new Set(rules.map(clauseFor).filter(Boolean))];
const optionalEvenThen = rules.every((rule) => rule.required === false);
];
lines.push(`# ${spec.description} (${spec.service})`);
lines.push(`# Needed when ${clauses.join(" or ")}${optionalEvenThen ? " (optional even then)" : ""}.`);
lines.push(`# Needed when ${clauses.join(" and ")}${spec.required === false ? " (optional even then)" : ""}.`);
if (spec.generate) lines.push(`# Generate with: ${generate(spec.generate)}`);
lines.push(spec.managedBy === "terraform" ? `# ${spec.name}= # populated by Terraform` : `# ${spec.name}=`);
lines.push("");
Expand Down
22 changes: 16 additions & 6 deletions cli/templates/deployment/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,14 @@ has none. Treat a rejected key exactly like a rejected sign-in credential: stop
and get a working one rather than deploying a stack that greets the
administrator and then fails their first message.

`modelProvider` also picks the model itself, so no model id has to be chosen at
deploy time: Anthropic serves `claude-opus-5`, OpenAI `gpt-5.6-sol`, OpenRouter
`openrouter/auto`. Set `model` in `qm.config.jsonc` only to override that, and
only with a model the chosen provider can bill — a mismatch is refused at
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.

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 All @@ -178,12 +186,14 @@ npm exec qm -- outputs --json
```

Open `adminOnboardingUrl` from the JSON output and confirm Model provider
already reports the deployment's base model. It does when `modelProvider` is
set: the key travelled with the rest of the deployment secrets, so there is
nothing to paste here. Enter and validate a key on that page only when the
operator chose to defer, or when they are replacing the deployment key with one
they would rather rotate from Admin — the write-only surface stores it in
durable encrypted storage and takes precedence over the deployment key.
reports the chosen vendor as configured, sourced from the environment. It does
when `modelProvider` is set: the key travelled with the rest of the deployment
secrets, so there is nothing to paste here. Enter and validate a key on that
page only when the operator chose to defer, or when they are replacing the
deployment key with one they would rather rotate from Admin — the write-only
surface stores it in durable encrypted storage and takes precedence over the
deployment key. On the deferred route, set Base model on that same page after
the key: a key alone leaves the deployment on a model it cannot bill.

Never paste any provider key into chat or terminal output. `.env` is the one
place a deployment key belongs, and `qm secrets push` moves it without printing
Expand Down
43 changes: 43 additions & 0 deletions cli/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1135,3 +1135,46 @@ test("aws.services logGroup and stopTimeout adopt live task-def values and valid
);
}
});

test("modelProvider must name a vendor the configured harness can bill", () => {
withConfig({ modelProvider: "openrouter", env: { core: { HARNESS: "pi" } } }, ({ path }) => {
assert.equal(loadConfigAt(path).config.modelProvider, "openrouter");
});
withConfig({ modelProvider: "openrouter", env: { core: { HARNESS: "codex" } } }, ({ path }) => {
assert.throws(
() => loadConfigAt(path),
/model provider "openrouter" cannot serve a base model on env.core.HARNESS "codex"/,
);
});
withConfig({ modelProvider: "anthropic", env: { core: { HARNESS: "codex" } } }, ({ path }) => {
assert.throws(() => loadConfigAt(path), /cannot serve a base model/);
});
withConfig({ modelProvider: "openai", env: { core: { HARNESS: "codex" } } }, ({ path }) => {
assert.equal(loadConfigAt(path).config.modelProvider, "openai");
});
withConfig({ modelProvider: "openrouter" }, ({ path }) => {
assert.equal(
loadConfigAt(path).config.modelProvider,
"openrouter",
"an unset harness is mock, which bills anything",
);
});
});

test("env.core.MODEL_PROVIDER is validated as the provider core will actually use", () => {
withConfig(
{ modelProvider: "openai", env: { core: { HARNESS: "codex", MODEL_PROVIDER: "anthropic" } } },
({ path }) => {
assert.throws(() => loadConfigAt(path), /model provider "anthropic" cannot serve a base model/);
},
);
withConfig(
{ modelProvider: "anthropic", env: { core: { HARNESS: "codex", MODEL_PROVIDER: "openai" } } },
({ path }) => {
assert.equal(loadConfigAt(path).config.modelProvider, "anthropic", "the override decides, the declaration stays");
},
);
withConfig({ env: { core: { HARNESS: "pi", MODEL_PROVIDER: "bedrock" } } }, ({ path }) => {
assert.throws(() => loadConfigAt(path), /env.core.MODEL_PROVIDER must be one of/);
});
});
14 changes: 13 additions & 1 deletion cli/test/secrets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
secretsForService,
type ComputedSecret,
} from "../src/secrets.ts";
import { isReservedContainerName, pluginNameError } from "../src/services.ts";
import { isReservedContainerName, pluginNameError, SERVICE_NAMES } from "../src/services.ts";

function makeConfig(overrides: Partial<QmConfig> = {}): QmConfig {
return {
Expand Down Expand Up @@ -368,3 +368,15 @@ test("an explicit sandbox.backend wins, and non-fly targets keep their own defau
});
assert.equal(sandboxCoreEnv(docker).env.SANDBOX_BACKEND, undefined);
});

test("the .env.example catalog names every secret exactly once", () => {
for (const services of [["core"], ["core", "portal"], ["core", "portal", "auth"], SERVICE_NAMES] as const) {
const rendered = renderEnvExample(makeConfig({ services: [...services] as QmConfig["services"] }));
const declared = rendered
.split("\n")
.map((line) => /^#?\s*([A-Z0-9_]+)=/.exec(line)?.[1])
.filter((name): name is string => Boolean(name));
const duplicated = declared.filter((name, i) => declared.indexOf(name) !== i);
assert.deepEqual(duplicated, [], `services=${services.join("+")} lists a secret twice`);
}
});
Loading