@agent-native/core 0.94.3 → 0.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/docs/content/pr-visual-recap.mdx +20 -12
  4. package/corpus/core/package.json +1 -1
  5. package/corpus/core/src/agent/engine/ai-sdk-engine.ts +3 -0
  6. package/corpus/core/src/agent/engine/registry.ts +6 -1
  7. package/corpus/core/src/agent/engine/types.ts +2 -0
  8. package/corpus/core/src/cli/code-agent-executor.ts +61 -1
  9. package/corpus/core/src/cli/pr-visual-recap-workflow.ts +1 -1
  10. package/corpus/core/src/cli/recap.ts +262 -25
  11. package/corpus/core/src/client/settings/UsageSection.tsx +26 -5
  12. package/corpus/core/src/index.ts +2 -0
  13. package/corpus/core/src/usage/store.ts +82 -20
  14. package/corpus/templates/plan/actions/record-recap-usage-cost.ts +49 -0
  15. package/corpus/templates/plan/actions/record-recap-usage.ts +15 -22
  16. package/dist/agent/engine/ai-sdk-engine.d.ts.map +1 -1
  17. package/dist/agent/engine/ai-sdk-engine.js +3 -0
  18. package/dist/agent/engine/ai-sdk-engine.js.map +1 -1
  19. package/dist/agent/engine/registry.d.ts +1 -1
  20. package/dist/agent/engine/registry.d.ts.map +1 -1
  21. package/dist/agent/engine/registry.js +2 -0
  22. package/dist/agent/engine/registry.js.map +1 -1
  23. package/dist/agent/engine/types.d.ts +2 -0
  24. package/dist/agent/engine/types.d.ts.map +1 -1
  25. package/dist/agent/engine/types.js.map +1 -1
  26. package/dist/cli/code-agent-executor.d.ts +2 -0
  27. package/dist/cli/code-agent-executor.d.ts.map +1 -1
  28. package/dist/cli/code-agent-executor.js +52 -3
  29. package/dist/cli/code-agent-executor.js.map +1 -1
  30. package/dist/cli/pr-visual-recap-workflow.d.ts +1 -1
  31. package/dist/cli/pr-visual-recap-workflow.d.ts.map +1 -1
  32. package/dist/cli/pr-visual-recap-workflow.js +1 -1
  33. package/dist/cli/pr-visual-recap-workflow.js.map +1 -1
  34. package/dist/cli/recap.d.ts +22 -2
  35. package/dist/cli/recap.d.ts.map +1 -1
  36. package/dist/cli/recap.js +199 -22
  37. package/dist/cli/recap.js.map +1 -1
  38. package/dist/client/settings/UsageSection.d.ts.map +1 -1
  39. package/dist/client/settings/UsageSection.js +12 -3
  40. package/dist/client/settings/UsageSection.js.map +1 -1
  41. package/dist/collab/struct-routes.d.ts +1 -1
  42. package/dist/index.d.ts +1 -1
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/usage/store.d.ts +21 -0
  46. package/dist/usage/store.d.ts.map +1 -1
  47. package/dist/usage/store.js +54 -19
  48. package/dist/usage/store.js.map +1 -1
  49. package/docs/content/pr-visual-recap.mdx +20 -12
  50. package/package.json +1 -1
@@ -40,6 +40,7 @@ import fs from "node:fs";
40
40
  import os from "node:os";
41
41
  import path from "node:path";
42
42
 
