feat: complete live provider auth attribution

This commit is contained in:
Shakker 2026-05-01 08:18:50 +01:00
parent 8a0bf5d080
commit 97454f3f99
No known key found for this signature in database
10 changed files with 311 additions and 19 deletions

View File

@ -44,6 +44,7 @@ codex skills install https://github.com/shakkernerd/ocm/tree/main/skills/ocm-ope
node bin/kova.mjs version
node bin/kova.mjs setup
node bin/kova.mjs setup --non-interactive --auth env-only --provider openai --env-var OPENAI_API_KEY
node bin/kova.mjs setup --non-interactive --auth env-only --provider openai --env-var OPENAI_API_KEY --fallback-policy external-cli
node bin/kova.mjs setup --ci --json
node bin/kova.mjs self-check
node bin/kova.mjs plan
@ -77,7 +78,9 @@ answers accept either the displayed number or the name, for example `2` or
External CLI auth is strict: Kova verifies the selected CLI binary and local
auth evidence before setup can pass. `openai + external-cli` uses Codex CLI;
`anthropic + external-cli` uses Claude CLI. `custom-openai` should use API-key
or env-only auth.
or env-only auth. External CLI fallback is not automatic; set
`--fallback-policy external-cli` when a live API-key/env-only run may use the
selected local CLI if the live env var is missing.
`run` is dry-run by default. It writes Markdown and JSON reports showing the
planned OpenClaw scenario.
@ -87,7 +90,8 @@ the scenario/state explicitly tests missing or broken auth. `--auth mock` is the
default and uses Kova's deterministic local OpenAI-compatible provider.
`--auth live` requires credentials configured through `kova setup`; live results
are marked environment-dependent and should be compared separately from mock
baselines.
baselines. Kova's live auth setup patches disposable env config as fixture setup
for runtime validation; it is not proof that OpenClaw onboarding/auth UX passed.
`plan --json` is coverage-aware: scenarios map to declared OpenClaw surfaces,
surfaces declare process roles and required metrics, and profile coverage gaps

View File

@ -66,7 +66,11 @@ Interactive `kova setup` asks provider first and then auth method. Both prompts
accept either the displayed number or the name.
For `external-cli`, Kova must verify the selected CLI and auth evidence before
setup passes. `openai + external-cli` uses Codex CLI; `anthropic + external-cli`
uses Claude CLI. Use API-key or env-only auth for `custom-openai`.
uses Claude CLI. External CLI fallback is only valid when setup explicitly
selected `--fallback-policy external-cli`. Use API-key or env-only auth for
`custom-openai`.
Live auth setup patches disposable env config as fixture setup for runtime
validation; do not cite it as proof that OpenClaw onboarding/auth UX passed.
4. Execute one scenario explicitly:

View File

@ -47,6 +47,8 @@ kova.report.v1
"available": false,
"providerId": "openai",
"method": "mock",
"fallbackFrom": null,
"fallbackPolicy": "mock",
"envVars": ["OPENAI_API_KEY"],
"reason": "no live provider configured",
"environmentDependent": false
@ -109,6 +111,9 @@ Important fields:
- `collectorArtifactDirs`: stable per-record artifact directories used by
collectors
- `measurements`: evaluated measurements
- `providerEvidence`: provider request timing, route/model/status summaries,
optional token-like usage totals, and whether evidence came from mock-provider
logs or OpenClaw timeline events
- `violations`: threshold or behavior violations
- `phases`: commands, results, and metrics by phase
- `finalMetrics`: service/process snapshot before cleanup

View File