43
+ import { normalizeOpenAiBaseUrl } from "../agent/engine/openai-compatible-endpoint.js";
43
44
  import {
44
45
  DEFAULT_PLAN_APP_URL,
45
46
  fetchPlanBlockCatalog,
@@ -99,7 +100,9 @@ export const PR_VISUAL_RECAP_SETUP: string[] = [
99
100
  " ANTHROPIC_API_KEY — the LLM key for the default Claude Code backend",
100
101
  "Optional (only if you change defaults):",
101
102
  " OPENAI_API_KEY (secret) + VISUAL_RECAP_AGENT=codex (variable) — use Codex instead of Claude",
102
- " VISUAL_RECAP_MODEL / VISUAL_RECAP_REASONING (variables) pin the model (e.g. gpt-5.6-sol) and reasoning depth (none|minimal|low|medium|high|xhigh; Codex only)",
103
+ " VISUAL_RECAP_API_KEY (secret) + VISUAL_RECAP_AGENT=openai-compatible + VISUAL_RECAP_BASE_URL (variable) use DeepSeek, Kimi, or any OpenAI-compatible API",
104
+ " VISUAL_RECAP_MODEL (variable, required for openai-compatible) — provider model id; optional override for Claude/Codex",
105
+ " VISUAL_RECAP_REASONING (variable) — reasoning depth (none|minimal|low|medium|high|xhigh; Codex only)",
103
106
  " VISUAL_RECAP_SKILL_SOURCE=repo (variable) — pin CI to the repo-local visual-recap skill instead of latest bundled guidance",
104
107
  " VISUAL_RECAP_SECRET_SCAN=off|high-confidence|strict (variable) — default high-confidence; strict restores generic TOKEN/SECRET assignment suppression",
105
108
  " PLAN_RECAP_APP_URL (secret) — only when self-hosting the plan app (defaults to https://plan.agent-native.com)",
@@ -198,10 +201,12 @@ export function buildReusableCallerWorkflow(
198
201
  ` PLAN_RECAP_TOKEN: \${{ secrets.PLAN_RECAP_TOKEN }}\n` +
199
202
  ` ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}\n` +
200
203
  ` OPENAI_API_KEY: \${{ secrets.OPENAI_API_KEY }}\n` +
204
+ ` VISUAL_RECAP_API_KEY: \${{ secrets.VISUAL_RECAP_API_KEY }}\n` +
201
205
  ` PLAN_RECAP_APP_URL: \${{ secrets.PLAN_RECAP_APP_URL }}\n` +
202
206
  ` with:\n` +
203
207
  ` agent: ${agentValue}\n` +
204
208
  ` model: ${modelValue}\n` +
209
+ ` base-url: \${{ vars.VISUAL_RECAP_BASE_URL || '' }}\n` +
205
210
  ` reasoning: \${{ vars.VISUAL_RECAP_REASONING || '' }}\n` +
206
211
  ` skill-source: \${{ vars.VISUAL_RECAP_SKILL_SOURCE || 'auto' }}\n` +
207
212
  ` secret-scan: \${{ vars.VISUAL_RECAP_SECRET_SCAN || 'high-confidence' }}\n` +
@@ -253,9 +258,9 @@ export function writePrVisualRecapReusableCallerWorkflow(
253
258
 
254
259
  // Narrow type used only where it's needed (avoids importing the full
255
260
  // RecapAgent type before it is defined below).
256
- type RecapAgentValue = "claude" | "codex";
261
+ type RecapAgentValue = "claude" | "codex" | "openai-compatible";
257
262
 
258
- export type RecapAgent = "claude" | "codex";
263
+ export type RecapAgent = "claude" | "codex" | "openai-compatible";
259
264
 
260
265
  const DEFAULT_RECAP_APP_URL = DEFAULT_PLAN_APP_URL;
261
266
 
@@ -263,15 +268,26 @@ export function normalizeRecapAgent(value: string | undefined): RecapAgent {
263
268
  const agent = (value || "claude").toLowerCase();
264
269
  if (agent === "codex") return "codex";
265
270
  if (agent === "claude") return "claude";
271
+ if (
272
+ ["openai-compatible", "deepseek", "kimi", "moonshot", "custom"].includes(
273
+ agent,
274
+ )
275
+ ) {
276
+ return "openai-compatible";
277
+ }
266
278
  throw new Error(
267
- `Unsupported recap agent "${value}" (expected "claude" or "codex").`,
279
+ `Unsupported recap agent "${value}" (expected "claude", "codex", or "openai-compatible").`,
268
280
  );
269
281
  }
270
282
 
271
283
  export function recapRequiredSecrets(agent: RecapAgent): string[] {
272
284
  return [
273
285
  "PLAN_RECAP_TOKEN",
274
- agent === "codex" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY",
286
+ agent === "codex"
287
+ ? "OPENAI_API_KEY"
288
+ : agent === "openai-compatible"
289
+ ? "VISUAL_RECAP_API_KEY"
290
+ : "ANTHROPIC_API_KEY",
275
291
  ];
276
292
  }
277
293
 
@@ -431,10 +447,71 @@ export interface RecapSetupPlan {
431
447
  workflowPath: string;
432
448
  workflowExists: boolean;
433
449
  requiredSecrets: string[];
450
+ requiredVariables: readonly RecapVariableRequirement[];
451
+ variableProblems: RecapVariableProblem[];
434
452
  variableValues: Record<string, string>;
435
453
  secretValues: Record<string, string | undefined>;
436
454
  }
437
455
 
456
+ export interface RecapVariableRequirement {
457
+ name: "VISUAL_RECAP_BASE_URL" | "VISUAL_RECAP_MODEL";
458
+ example: string;
459
+ }
460
+
461
+ export interface RecapVariableProblem {
462
+ requirement: RecapVariableRequirement;
463
+ reason: string;
464
+ }
465
+
466
+ const OPENAI_COMPATIBLE_VARIABLE_REQUIREMENTS = [
467
+ {
468
+ name: "VISUAL_RECAP_BASE_URL",
469
+ example: "https://provider.example/v1",
470
+ },
471
+ { name: "VISUAL_RECAP_MODEL", example: "provider-model-id" },
472
+ ] as const satisfies readonly RecapVariableRequirement[];
473
+
474
+ const RECAP_MODEL_PATTERN = /^[a-zA-Z0-9._-]{1,80}$/;
475
+ const OPENAI_COMPATIBLE_RECAP_MODEL_PATTERN = /^[^\p{C}\p{Z}]{1,200}$/u;
476
+
477
+ export function validateOpenAiCompatibleRecapVariables(input: {
478
+ baseUrl?: string;
479
+ model?: string;
480
+ }): RecapVariableProblem[] {
481
+ const [baseUrlRequirement, modelRequirement] =
482
+ OPENAI_COMPATIBLE_VARIABLE_REQUIREMENTS;
483
+ const problems: RecapVariableProblem[] = [];
484
+ const baseUrl = input.baseUrl?.trim() || "";
485
+ const rawModel = input.model || "";
486
+ const model = rawModel.trim();
487
+
488
+ try {
489
+ const parsed = normalizeOpenAiBaseUrl(baseUrl);
490
+ if (!parsed) throw new Error("empty");
491
+ } catch {
492
+ problems.push({
493
+ requirement: baseUrlRequirement,
494
+ reason:
495
+ "VISUAL_RECAP_BASE_URL must be a valid http(s) URL without credentials",
496
+ });
497
+ }
498
+
499
+ if (!model) {
500
+ problems.push({
501
+ requirement: modelRequirement,
502
+ reason: "VISUAL_RECAP_MODEL is required (openai-compatible backend)",
503
+ });
504
+ } else if (!OPENAI_COMPATIBLE_RECAP_MODEL_PATTERN.test(rawModel)) {
505
+ problems.push({
506
+ requirement: modelRequirement,
507
+ reason:
508
+ "invalid VISUAL_RECAP_MODEL value (must be 1-200 characters without whitespace or controls)",
509
+ });
510
+ }
511
+
512
+ return problems;
513
+ }
514
+
438
515
  export function buildRecapSetupPlan(input: {
439
516
  baseDir: string;
440
517
  appUrl?: string;
@@ -448,10 +525,18 @@ export function buildRecapSetupPlan(input: {
448
525
  );
449
526
  const agent = normalizeRecapAgent(input.agent || env.VISUAL_RECAP_AGENT);
450
527
  const requiredSecrets = recapRequiredSecrets(agent);
528
+ const requiredVariables =
529
+ agent === "openai-compatible"
530
+ ? OPENAI_COMPATIBLE_VARIABLE_REQUIREMENTS
531
+ : [];
451
532
  const planToken =
452
533
  envValue(env, "PLAN_RECAP_TOKEN") ?? planTokenFromLocalStore(appUrl);
453
534
  const llmSecretName =
454
- agent === "codex" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
535
+ agent === "codex"
536
+ ? "OPENAI_API_KEY"
537
+ : agent === "openai-compatible"
538
+ ? "VISUAL_RECAP_API_KEY"
539
+ : "ANTHROPIC_API_KEY";
455
540
  const variableValues: Record<string, string> = {};
456
541
  if (agent !== "claude") variableValues.VISUAL_RECAP_AGENT = agent;
457
542
  for (const key of [
@@ -462,6 +547,17 @@ export function buildRecapSetupPlan(input: {
462
547
  const value = envValue(env, key);
463
548
  if (value) variableValues[key] = value;
464
549
  }
550
+ if (agent === "openai-compatible") {
551
+ const baseUrl = envValue(env, "VISUAL_RECAP_BASE_URL");
552
+ if (baseUrl) variableValues.VISUAL_RECAP_BASE_URL = baseUrl;
553
+ }
554
+ const variableProblems =
555
+ agent === "openai-compatible"
556
+ ? validateOpenAiCompatibleRecapVariables({
557
+ baseUrl: variableValues.VISUAL_RECAP_BASE_URL,
558
+ model: variableValues.VISUAL_RECAP_MODEL,
559
+ })
560
+ : [];
465
561
  return {
466
562
  agent,
467
563
  appUrl,
@@ -472,6 +568,8 @@ export function buildRecapSetupPlan(input: {
472
568
  ),
473
569
  workflowExists: fs.existsSync(recapWorkflowFile(input.baseDir)),
474
570
  requiredSecrets,
571
+ requiredVariables,
572
+ variableProblems,
475
573
  variableValues,
476
574
  secretValues: {
477
575
  PLAN_RECAP_TOKEN: planToken,
@@ -592,7 +690,12 @@ function runSetup(args: Record<string, string | boolean>): void {
592
690
  }
593
691
  }
594
692
 
693
+ const invalidVariableNames = new Set(
694
+ plan.variableProblems.map((problem) => problem.requirement.name),
695
+ );
595
696
  for (const [name, value] of Object.entries(plan.variableValues)) {
697
+ if (invalidVariableNames.has(name as RecapVariableRequirement["name"]))
698
+ continue;
596
699
  const status = setGithubVariable(name, value, repo, dryRun);
597
700
  if (status === "set") {
598
701
  lines.push(` ${name}: set to ${value}.`);
@@ -605,6 +708,21 @@ function runSetup(args: Record<string, string | boolean>): void {
605
708
  );
606
709
  }
607
710
  }
711
+ for (const problem of plan.variableProblems) {
712
+ const { requirement } = problem;
713
+ const hasValue = Boolean(plan.variableValues[requirement.name]);
714
+ lines.push(
715
+ ` ${requirement.name}: ${hasValue ? "invalid value" : "missing value"}.`,
716
+ );
717
+ lines.push(` ${problem.reason}.`);
718
+ lines.push(
719
+ ` Set manually: ${commandForMissingVariable(
720
+ requirement.name,
721
+ requirement.example,
722
+ repo,
723
+ )}`,
724
+ );
725
+ }
608
726
  }
609
727
 
610
728
  lines.push("");
@@ -695,6 +813,36 @@ function runDoctor(args: Record<string, string | boolean>): void {
695
813
  } else {
696
814
  const configuredAgent = variables.get("VISUAL_RECAP_AGENT") || "claude";
697
815
  lines.push(`[ok] Recap backend variable: ${configuredAgent}.`);
816
+ const remoteVariableProblems =
817
+ plan.agent === "openai-compatible"
818
+ ? validateOpenAiCompatibleRecapVariables({
819
+ baseUrl: variables.get("VISUAL_RECAP_BASE_URL"),
820
+ model: variables.get("VISUAL_RECAP_MODEL"),
821
+ })
822
+ : [];
823
+ const problemsByName = new Map(
824
+ remoteVariableProblems.map((problem) => [
825
+ problem.requirement.name,
826
+ problem,
827
+ ]),
828
+ );
829
+ for (const requirement of plan.requiredVariables) {
830
+ const problem = problemsByName.get(requirement.name);
831
+ if (!problem) {
832
+ lines.push(`[ok] GitHub variable configured: ${requirement.name}.`);
833
+ } else {
834
+ ok = false;
835
+ const hasValue = Boolean(variables.get(requirement.name)?.trim());
836
+ lines.push(`[${hasValue ? "invalid" : "missing"}] ${problem.reason}.`);
837
+ lines.push(
838
+ ` Set it with: ${commandForMissingVariable(
839
+ requirement.name,
840
+ requirement.example,
841
+ repo,
842
+ )}`,
843
+ );
844
+ }
845
+ }
698
846
  }
699
847
 
700
848
  process.stdout.write(`${lines.join("\n")}\n`);
@@ -988,6 +1136,7 @@ function agentLabel(agent: string): string {
988
1136
  const normalized = agent.toLowerCase();
989
1137
  if (normalized === "codex") return "Codex";
990
1138
  if (normalized === "claude") return "Claude";
1139
+ if (normalized === "openai-compatible") return "OpenAI-compatible";
991
1140
  return agent || "Agent";
992
1141
  }
993
1142
 
@@ -1028,7 +1177,7 @@ function readTextIfExists(file: string): string | null {
1028
1177
  }
1029
1178
 
1030
1179
  function localAgentResultCandidates(agent: string): Array<{
1031
- agent: "claude" | "codex";
1180
+ agent: string;
1032
1181
  resultFile: string;
1033
1182
  stderrFile: string;
1034
1183
  exitCodeFile: string;
@@ -1046,9 +1195,16 @@ function localAgentResultCandidates(agent: string): Array<{
1046
1195
  stderrFile: "codex-stderr.log",
1047
1196
  exitCodeFile: "codex-exit-code.txt",
1048
1197
  },
1198
+ {
1199
+ agent: "openai-compatible",
1200
+ resultFile: "openai-compatible-result.txt",
1201
+ stderrFile: "openai-compatible-stderr.log",
1202
+ exitCodeFile: "openai-compatible-exit-code.txt",
1203
+ },
1049
1204
  ];
1050
1205
  const normalized = agent.toLowerCase();
1051
- if (normalized === "codex") return [all[1], all[0]];
1206
+ if (normalized === "codex") return [all[1], all[0], all[2]];
1207
+ if (normalized === "openai-compatible") return [all[2], all[0], all[1]];
1052
1208
  return all;
1053
1209
  }
1054
1210
 
@@ -3255,10 +3411,14 @@ export interface RecapGateInput {
3255
3411
  hasAnthropic: boolean;
3256
3412
  /** OPENAI_API_KEY present. */
3257
3413
  hasOpenai: boolean;
3414
+ /** VISUAL_RECAP_API_KEY present for OpenAI-compatible backends. */
3415
+ hasOpenaiCompatible?: boolean;
3258
3416
  /** Raw VISUAL_RECAP_AGENT value (may be undefined / mis-cased). */
3259
3417
  agentRaw: string | undefined;
3260
3418
  /** Raw VISUAL_RECAP_MODEL value (may be undefined). */
3261
3419
  model: string | undefined;
3420
+ /** Raw VISUAL_RECAP_BASE_URL value for OpenAI-compatible backends. */
3421
+ baseUrl?: string;
3262
3422
  /** Raw VISUAL_RECAP_SKILL_SOURCE value (auto/latest/repo; may be undefined). */
3263
3423
  skillSource: string | undefined;
3264
3424
  /** Filenames changed by the PR (for the self-modifying guard). */
@@ -3360,23 +3520,41 @@ export function evaluateRecapGate(input: RecapGateInput): {
3360
3520
  // The chosen backend's API key must be present. Normalize the agent value once
3361
3521
  // here and validate it: an unknown or mis-cased value (e.g. "Claude", "gpt")
3362
3522
  // must NOT silently pass the gate and then match neither agent step.
3363
- const agent = (input.agentRaw || "claude").toLowerCase();
3364
- if (agent !== "claude" && agent !== "codex") {
3523
+ const rawAgent = (input.agentRaw || "claude").toLowerCase();
3524
+ const agent = ["deepseek", "kimi", "moonshot", "custom"].includes(rawAgent)
3525
+ ? "openai-compatible"
3526
+ : rawAgent;
3527
+ if (!["claude", "codex", "openai-compatible"].includes(agent)) {
3365
3528
  reasons.push(
3366
- `unsupported VISUAL_RECAP_AGENT "${input.agentRaw}" (expected "claude" or "codex")`,
3529
+ `unsupported VISUAL_RECAP_AGENT "${input.agentRaw}" (expected "claude", "codex", or "openai-compatible")`,
3367
3530
  );
3368
3531
  } else if (agent === "codex") {
3369
3532
  if (!input.hasOpenai)
3370
3533
  reasons.push("OPENAI_API_KEY not configured (codex backend)");
3371
- } else {
3534
+ } else if (agent === "claude") {
3372
3535
  if (!input.hasAnthropic)
3373
3536
  reasons.push("ANTHROPIC_API_KEY not configured (claude backend)");
3537
+ } else {
3538
+ if (!input.hasOpenaiCompatible)
3539
+ reasons.push(
3540
+ "VISUAL_RECAP_API_KEY not configured (openai-compatible backend)",
3541
+ );
3542
+ reasons.push(
3543
+ ...validateOpenAiCompatibleRecapVariables({
3544
+ baseUrl: input.baseUrl,
3545
+ model: input.model,
3546
+ }).map((problem) => problem.reason),
3547
+ );
3374
3548
  }
3375
3549
 
3376
3550
  // Validate VISUAL_RECAP_MODEL if set — an unchecked value could be injected by
3377
3551
  // a repo settings writer and passed straight to the agent CLI.
3378
3552
  const model = input.model || "";
3379
- if (model && !/^[a-zA-Z0-9._-]{1,80}$/.test(model)) {
3553
+ if (
3554
+ agent !== "openai-compatible" &&
3555
+ model &&
3556
+ !RECAP_MODEL_PATTERN.test(model)
3557
+ ) {
3380
3558
  reasons.push(
3381
3559
  "invalid VISUAL_RECAP_MODEL value (must match [a-zA-Z0-9._-]{1,80})",
3382
3560
  );
@@ -3514,8 +3692,10 @@ async function runGate(): Promise<void> {
3514
3692
  hasPlan: process.env.HAS_PLAN === "true",
3515
3693
  hasAnthropic: process.env.HAS_ANTHROPIC === "true",
3516
3694
  hasOpenai: process.env.HAS_OPENAI === "true",
3695
+ hasOpenaiCompatible: process.env.HAS_COMPATIBLE === "true",
3517
3696
  agentRaw: process.env.AGENT,
3518
3697
  model: process.env.VISUAL_RECAP_MODEL,
3698
+ baseUrl: process.env.VISUAL_RECAP_BASE_URL,
3519
3699
  skillSource: process.env.VISUAL_RECAP_SKILL_SOURCE,
3520
3700
  changedFiles,
3521
3701
  });
@@ -4103,6 +4283,49 @@ export function parseCodexUsage(jsonl: string): ParsedUsage | null {
4103
4283
  };
4104
4284
  }
4105
4285
 
4286
+ /** Parse the usage sidecar emitted by an Agent-Native Code run. */
4287
+ export function parseOpenAiCompatibleUsage(json: string): ParsedUsage | null {
4288
+ const obj = parseLastJsonObject(json);
4289
+ const usage = obj?.usage ?? obj;
4290
+ if (!usage || typeof usage !== "object") return null;
4291
+
4292
+ const input =
4293
+ usage.inputTokens ?? usage.input_tokens ?? usage.prompt_tokens ?? undefined;
4294
+ const output =
4295
+ usage.outputTokens ??
4296
+ usage.output_tokens ??
4297
+ usage.completion_tokens ??
4298
+ undefined;
4299
+ if (input == null && output == null) return null;
4300
+
4301
+ const asCount = (value: unknown): number => {
4302
+ const parsed = Number(value ?? 0);
4303
+ return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
4304
+ };
4305
+ const inputDetails = usage.inputTokenDetails;
4306
+ return {
4307
+ inputTokens: asCount(input),
4308
+ outputTokens: asCount(output),
4309
+ cacheReadTokens: asCount(
4310
+ usage.cacheReadTokens ??
4311
+ usage.cachedInputTokens ??
4312
+ usage.cache_read_input_tokens ??
4313
+ inputDetails?.cacheReadTokens,
4314
+ ),
4315
+ cacheWriteTokens: asCount(
4316
+ usage.cacheWriteTokens ??
4317
+ usage.cache_write_tokens ??
4318
+ inputDetails?.cacheWriteTokens,
4319
+ ),
4320
+ model:
4321
+ typeof obj?.model === "string"
4322
+ ? obj.model
4323
+ : typeof usage.model === "string"
4324
+ ? usage.model
4325
+ : undefined,
4326
+ };
4327
+ }
4328
+
4106
4329
  /**
4107
4330
  * `recap usage` — parse the agent's run output for token usage and POST it to
4108
4331
  * the plan app's record-recap-usage action so the recap row carries cost. The
@@ -4134,7 +4357,12 @@ async function runUsage(args: Record<string, string | boolean>): Promise<void> {
4134
4357
  path.resolve(stringArg(args, "result-file")),
4135
4358
  "utf8",
4136
4359
  );
4137
- parsed = agent === "codex" ? parseCodexUsage(raw) : parseClaudeUsage(raw);
4360
+ parsed =
4361
+ agent === "codex"
4362
+ ? parseCodexUsage(raw)
4363
+ : agent === "openai-compatible"
4364
+ ? parseOpenAiCompatibleUsage(raw)
4365
+ : parseClaudeUsage(raw);
4138
4366
  } catch (err) {
4139
4367
  done({ ok: false, reason: `could not read/parse usage: ${String(err)}` });
4140
4368
  return;
@@ -4149,10 +4377,18 @@ async function runUsage(args: Record<string, string | boolean>): Promise<void> {
4149
4377
  const model =
4150
4378
  parsed.model ??
4151
4379
  optionalArg(args, "model") ??
4152
- (agent === "codex" ? "gpt-5.6-sol" : "claude");
4380
+ (agent === "codex"
4381
+ ? "gpt-5.6-sol"
4382
+ : agent === "openai-compatible"
4383
+ ? "openai-compatible"
4384
+ : "claude");
4385
+ const usageAgent =
4386
+ agent === "claude" || agent === "codex" || agent === "openai-compatible"
4387
+ ? agent
4388
+ : undefined;
4153
4389
  const body: Record<string, unknown> = {
4154
4390
  planId,
4155
- ...(agent === "codex" || agent === "claude" ? { agent } : {}),
4391
+ ...(usageAgent ? { agent: usageAgent } : {}),
4156
4392
  model,
4157
4393
  inputTokens: parsed.inputTokens,
4158
4394
  outputTokens: parsed.outputTokens,
@@ -4230,16 +4466,16 @@ function runAgentSummary(args: Record<string, string | boolean>): void {
4230
4466
  const HELP = `npx @agent-native/core@latest recap — PR visual recap helpers (used by the GitHub Action)
4231
4467
 
4232
4468
  Usage:
4233
- npx @agent-native/core@latest recap setup [--repo owner/name] [--agent claude|codex] [--app-url <url>] [--skip-secrets] [--dry-run] [--force]
4234
- npx @agent-native/core@latest recap doctor [--repo owner/name] [--agent claude|codex] [--app-url <url>]
4469
+ npx @agent-native/core@latest recap setup [--repo owner/name] [--agent claude|codex|openai-compatible] [--app-url <url>] [--skip-secrets] [--dry-run] [--force]
4470
+ npx @agent-native/core@latest recap doctor [--repo owner/name] [--agent claude|codex|openai-compatible] [--app-url <url>]
4235
4471
  npx @agent-native/core@latest recap collect-diff --base <baseSha> --head <headSha> [--out recap.diff] [--stat recap.stat]
4236
4472
  npx @agent-native/core@latest recap block-reference [--app-url <url>] [--out recap-blocks.md]
4237
4473
  npx @agent-native/core@latest recap scan --diff <path> [--mode off|high-confidence|strict]
4238
4474
  npx @agent-native/core@latest recap build-prompt --pr <n> [--repo owner/name] [--head <sha>] [--app-url <url>] [--diff <path>] [--stat <path>] [--block-reference recap-blocks.md] [--prev-plan-id <id>] [--huge] [--local-files] [--local-dir <folder>] [--skill-source auto|latest|repo] [--out <path>]
4239
4475
  npx @agent-native/core@latest recap publish [--source recap-source.json] [--out recap-url.txt] [--repo owner/name] [--pr <n>] [--prev-plan-id <id>] [--source-pr-state open|closed|merged] [--source-pr-merged-at <iso>] [--source-author-email <email>] [--source-author-name <name>] [--source-author-login <login>] [--app-url <url>] [--token <planToken>] [--github-token <ghToken>]
4240
4476
  npx @agent-native/core@latest recap shot --url <planUrl> [--token <planToken>] [--app-url <url>] [--out recap.png] [--theme light|dark] [--image-cache-key <key>]
4241
- npx @agent-native/core@latest recap usage --plan-url <planUrl> --result-file <path> --app-url <url> --token <planToken> [--agent claude|codex] [--model <id>]
4242
- npx @agent-native/core@latest recap agent-summary --result-file <path> [--stderr-file <path>] [--exit-code-file <path>] [--agent claude|codex]
4477
+ npx @agent-native/core@latest recap usage --plan-url <planUrl> --result-file <path> --app-url <url> --token <planToken> [--agent claude|codex|openai-compatible] [--model <id>]
4478
+ npx @agent-native/core@latest recap agent-summary --result-file <path> [--stderr-file <path>] [--exit-code-file <path>] [--agent claude|codex|openai-compatible]
4243
4479
  npx @agent-native/core@latest recap comment <find-plan-id|upsert> --repo owner/name --issue <n> --token <github-token>
4244
4480
  npx @agent-native/core@latest recap check start [--repo owner/name] [--sha <headSha>] [--token <github-token>] [--workflow-url <url>]
4245
4481
  Create the in-progress "Visual Recap" GitHub check run and write its id to
@@ -4256,16 +4492,16 @@ Usage:
4256
4492
  The PR Visual Recap security gate. Decides whether to run the recap at all
4257
4493
  and which (normalized) backend agent to use. Reads the pull_request payload
4258
4494
  from $GITHUB_EVENT_PATH, the secret-presence/agent/model signals from the
4259
- environment (HAS_PLAN / HAS_ANTHROPIC / HAS_OPENAI === 'true', AGENT,
4260
- VISUAL_RECAP_MODEL), the repo from $GITHUB_REPOSITORY, and the PR's changed
4495
+ environment (HAS_PLAN / HAS_ANTHROPIC / HAS_OPENAI / HAS_COMPATIBLE === 'true', AGENT,
4496
+ VISUAL_RECAP_MODEL / VISUAL_RECAP_BASE_URL), the repo from $GITHUB_REPOSITORY, and the PR's changed
4261
4497
  files from the GitHub REST API (paged, with GH_TOKEN/GITHUB_TOKEN). Skips
4262
4498
  drafts, forks without secret access, bot authors, the missing-secret case, an
4263
4499
  invalid agent/model, and any untrusted PR that touches recap-control files
4264
4500
  (repo-pinned skill instructions, .claude/**, root CLAUDE.md, root AGENTS.md,
4265
4501
  root .mcp.json) — failing CLOSED on any file-list error. Writes
4266
- run=<true|false> and agent=<claude|codex> to $GITHUB_OUTPUT.
4502
+ run=<true|false> and agent=<claude|codex|openai-compatible> to $GITHUB_OUTPUT.
4267
4503
  npx @agent-native/core@latest recap agent-summary
4268
- Read the captured Claude/Codex result file and write a sanitized one-line
4504
+ Read the captured agent result file and write a sanitized one-line
4269
4505
  summary to stdout and $GITHUB_OUTPUT (summary). Used only when no plan URL
4270
4506
  was produced, so PR comments/checks explain the actual failure.
4271
4507
  npx @agent-native/core@latest recap scan
@@ -4286,7 +4522,8 @@ Usage:
4286
4522
  commands; secret values are sent to gh through stdin, never argv.
4287
4523
  npx @agent-native/core@latest recap doctor
4288
4524
  Check workflow presence/drift, local Plans publish-token availability, gh
4289
- repo access, and required GitHub Actions secrets for the selected backend.
4525
+ repo access, and required GitHub Actions secrets and variables for the
4526
+ selected backend, including provider-variable validity.
4290
4527
  `;
4291
4528
 
4292
4529
  export async function runRecap(argv: string[]): Promise<void> {
@@ -6,6 +6,7 @@ import { agentNativePath } from "../api-path.js";
6
6
  interface UsageBucket {
7
7
  key: string;
8
8
  cents: number;
9
+ cost: UsageCostAggregate;
9
10
  calls: number;
10
11
  inputTokens: number;
11
12
  outputTokens: number;
@@ -16,6 +17,7 @@ interface UsageBucket {
16
17
  interface DailyBucket {
17
18
  date: string;
18
19
  cents: number;
20
+ cost: UsageCostAggregate;
19
21
  calls: number;
20
22
  }
21
23
 
@@ -30,8 +32,14 @@ interface UsageRecentEntry {
30
32
  cacheReadTokens: number;
31
33
  cacheWriteTokens: number;
32
34
  cents: number;
35
+ costSource: "reported" | "estimated" | "unavailable";
33
36
  }
34
37
 
38
+ type UsageCostAggregate =
39
+ | { status: "known"; knownCents: number; unavailableCalls: 0 }
40
+ | { status: "partial"; knownCents: number; unavailableCalls: number }
41
+ | { status: "unavailable"; knownCents: 0; unavailableCalls: number };
42
+
35
43
  interface UsageBillingMode {
36
44
  unit: "usd" | "builder-credits";
37
45
  label: string;
@@ -44,6 +52,7 @@ interface UsageBillingMode {
44
52
  interface UsageSummary {
45
53
  billing?: UsageBillingMode;
46
54
  totalCents: number;
55
+ totalCost: UsageCostAggregate;
47
56
  totalCalls: number;
48
57
  totalInputTokens: number;
49
58
  totalOutputTokens: number;
@@ -103,6 +112,15 @@ function formatSpend(cents: number, billing: UsageBillingMode): string {
103
112
  return `$${(cents / 100).toFixed(2)}`;
104
113
  }
105
114
 
115
+ function formatAggregateCost(
116
+ cost: UsageCostAggregate,
117
+ billing: UsageBillingMode,
118
+ ): string {
119
+ if (cost.status === "known") return formatSpend(cost.knownCents, billing);
120
+ if (cost.status === "unavailable") return "Unknown";
121
+ return `${formatSpend(cost.knownCents, billing)} + ${cost.unavailableCalls} unpriced`;
122
+ }
123
+
106
124
  function formatTokens(n: number): string {
107
125
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
108
126
  if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
@@ -139,7 +157,7 @@ function BucketBars({
139
157
  {b.key || "(none)"}
140
158
  </span>
141
159
  <span className="shrink-0 text-muted-foreground tabular-nums">
142
- {formatSpend(b.cents, billing)}
160
+ {formatAggregateCost(b.cost, billing)}
143
161
  <span className="ms-1 opacity-60">
144
162
  · {formatTokens(b.inputTokens + b.outputTokens)} tok
145
163
  </span>
@@ -183,7 +201,7 @@ function DailySparkline({
183
201
  (displayAmountFromCostCents(d.cents, billing) / max) * 100,
184
202
  )}%`,
185
203
  }}
186
- title={`${d.date}: ${formatSpend(d.cents, billing)} (${d.calls} calls)`}
204
+ title={`${d.date}: ${formatAggregateCost(d.cost, billing)} (${d.calls} calls)`}
187
205
  />
188
206
  ))}
189
207
  </div>
@@ -266,7 +284,7 @@ export function UsageSection() {
266
284
  : "Total spend"}
267
285
  </div>
268
286
  <div className="text-[18px] font-semibold tabular-nums">
269
- {formatSpend(data.totalCents, billing)}
287
+ {formatAggregateCost(data.totalCost, billing)}
270
288
  </div>
271
289
  </div>
272
290
  <div className="text-end">
@@ -352,7 +370,9 @@ export function UsageSection() {
352
370
  </div>
353
371
  </div>
354
372
  <div className="shrink-0 text-end tabular-nums text-muted-foreground">
355
- {formatSpend(r.cents, billing)}
373
+ {r.costSource === "unavailable"
374
+ ? "Unknown"
375
+ : formatSpend(r.cents, billing)}
356
376
  </div>
357
377
  </div>
358
378
  ))}
@@ -370,7 +390,8 @@ export function UsageSection() {
370
390
  <p className="text-[10px] text-muted-foreground">
371
391
  Spend is estimated from published Anthropic pricing and your own
372
392
  recorded token counts. Cached input is priced at ~10% of regular
373
- input.
393
+ input. Calls without provider pricing are marked unknown and
394
+ excluded from known-cost subtotals.
374
395
  </p>
375
396
  )}
376
397
  </>
@@ -236,6 +236,8 @@ export {
236
236
  BUILDER_CREDIT_USAGE_BILLING,
237
237
  USD_USAGE_BILLING,
238
238
  type UsageRecord,
239
+ type UsageCostSource,
240
+ type UsageCostAggregate,
239
241
  type UsageSummary,
240
242
  type UsageBillingMode,
241
243
  type UsageBillingUnit,