@ -1,2 +1,2 @@
{"schemaVersion":"kova.mockProvider.request.v1","requestId":"req_models","receivedAt":"2026-04-30T10:00:00.100Z","receivedAtEpochMs":1777543200100,"respondedAt":"2026-04-30T10:00:00.120Z","respondedAtEpochMs":1777543200120,"durationMs":20,"firstByteAt":"2026-04-30T10:00:00.115Z","firstByteAtEpochMs":1777543200115,"firstByteLatencyMs":15,"firstChunkAt":"2026-04-30T10:00:00.115Z","firstChunkAtEpochMs":1777543200115,"firstChunkLatencyMs":15,"method":"GET","route":"/v1/models","path":"/v1/models","model":null,"stream":false,"status":200,"statusClass":"2xx","bodyBytes":0,"parseError":null}
{"schemaVersion":"kova.mockProvider.request.v1","requestId":"req_agent","receivedAt":"2026-04-30T10:00:06.000Z","receivedAtEpochMs":1777543206000,"respondedAt":"2026-04-30T10:00:06.800Z","respondedAtEpochMs":1777543206800,"durationMs":800,"firstByteAt":"2026-04-30T10:00:06.050Z","firstByteAtEpochMs":1777543206050,"firstByteLatencyMs":50,"firstChunkAt":"2026-04-30T10:00:06.050Z","firstChunkAtEpochMs":1777543206050,"firstChunkLatencyMs":50,"method":"POST","route":"/v1/responses","path":"/v1/responses","model":"gpt-5.5","stream":true,"status":200,"statusClass":"2xx","bodyBytes":42,"parseError":null}
{"schemaVersion":"kova.mockProvider.request.v1","requestId":"req_agent","receivedAt":"2026-04-30T10:00:06.000Z","receivedAtEpochMs":1777543206000,"respondedAt":"2026-04-30T10:00:06.800Z","respondedAtEpochMs":1777543206800,"durationMs":800,"firstByteAt":"2026-04-30T10:00:06.050Z","firstByteAtEpochMs":1777543206050,"firstByteLatencyMs":50,"firstChunkAt":"2026-04-30T10:00:06.050Z","firstChunkAtEpochMs":1777543206050,"firstChunkLatencyMs":50,"method":"POST","route":"/v1/responses","path":"/v1/responses","model":"gpt-5.5","stream":true,"status":200,"usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12},"statusClass":"2xx","bodyBytes":42,"parseError":null}

View File

@ -3,11 +3,16 @@ import { constants } from "node:fs";
import { join } from "node:path";
import { credentialsDir, liveEnvPath, providersPath, repoRoot } from "./paths.mjs";
import { quoteShell } from "./commands.mjs";
import { externalCliVerificationSummary, verifyExternalCliAuth } from "./external-cli-auth.mjs";
import {
externalCliVerificationSummary,
resolveExternalCliName,
verifyExternalCliAuth
} from "./external-cli-auth.mjs";
export const authModes = ["mock", "live", "skip"];
export const credentialMethods = ["mock", "api-key", "env-only", "external-cli", "oauth", "skip"];
export const authOverrideModes = ["default", "mock", "live", "skip", "missing", "broken", "none"];
export const fallbackPolicies = ["mock", "external-cli", "none"];
const defaultProviderId = "openai";
const mockApiKey = "kova-mock-key";
@ -35,6 +40,7 @@ export async function configureCredentialProvider(options = {}) {
const metadata = await readProvidersMetadata();
const envVar = options.envVar ?? defaultEnvVarForProvider(providerId);
const fallbackPolicy = normalizeFallbackPolicy(options.fallbackPolicy ?? "mock");
metadata.defaultProvider = providerId;
metadata.providers = {
...(metadata.providers ?? {}),
@ -43,7 +49,7 @@ export async function configureCredentialProvider(options = {}) {
method,
envVars: method === "api-key" || method === "env-only" ? [envVar] : [],
externalCli: method === "external-cli" ? (options.externalCli ?? providerId) : null,
fallbackPolicy: options.fallbackPolicy ?? "mock",
fallbackPolicy,
configuredAt: new Date().toISOString()
}
};
@ -120,7 +126,10 @@ export function scenarioAuthPolicy(context, scenario, state) {
providerId,
source: live.method,
externalCli: live.externalCli ?? null,
fallbackFrom: live.fallbackFrom ?? null,
fallbackPolicy: live.fallbackPolicy ?? null,
setup: true,
setupKind: "fixture-config-patch",
commandEnv: env,
redactionValues: [...(context.auth?.redactionValues ?? []), ...secretValues(env)],
summary: authDisplay({
@ -128,7 +137,10 @@ export function scenarioAuthPolicy(context, scenario, state) {
providerId,
source: live.method,
externalCli: live.externalCli ?? null,
fallbackFrom: live.fallbackFrom ?? null,
fallbackPolicy: live.fallbackPolicy ?? null,
setup: true,
setupKind: "fixture-config-patch",
envVars: live.envVars
})
};
@ -187,9 +199,9 @@ export function buildAuthSetupPhase(authPolicy, envName, artifactDir) {
return {
id: "auth-setup",
title: "Auth Setup",
intent: "Configure the disposable OpenClaw env with the selected live provider auth.",
intent: "Patch the disposable OpenClaw env with fixture live auth config; this proves runtime behavior, not OpenClaw onboarding/auth UX.",
commands: [configureLiveAuthCommand(authPolicy, envName)],
evidence: ["OpenClaw config references live auth env vars", "live auth env vars available to OpenClaw runtime"]
evidence: ["fixture auth config applied", "OpenClaw config references live auth env vars or selected external CLI", "live auth is environment-dependent"]
};
}
@ -214,7 +226,10 @@ export function authDisplay(policy) {
providerId: policy.providerId ?? null,
source: policy.source,
externalCli: policy.externalCli ?? null,
fallbackFrom: policy.fallbackFrom ?? null,
fallbackPolicy: policy.fallbackPolicy ?? null,
setup: policy.setup === true,
setupKind: policy.setupKind ?? null,
deterministic: policy.mode === "mock",
environmentDependent: policy.mode === "live",
envVars: policy.envVars ?? [],
@ -233,6 +248,8 @@ export function authReportSummary(authContext) {
providerId: authContext.live.providerId,
method: authContext.live.method,
externalCli: authContext.live.externalCli ?? null,
fallbackFrom: authContext.live.fallbackFrom ?? null,
fallbackPolicy: authContext.live.fallbackPolicy ?? null,
verification: authContext.live.verification ?? null,
envVars: authContext.live.envVars,
reason: authContext.live.reason,
@ -301,6 +318,9 @@ function validateProvidersMetadata(metadata) {
if (!credentialMethods.includes(provider.method)) {
throw new Error(`providers.${id}.method must be one of ${credentialMethods.join(", ")}`);
}
if (provider.fallbackPolicy !== undefined && !fallbackPolicies.includes(provider.fallbackPolicy)) {
throw new Error(`providers.${id}.fallbackPolicy must be one of ${fallbackPolicies.join(", ")}`);
}
if (provider.envVars !== undefined && !Array.isArray(provider.envVars)) {
throw new Error(`providers.${id}.envVars must be an array`);
}
@ -379,6 +399,7 @@ function liveCredentialStatus(store) {
available: false,
providerId: provider.id,
method: provider.method,
fallbackPolicy: provider.fallbackPolicy ?? "mock",
envVars,
reason: `missing env var(s): ${missing.join(", ")}`
};
@ -389,6 +410,7 @@ function liveCredentialStatus(store) {
providerId: provider.id,
method: provider.method,
externalCli: provider.externalCli ?? null,
fallbackPolicy: provider.fallbackPolicy ?? "mock",
envVars,
reason: "configured"
};
@ -398,12 +420,35 @@ function liveCredentialStatus(store) {
providerId: defaultId,
method: providers[defaultId]?.method ?? "mock",
externalCli: providers[defaultId]?.externalCli ?? null,
fallbackPolicy: providers[defaultId]?.fallbackPolicy ?? "mock",
envVars: providers[defaultId]?.envVars ?? [],
reason: "no live provider configured"
};
}
async function verifyLiveCredentialStatus(status) {
if (status.available === false && status.fallbackPolicy === "external-cli") {
try {
const externalCli = resolveExternalCliName(status.providerId);
const verification = await verifyExternalCliAuth(externalCli);
return {
...status,
available: verification.verified,
method: "external-cli",
externalCli,
fallbackFrom: status.method,
envVars: [],
reason: verification.verified ? "configured via external-cli fallback" : `external-cli ${externalCli} is not usable: ${verification.reason}`,
verification: externalCliVerificationSummary(verification)
};
} catch (error) {
return {
...status,
available: false,
reason: `${status.reason}; external-cli fallback unavailable: ${error.message}`
};
}
}
if (status.method !== "external-cli") {
return status;
}
@ -454,6 +499,24 @@ function normalizeAuthOverride(value) {
return mode;
}
function normalizeFallbackPolicy(value) {
const normalized = String(value ?? "mock").trim().toLowerCase().replaceAll("_", "-");
const aliases = {
mock: "mock",
default: "mock",
"external-cli": "external-cli",
external: "external-cli",
cli: "external-cli",
none: "none",
skip: "none",
disabled: "none"
};
if (aliases[normalized]) {
return aliases[normalized];
}
throw new Error(`fallbackPolicy must be one of ${fallbackPolicies.join(", ")}`);
}
function mockDir(artifactDir) {
return join(artifactDir, "mock-openai");
}

View File

@ -35,8 +35,8 @@ export function printHelp() {
Usage:
kova version [--json]
kova --version
kova setup [--ci|--non-interactive] [--auth <mock|api-key|env-only|external-cli|oauth|skip>] [--provider <id>] [--env-var <name>] [--value <secret>] [--json]
kova setup auth [--provider <id>] [--method <mock|api-key|env-only|external-cli|oauth|skip>] [--env-var <name>] [--value <secret>] [--json]
kova setup [--ci|--non-interactive] [--auth <mock|api-key|env-only|external-cli|oauth|skip>] [--provider <id>] [--env-var <name>] [--value <secret>] [--fallback-policy <mock|external-cli|none>] [--json]
kova setup auth [--provider <id>] [--method <mock|api-key|env-only|external-cli|oauth|skip>] [--env-var <name>] [--value <secret>] [--fallback-policy <mock|external-cli|none>] [--json]
kova self-check [--json]
kova plan [--scenario <id>] [--json]
kova run --target <selector> [--from <selector>] [--scenario <id>] [--state <id>] [--auth <mock|live|skip>] [--repeat <n>] [--baseline [path]] [--save-baseline [path]] [--regression-thresholds <json>] [--report-dir <path>] [--health-samples <n>] [--readiness-interval-ms <n>] [--resource-sample-interval-ms <n>] [--deep-profile] [--node-profile] [--heap-snapshot] [--profile-on-failure] [--execute] [--keep-env] [--retain-on-failure] [--json]
@ -65,6 +65,7 @@ Notes:
--auth defaults to mock so every disposable env has deliberate model auth unless a scenario opts out.
setup provider/auth choices accept either numbers from the prompt or names such as openai, anthropic, env-only, api-key.
external-cli setup derives Codex for OpenAI and Claude CLI for Anthropic, then verifies the CLI and auth evidence.
external CLI fallback is only used when explicitly configured with --fallback-policy external-cli.
--baseline compares executed aggregates against a Kova baseline store; without a path it uses the default store.
--save-baseline writes executed aggregates into the selected baseline store.
--deep-profile enables Node CPU/heap/trace profiling, OpenClaw timeline envs,

View File

@ -33,6 +33,7 @@ export async function collectProviderEvidence(artifactDir, options = {}) {
routes: [],
models: [],
statuses: [],
usage: null,
errors: [],
requests: [],
artifacts: [],
@ -146,6 +147,7 @@ export function parseProviderRequestLog(text) {
outcomes: summarizeBy(requests, "outcome"),
errorClasses: summarizeBy(requests, "errorClass"),
statuses: summarizeBy(requests, "status"),
usage: summarizeUsage(requests),
errors: [...parseErrors, ...requestErrors(requests)],
requests
};
@ -226,6 +228,7 @@ export function computeProviderTurnAttribution(result, providerEvidence) {
modes: [],
outcomes: [],
errorClasses: [],
usage: null,
errors: [],
providerDominates: null,
preProviderDominates: null,
@ -263,6 +266,7 @@ export function computeProviderTurnAttribution(result, providerEvidence) {
modes: summarizeBy(requests, "mode"),
outcomes: summarizeBy(requests, "outcome"),
errorClasses: summarizeBy(requests, "errorClass"),
usage: summarizeUsage(requests),
errors: requestErrors(requests),
providerDominates: dominanceRatio(Math.max(0, lastProviderResponseAt - firstProviderRequestAt), Math.max(0, commandFinishedAt - commandStartedAt)),
preProviderDominates: dominanceRatio(Math.max(0, firstProviderRequestAt - commandStartedAt), Math.max(0, commandFinishedAt - commandStartedAt)),
@ -322,6 +326,7 @@ function normalizeTimelineProviderRequest(event, line) {
stream: typeof event.stream === "boolean" ? event.stream : null,
status: numberOrNull(event.status),
statusClass: typeof event.status === "number" ? `${Math.floor(event.status / 100)}xx` : null,
usage: normalizeUsage(event.usage ?? event.attributes?.usage),
bodyBytes: null,
parseError: null
};
@ -358,6 +363,7 @@ function summarizeProviderRequests(requests) {
outcomes: summarizeBy(requests, "outcome"),
errorClasses: summarizeBy(requests, "errorClass"),
statuses: summarizeBy(requests, "status"),
usage: summarizeUsage(requests),
errors: requestErrors(requests)
};
}
@ -394,6 +400,7 @@ function normalizeProviderRequest(raw, line) {
model: raw.model ?? modelFromBody(raw.body),
stream: typeof raw.stream === "boolean" ? raw.stream : streamFromBody(raw.body),
status: numberOrNull(raw.status),
usage: normalizeUsage(raw.usage),
statusClass: raw.statusClass ?? (typeof raw.status === "number" ? `${Math.floor(raw.status / 100)}xx` : null),
bodyBytes: numberOrNull(raw.bodyBytes) ?? (typeof raw.body === "string" ? Buffer.byteLength(raw.body) : null),
parseError: raw.parseError ?? null
@ -458,6 +465,56 @@ function requestErrors(requests) {
return errors;
}
function summarizeUsage(requests) {
const usages = requests
.map((request) => request.usage)
.filter((usage) => usage && typeof usage === "object");
if (usages.length === 0) {
return {
schemaVersion: "kova.providerUsageSummary.v1",
available: false,
requestCount: requests.length,
requestsWithUsage: 0,
inputTokens: null,
outputTokens: null,
totalTokens: null
};
}
return {
schemaVersion: "kova.providerUsageSummary.v1",
available: true,
requestCount: requests.length,
requestsWithUsage: usages.length,
inputTokens: sumNullable(usages.map((usage) => usage.inputTokens)),
outputTokens: sumNullable(usages.map((usage) => usage.outputTokens)),
totalTokens: sumNullable(usages.map((usage) => usage.totalTokens))
};
}
function normalizeUsage(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
const inputTokens = numberOrNull(value.input_tokens ?? value.inputTokens ?? value.prompt_tokens ?? value.promptTokens);
const outputTokens = numberOrNull(value.output_tokens ?? value.outputTokens ?? value.completion_tokens ?? value.completionTokens);
const totalTokens = numberOrNull(value.total_tokens ?? value.totalTokens) ??
(typeof inputTokens === "number" && typeof outputTokens === "number" ? inputTokens + outputTokens : null);
if (![inputTokens, outputTokens, totalTokens].some((item) => typeof item === "number")) {
return null;
}
return {
schemaVersion: "kova.providerUsage.v1",
inputTokens,
outputTokens,
totalTokens
};
}
function sumNullable(values) {
const numbers = values.filter((value) => typeof value === "number");
return numbers.length > 0 ? numbers.reduce((sum, value) => sum + value, 0) : null;
}
function modelFromBody(body) {
const parsed = parseBody(body);
return typeof parsed?.model === "string" ? parsed.model : null;

View File

@ -74,6 +74,9 @@ export function renderMarkdownReport(report) {
}
if (record.auth) {
lines.push(`- Auth: ${record.auth.mode} (${record.auth.source}; provider ${record.auth.providerId ?? "none"})`);
if (record.auth.fallbackFrom) {
lines.push(`- Auth fallback: ${record.auth.fallbackFrom} -> ${record.auth.source}`);
}
if (record.auth.environmentDependent) {
lines.push("- Live provider lane: environment-dependent; compare separately from deterministic mock baselines.");
}
@ -145,7 +148,10 @@ export function renderMarkdownReport(report) {
if (record.measurements.agentProviderAttribution) {
lines.push(`- Provider evidence: ${record.measurements.agentProviderRequestCount ?? 0} request(s); provider work ${record.measurements.agentProviderFinalMs ?? "unknown"} ms; pre-provider ${record.measurements.agentPreProviderMs ?? "unknown"} ms; post-provider ${record.measurements.agentPostProviderMs ?? "unknown"} ms`);
} else if (record.providerEvidence?.available) {
lines.push(`- Provider evidence: ${record.providerEvidence.requestCount ?? 0} request(s); provider duration ${record.providerEvidence.providerDurationMs ?? "unknown"} ms`);
const usage = record.providerEvidence.usage?.available
? `; tokens ${record.providerEvidence.usage.totalTokens ?? "unknown"}`
: "";
lines.push(`- Provider evidence: ${record.providerEvidence.requestCount ?? 0} request(s); provider duration ${record.providerEvidence.providerDurationMs ?? "unknown"} ms${usage}`);
} else if (record.auth?.mode === "live") {
lines.push(`- Provider evidence: unavailable for live lane (${record.providerEvidence?.error ?? "no provider events captured"})`);
}

View File

@ -63,8 +63,11 @@ export async function runSelfCheck(flags = {}) {
checks.push(await interactiveSetupChoiceCheck(tmp));
checks.push(await externalCliSetupCheck(tmp));
checks.push(await externalCliOpenClawConfigCheck(tmp));
checks.push(await anthropicApiKeyOpenClawConfigCheck(tmp));
checks.push(await claudeCliOpenClawConfigCheck(tmp));
checks.push(await liveApiKeyExecutionCheck(tmp));
checks.push(await liveExternalCliDryRunCheck(tmp));
checks.push(await liveExternalCliFallbackCheck(tmp));
checks.push(await failingCommandCheck(
"setup-custom-provider-rejects-external-cli",
`KOVA_HOME=${quoteShell(join(tmp, "custom-external-cli-home"))} node bin/kova.mjs setup --non-interactive --provider custom-openai --auth external-cli --json`,
@ -840,6 +843,8 @@ async function providerEvidenceParserCheck() {
assertEqual(attribution.preProviderMs, 5000, "pre-provider latency");
assertEqual(attribution.providerFinalMs, 800, "provider final latency");
assertEqual(attribution.postProviderMs, 200, "post-provider latency");
assertEqual(evidence.usage?.available, true, "provider usage availability");
assertEqual(evidence.usage?.totalTokens, 12, "provider usage total tokens");
return {
id: "provider-evidence-parser",
status: "PASS",
@ -1011,6 +1016,71 @@ async function liveExternalCliDryRunCheck(tmp) {
}
}
async function liveExternalCliFallbackCheck(tmp) {
const home = join(tmp, "live-external-cli-fallback-home");
const kovaHome = join(tmp, "live-external-cli-fallback-kova-home");
const fakeBin = join(tmp, "live-external-cli-fallback-bin");
const reportDir = join(tmp, "live-external-cli-fallback-report");
await mkdir(join(home, ".codex"), { recursive: true });
await mkdir(join(kovaHome, "credentials"), { recursive: true });
await mkdir(fakeBin, { recursive: true });
await writeFile(join(home, ".codex", "auth.json"), "{\"tokens\":{\"access_token\":\"redacted\"}}\n", "utf8");
await writeFile(join(fakeBin, "codex"), "#!/bin/sh\necho codex-selfcheck\n", "utf8");
await chmod(join(fakeBin, "codex"), 0o755);
await writeFile(join(kovaHome, "credentials", "providers.json"), `${JSON.stringify({
schemaVersion: "kova.credentials.providers.v1",
defaultProvider: "openai",
providers: {
openai: {
id: "openai",
method: "env-only",
envVars: ["OPENAI_API_KEY"],
externalCli: null,
fallbackPolicy: "external-cli",
configuredAt: new Date().toISOString()
}
}
}, null, 2)}\n`, "utf8");
await writeFile(join(kovaHome, "credentials", "live.env"), "", { encoding: "utf8", mode: 0o600 });
const command = [
`HOME=${quoteShell(home)}`,
`PATH=${quoteShell(`${fakeBin}:${process.env.PATH}`)}`,
`KOVA_HOME=${quoteShell(kovaHome)}`,
`node bin/kova.mjs run --target runtime:stable --scenario fresh-install --auth live --report-dir ${quoteShell(reportDir)} --json`
].join(" ");
const result = await runCommand(command, { timeoutMs: 30000, maxOutputChars: 1000000 });
try {
if (result.status !== 0) {
throw new Error(result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`);
}
const receipt = JSON.parse(result.stdout);
const report = JSON.parse(await readFile(receipt.jsonPath, "utf8"));
const record = report.records?.[0];
assertEqual(report.auth?.live?.method, "external-cli", "fallback live method");
assertEqual(report.auth?.live?.fallbackFrom, "env-only", "fallback source method");
assertEqual(report.auth?.live?.fallbackPolicy, "external-cli", "fallback policy");
assertEqual(record?.auth?.source, "external-cli", "record fallback source");
assertEqual(record?.auth?.fallbackFrom, "env-only", "record fallback from");
assertEqual(record?.auth?.externalCli, "codex", "record fallback CLI");
return {
id: "live-external-cli-fallback",
status: "PASS",
command,
durationMs: result.durationMs
};
} catch (error) {
return {
id: "live-external-cli-fallback",
status: "FAIL",
command,
durationMs: result.durationMs,
message: error.message
};
}
}
function fakeOcmScript() {
return `#!/bin/sh
printf '%s\\n' "$*" >> "$KOVA_MOCK_OCM_LOG"
@ -2310,6 +2380,72 @@ async function externalCliOpenClawConfigCheck(tmp) {
}
}
async function anthropicApiKeyOpenClawConfigCheck(tmp) {
const home = join(tmp, "anthropic-api-key-config-home");
const command = [
`OPENCLAW_HOME=${quoteShell(home)}`,
"node support/configure-openclaw-live-auth.mjs --provider anthropic --env-var ANTHROPIC_API_KEY"
].join(" ");
const result = await runCommand(command, { timeoutMs: 30000, maxOutputChars: 1000000 });
try {
if (result.status !== 0) {
throw new Error(result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`);
}
const config = JSON.parse(await readFile(join(home, ".openclaw", "openclaw.json"), "utf8"));
assertEqual(config.models?.providers?.anthropic?.apiKey?.id, "ANTHROPIC_API_KEY", "anthropic env ref");
assertEqual(config.agents?.defaults?.model?.primary, "anthropic/claude-sonnet-4-5", "anthropic default model");
return {
id: "anthropic-api-key-openclaw-config",
status: "PASS",
command,
durationMs: result.durationMs
};
} catch (error) {
return {
id: "anthropic-api-key-openclaw-config",
status: "FAIL",
command,
durationMs: result.durationMs,
message: error.message
};
}
}
async function claudeCliOpenClawConfigCheck(tmp) {
const home = join(tmp, "claude-cli-config-home");
const command = [
`OPENCLAW_HOME=${quoteShell(home)}`,
"node support/configure-openclaw-live-auth.mjs --provider anthropic --auth-method external-cli --external-cli claude"
].join(" ");
const result = await runCommand(command, { timeoutMs: 30000, maxOutputChars: 1000000 });
try {
if (result.status !== 0) {
throw new Error(result.stderr.trim() || result.stdout.trim() || `exit ${result.status}`);
}
const config = JSON.parse(await readFile(join(home, ".openclaw", "openclaw.json"), "utf8"));
assertEqual(config.agents?.defaults?.model?.primary, "anthropic/claude-sonnet-4-5", "claude cli model ref");
assertEqual(config.agents?.defaults?.agentRuntime?.id, "claude-cli", "claude cli runtime id");
assertEqual(config.agents?.defaults?.agentRuntime?.fallback, "none", "claude cli runtime fallback");
if (config.models?.providers?.anthropic?.apiKey !== undefined) {
throw new Error("Claude CLI config must not write env apiKey config");
}
return {
id: "claude-cli-openclaw-config",
status: "PASS",
command,
durationMs: result.durationMs
};
} catch (error) {
return {
id: "claude-cli-openclaw-config",
status: "FAIL",
command,
durationMs: result.durationMs,
message: error.message
};
}
}
async function externalCliRunAuthVerificationCheck(tmp) {
const home = join(tmp, "stale-external-cli-home");
const kovaHome = join(tmp, "stale-external-cli-kova-home");

View File

@ -84,6 +84,7 @@ async function writeStreamingStall(res, stream, call) {
}
function responseEvents(text) {
const usage = mockUsage();
return [
{
type: "response.output_item.added",
@ -103,17 +104,25 @@ function responseEvents(text) {
type: "response.completed",
response: {
status: "completed",
usage: {
input_tokens: 9,
output_tokens: 3,
total_tokens: 12,
input_tokens_details: { cached_tokens: 0 }
}
usage
}
}
];
}
function mockUsage() {
return {
input_tokens: 9,
output_tokens: 3,
total_tokens: 12,
input_tokens_details: { cached_tokens: 0 }
};
}
function chatUsage() {
return { prompt_tokens: 9, completion_tokens: 3, total_tokens: 12 };
}
function writeChatCompletion(res, stream) {
if (stream) {
writeSse(res, [
@ -135,7 +144,7 @@ function writeChatCompletion(res, stream) {
id: "chatcmpl_kova",
object: "chat.completion",
choices: [{ index: 0, message: { role: "assistant", content: marker }, finish_reason: "stop" }],
usage: { prompt_tokens: 9, completion_tokens: 3, total_tokens: 12 }
usage: chatUsage()
});
}
@ -169,6 +178,7 @@ const server = http.createServer(async (req, res) => {
let loggable = false;
let stream = false;
let model = null;
let usage = null;
let behavior = {
mode: providerMode,
outcome: null,
@ -212,6 +222,7 @@ const server = http.createServer(async (req, res) => {
model,
stream,
status,
usage,
statusClass: typeof status === "number" ? `${Math.floor(status / 100)}xx` : null,
bodyBytes: Buffer.byteLength(bodyText),
parseError
@ -259,6 +270,7 @@ const server = http.createServer(async (req, res) => {
return;
}
if (body.stream === false) {
usage = mockUsage();
writeJson(res, 200, {
id: "resp_kova",
object: "response",
@ -272,10 +284,11 @@ const server = http.createServer(async (req, res) => {
content: [{ type: "output_text", text: marker, annotations: [] }]
}
],
usage: { input_tokens: 9, output_tokens: 3, total_tokens: 12 }
usage
});
return;
}
usage = mockUsage();
writeSse(res, responseEvents(marker));
return;
}
@ -286,6 +299,9 @@ const server = http.createServer(async (req, res) => {
if (await maybeWriteFailureBehavior(res, behavior, body.stream !== false)) {
return;
}
if (body.stream === false) {
usage = chatUsage();
}
writeChatCompletion(res, body.stream !== false);
return;
}