bopodev-api 0.1.13 → 0.1.15

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.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 BopoDev contributors
3
+ Copyright (c) 2026 Bopo
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bopodev-api",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "files": [
@@ -17,9 +17,9 @@
17
17
  "nanoid": "^5.1.5",
18
18
  "ws": "^8.19.0",
19
19
  "zod": "^4.1.5",
20
- "bopodev-contracts": "0.1.13",
21
- "bopodev-agent-sdk": "0.1.13",
22
- "bopodev-db": "0.1.13"
20
+ "bopodev-agent-sdk": "0.1.15",
21
+ "bopodev-contracts": "0.1.15",
22
+ "bopodev-db": "0.1.15"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@types/cors": "^2.8.19",
@@ -39,10 +39,39 @@ export function requiresRuntimeCwd(providerType: string) {
39
39
  providerType === "claude_code" ||
40
40
  providerType === "cursor" ||
41
41
  providerType === "opencode" ||
42
+ providerType === "gemini_cli" ||
42
43
  providerType === "shell"
43
44
  );
44
45
  }
45
46
 
47
+ export function resolveDefaultRuntimeModelForProvider(providerType: string | undefined) {
48
+ const normalizedProviderType = providerType?.trim() ?? "";
49
+ if (normalizedProviderType === "claude_code") {
50
+ return "claude-sonnet-4-6";
51
+ }
52
+ if (normalizedProviderType === "codex") {
53
+ return "gpt-5.3-codex";
54
+ }
55
+ if (normalizedProviderType === "opencode") {
56
+ return "opencode/big-pickle";
57
+ }
58
+ if (normalizedProviderType === "gemini_cli") {
59
+ return "gemini-2.5-pro";
60
+ }
61
+ return undefined;
62
+ }
63
+
64
+ export function resolveRuntimeModelForProvider(
65
+ providerType: string | undefined,
66
+ runtimeModel: string | undefined
67
+ ) {
68
+ const normalizedRuntimeModel = runtimeModel?.trim() || undefined;
69
+ if (normalizedRuntimeModel) {
70
+ return normalizedRuntimeModel;
71
+ }
72
+ return resolveDefaultRuntimeModelForProvider(providerType);
73
+ }
74
+
46
75
  export function normalizeRuntimeConfig(input: {
47
76
  runtimeConfig?: Partial<AgentRuntimeConfig>;
48
77
  legacy?: LegacyRuntimeFields;
@@ -116,12 +145,15 @@ export function parseRuntimeConfigFromAgentRow(agent: Record<string, unknown>):
116
145
  ? timeoutSecFromColumn
117
146
  : (toSeconds(fallback.timeoutMs) ?? 0);
118
147
 
148
+ const providerType = toText(agent.providerType);
149
+ const runtimeModel = resolveRuntimeModelForProvider(providerType, toText(agent.runtimeModel) ?? fallback.model);
150
+
119
151
  return {
120
152
  runtimeCommand: toText(agent.runtimeCommand) ?? fallback.command,
121
153
  runtimeArgs,
122
154
  runtimeCwd: toText(agent.runtimeCwd) ?? fallback.cwd,
123
155
  runtimeEnv,
124
- runtimeModel: toText(agent.runtimeModel),
156
+ runtimeModel,
125
157
  runtimeThinkingEffort: parseThinkingEffort(agent.runtimeThinkingEffort),
126
158
  bootstrapPrompt: toText(agent.bootstrapPrompt),
127
159
  runtimeTimeoutSec: Math.max(0, timeoutSec),
@@ -165,6 +197,7 @@ function parseRuntimeFromStateBlob(raw: unknown) {
165
197
  args?: string[];
166
198
  cwd?: string;
167
199
  env?: Record<string, string>;
200
+ model?: string;
168
201
  timeoutMs?: number;
169
202
  };
170
203
  }
@@ -175,6 +208,7 @@ function parseRuntimeFromStateBlob(raw: unknown) {
175
208
  args?: unknown;
176
209
  cwd?: unknown;
177
210
  env?: unknown;
211
+ model?: unknown;
178
212
  timeoutMs?: unknown;
179
213
  };
180
214
  };
@@ -184,6 +218,7 @@ function parseRuntimeFromStateBlob(raw: unknown) {
184
218
  args: Array.isArray(runtime.args) ? runtime.args.map((entry) => String(entry)) : undefined,
185
219
  cwd: typeof runtime.cwd === "string" ? runtime.cwd : undefined,
186
220
  env: toRecord(runtime.env),
221
+ model: typeof runtime.model === "string" && runtime.model.trim().length > 0 ? runtime.model.trim() : undefined,
187
222
  timeoutMs: toNumber(runtime.timeoutMs)
188
223
  };
189
224
  } catch {
@@ -1,4 +1,3 @@
1
- import { getAdapterModels } from "bopodev-agent-sdk";
2
1
  import type { NormalizedRuntimeConfig } from "./agent-config";
3
2
 
4
3
  export async function resolveOpencodeRuntimeModel(
@@ -6,30 +5,7 @@ export async function resolveOpencodeRuntimeModel(
6
5
  runtimeConfig: NormalizedRuntimeConfig
7
6
  ): Promise<string | undefined> {
8
7
  if (providerType !== "opencode") {
9
- return runtimeConfig.runtimeModel;
8
+ return runtimeConfig.runtimeModel?.trim() || undefined;
10
9
  }
11
- if (runtimeConfig.runtimeModel?.trim()) {
12
- return runtimeConfig.runtimeModel.trim();
13
- }
14
-
15
- const configured =
16
- process.env.BOPO_OPENCODE_MODEL?.trim() ||
17
- process.env.OPENCODE_MODEL?.trim() ||
18
- undefined;
19
- try {
20
- const discovered = await getAdapterModels("opencode", {
21
- command: runtimeConfig.runtimeCommand,
22
- cwd: runtimeConfig.runtimeCwd,
23
- env: runtimeConfig.runtimeEnv
24
- });
25
- if (configured && discovered.some((entry) => entry.id === configured)) {
26
- return configured;
27
- }
28
- if (discovered.length > 0) {
29
- return discovered[0]!.id;
30
- }
31
- } catch {
32
- // Fall back to configured env default when discovery is unavailable.
33
- }
34
- return configured;
10
+ return runtimeConfig.runtimeModel?.trim() || undefined;
35
11
  }
@@ -22,6 +22,7 @@ import { sendError, sendOk } from "../http";
22
22
  import {
23
23
  normalizeRuntimeConfig,
24
24
  parseRuntimeConfigFromAgentRow,
25
+ resolveRuntimeModelForProvider,
25
26
  requiresRuntimeCwd,
26
27
  runtimeConfigToDb,
27
28
  runtimeConfigToStateBlobPatch
@@ -70,6 +71,7 @@ const runtimePreflightSchema = z.object({
70
71
  "codex",
71
72
  "cursor",
72
73
  "opencode",
74
+ "gemini_cli",
73
75
  "openai_api",
74
76
  "anthropic_api",
75
77
  "http",
@@ -122,6 +124,17 @@ function toAgentResponse(agent: Record<string, unknown>) {
122
124
  };
123
125
  }
124
126
 
127
+ function providerRequiresNamedModel(providerType: string) {
128
+ return providerType !== "http" && providerType !== "shell";
129
+ }
130
+
131
+ function ensureNamedRuntimeModel(providerType: string, runtimeModel: string | undefined) {
132
+ if (!providerRequiresNamedModel(providerType)) {
133
+ return true;
134
+ }
135
+ return hasText(runtimeModel);
136
+ }
137
+
125
138
  export function createAgentsRouter(ctx: AppContext) {
126
139
  const router = Router();
127
140
  router.use(requireCompanyScope);
@@ -159,6 +172,7 @@ export function createAgentsRouter(ctx: AppContext) {
159
172
  | "codex"
160
173
  | "cursor"
161
174
  | "opencode"
175
+ | "gemini_cli"
162
176
  | "openai_api"
163
177
  | "anthropic_api"
164
178
  | "http"
@@ -265,6 +279,10 @@ export function createAgentsRouter(ctx: AppContext) {
265
279
  defaultRuntimeCwd
266
280
  });
267
281
  runtimeConfig.runtimeModel = await resolveOpencodeRuntimeModel(parsed.data.providerType, runtimeConfig);
282
+ runtimeConfig.runtimeModel = resolveRuntimeModelForProvider(parsed.data.providerType, runtimeConfig.runtimeModel);
283
+ if (!ensureNamedRuntimeModel(parsed.data.providerType, runtimeConfig.runtimeModel)) {
284
+ return sendError(res, "A named runtime model is required for this provider.", 422);
285
+ }
268
286
  if (requiresRuntimeCwd(parsed.data.providerType) && !hasText(runtimeConfig.runtimeCwd)) {
269
287
  return sendError(res, "Runtime working directory is required for this runtime provider.", 422);
270
288
  }
@@ -397,6 +415,10 @@ export function createAgentsRouter(ctx: AppContext) {
397
415
  : {})
398
416
  };
399
417
  nextRuntime.runtimeModel = await resolveOpencodeRuntimeModel(effectiveProviderType, nextRuntime);
418
+ nextRuntime.runtimeModel = resolveRuntimeModelForProvider(effectiveProviderType, nextRuntime.runtimeModel);
419
+ if (!ensureNamedRuntimeModel(effectiveProviderType, nextRuntime.runtimeModel)) {
420
+ return sendError(res, "A named runtime model is required for this provider.", 422);
421
+ }
400
422
  if (!nextRuntime.runtimeCwd && defaultRuntimeCwd) {
401
423
  nextRuntime.runtimeCwd = defaultRuntimeCwd;
402
424
  }
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { createCompany, deleteCompany, listCompanies, updateCompany } from "bopodev-db";
4
4
  import type { AppContext } from "../context";
5
5
  import { sendError, sendOk } from "../http";
6
+ import { ensureCompanyModelPricingDefaults } from "../services/model-pricing";
6
7
  import { ensureCompanyBuiltinPluginDefaults } from "../services/plugin-runtime";
7
8
 
8
9
  const createCompanySchema = z.object({
@@ -32,6 +33,7 @@ export function createCompaniesRouter(ctx: AppContext) {
32
33
  }
33
34
  const company = await createCompany(ctx.db, parsed.data);
34
35
  await ensureCompanyBuiltinPluginDefaults(ctx.db, company.id);
36
+ await ensureCompanyModelPricingDefaults(ctx.db, company.id);
35
37
  return sendOk(res, company);
36
38
  });
37
39
 
@@ -1,4 +1,5 @@
1
1
  import { Router } from "express";
2
+ import { z } from "zod";
2
3
  import {
3
4
  getHeartbeatRun,
4
5
  listAgents,
@@ -6,7 +7,9 @@ import {
6
7
  listCostEntries,
7
8
  listHeartbeatRunMessages,
8
9
  listHeartbeatRuns,
9
- listPluginRuns
10
+ listModelPricing,
11
+ listPluginRuns,
12
+ upsertModelPricing
10
13
  } from "bopodev-db";
11
14
  import type { AppContext } from "../context";
12
15
  import { sendError, sendOk } from "../http";
@@ -39,6 +42,52 @@ export function createObservabilityRouter(ctx: AppContext) {
39
42
  );
40
43
  });
41
44
 
45
+ const modelPricingUpdateSchema = z.object({
46
+ providerType: z.string().min(1),
47
+ modelId: z.string().min(1),
48
+ displayName: z.string().min(1).optional(),
49
+ inputUsdPer1M: z.number().min(0),
50
+ outputUsdPer1M: z.number().min(0),
51
+ currency: z.string().min(1).optional()
52
+ });
53
+
54
+ router.get("/models/pricing", async (req, res) => {
55
+ const rows = await listModelPricing(ctx.db, req.companyId!);
56
+ return sendOk(
57
+ res,
58
+ rows.map((row) => ({
59
+ companyId: row.companyId,
60
+ providerType: row.providerType,
61
+ modelId: row.modelId,
62
+ displayName: row.displayName,
63
+ inputUsdPer1M: typeof row.inputUsdPer1M === "number" ? row.inputUsdPer1M : Number(row.inputUsdPer1M ?? 0),
64
+ outputUsdPer1M: typeof row.outputUsdPer1M === "number" ? row.outputUsdPer1M : Number(row.outputUsdPer1M ?? 0),
65
+ currency: row.currency,
66
+ updatedAt: row.updatedAt?.toISOString?.() ?? null,
67
+ updatedBy: row.updatedBy ?? null
68
+ }))
69
+ );
70
+ });
71
+
72
+ router.put("/models/pricing", async (req, res) => {
73
+ const parsed = modelPricingUpdateSchema.safeParse(req.body);
74
+ if (!parsed.success) {
75
+ return sendError(res, parsed.error.message, 422);
76
+ }
77
+ const payload = parsed.data;
78
+ await upsertModelPricing(ctx.db, {
79
+ companyId: req.companyId!,
80
+ providerType: payload.providerType,
81
+ modelId: payload.modelId,
82
+ displayName: payload.displayName ?? null,
83
+ inputUsdPer1M: payload.inputUsdPer1M.toFixed(6),
84
+ outputUsdPer1M: payload.outputUsdPer1M.toFixed(6),
85
+ currency: payload.currency ?? "USD",
86
+ updatedBy: req.actor?.id ?? null
87
+ });
88
+ return sendOk(res, { ok: true });
89
+ });
90
+
42
91
  router.get("/heartbeats", async (req, res) => {
43
92
  const companyId = req.companyId!;
44
93
  const rawLimit = Number(req.query.limit ?? 100);
@@ -17,6 +17,7 @@ import {
17
17
  } from "bopodev-db";
18
18
  import { normalizeRuntimeConfig, runtimeConfigToDb, runtimeConfigToStateBlobPatch } from "../lib/agent-config";
19
19
  import { resolveDefaultRuntimeCwdForCompany } from "../lib/workspace-policy";
20
+ import { ensureCompanyModelPricingDefaults } from "../services/model-pricing";
20
21
 
21
22
  export interface OnboardSeedSummary {
22
23
  companyId: string;
@@ -30,7 +31,7 @@ export interface OnboardSeedSummary {
30
31
  const DEFAULT_COMPANY_NAME_ENV = "BOPO_DEFAULT_COMPANY_NAME";
31
32
  const DEFAULT_COMPANY_ID_ENV = "BOPO_DEFAULT_COMPANY_ID";
32
33
  const DEFAULT_AGENT_PROVIDER_ENV = "BOPO_DEFAULT_AGENT_PROVIDER";
33
- type AgentProvider = "codex" | "claude_code" | "cursor" | "opencode" | "openai_api" | "anthropic_api" | "shell";
34
+ type AgentProvider = "codex" | "claude_code" | "cursor" | "gemini_cli" | "opencode" | "openai_api" | "anthropic_api" | "shell";
34
35
  const CEO_BOOTSTRAP_SUMMARY = "ceo bootstrap heartbeat";
35
36
  const STARTUP_PROJECT_NAME = "Leadership Setup";
36
37
  const CEO_STARTUP_TASK_TITLE = "Set up CEO operating files and hire founding engineer";
@@ -135,6 +136,7 @@ export async function ensureOnboardingSeed(input: {
135
136
  ceoId
136
137
  });
137
138
  }
139
+ await ensureCompanyModelPricingDefaults(db, companyId);
138
140
 
139
141
  return {
140
142
  companyId,
@@ -259,6 +261,7 @@ function parseAgentProvider(value: unknown): AgentProvider | null {
259
261
  value === "codex" ||
260
262
  value === "claude_code" ||
261
263
  value === "cursor" ||
264
+ value === "gemini_cli" ||
262
265
  value === "opencode" ||
263
266
  value === "openai_api" ||
264
267
  value === "anthropic_api" ||
@@ -18,6 +18,7 @@ import {
18
18
  } from "bopodev-db";
19
19
  import {
20
20
  normalizeRuntimeConfig,
21
+ resolveRuntimeModelForProvider,
21
22
  requiresRuntimeCwd,
22
23
  runtimeConfigToDb,
23
24
  runtimeConfigToStateBlobPatch
@@ -195,6 +196,10 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
195
196
  defaultRuntimeCwd
196
197
  });
197
198
  runtimeConfig.runtimeModel = await resolveOpencodeRuntimeModel(parsed.data.providerType, runtimeConfig);
199
+ runtimeConfig.runtimeModel = resolveRuntimeModelForProvider(parsed.data.providerType, runtimeConfig.runtimeModel);
200
+ if (providerRequiresNamedModel(parsed.data.providerType) && !hasText(runtimeConfig.runtimeModel)) {
201
+ throw new GovernanceError("Approval payload for agent hiring must include a named runtime model.");
202
+ }
198
203
  if (requiresRuntimeCwd(parsed.data.providerType) && !hasText(runtimeConfig.runtimeCwd)) {
199
204
  throw new GovernanceError("Approval payload for agent hiring is missing runtime working directory.");
200
205
  }
@@ -348,6 +353,10 @@ async function applyApprovalAction(db: BopoDb, companyId: string, action: string
348
353
  throw new GovernanceError(`Unsupported approval action: ${action}`);
349
354
  }
350
355
 
356
+ function providerRequiresNamedModel(providerType: string) {
357
+ return providerType !== "http" && providerType !== "shell";
358
+ }
359
+
351
360
  function parsePayload(payloadJson: string) {
352
361
  try {
353
362
  const parsed = JSON.parse(payloadJson) as unknown;
@@ -32,6 +32,7 @@ import { createHeartbeatRunsRealtimeEvent } from "../realtime/heartbeat-runs";
32
32
  import { publishOfficeOccupantForAgent } from "../realtime/office-space";
33
33
  import { checkAgentBudget } from "./budget-service";
34
34
  import { appendDurableFact, loadAgentMemoryContext, persistHeartbeatMemory } from "./memory-file-service";
35
+ import { calculateModelPricedUsdCost } from "./model-pricing";
35
36
  import { runPluginHook } from "./plugin-runtime";
36
37
 
37
38
  type HeartbeatRunTrigger = "manual" | "scheduler";
@@ -41,6 +42,7 @@ type HeartbeatProviderType =
41
42
  | "codex"
42
43
  | "cursor"
43
44
  | "opencode"
45
+ | "gemini_cli"
44
46
  | "openai_api"
45
47
  | "anthropic_api"
46
48
  | "http"
@@ -373,6 +375,8 @@ export async function runHeartbeatForAgent(
373
375
  let memoryContext: HeartbeatContext["memoryContext"] | undefined;
374
376
  let stateParseError: string | null = null;
375
377
  let runtimeLaunchSummary: ReturnType<typeof summarizeRuntimeLaunch> | null = null;
378
+ let primaryIssueId: string | null = null;
379
+ let primaryProjectId: string | null = null;
376
380
  let transcriptSequence = 0;
377
381
  let transcriptWriteQueue = Promise.resolve();
378
382
  let transcriptLiveCount = 0;
@@ -499,6 +503,8 @@ export async function runHeartbeatForAgent(
499
503
  });
500
504
  const workItems = await claimIssuesForAgent(db, companyId, agentId, runId);
501
505
  issueIds = workItems.map((item) => item.id);
506
+ primaryIssueId = workItems[0]?.id ?? null;
507
+ primaryProjectId = workItems[0]?.project_id ?? null;
502
508
  await runPluginHook(db, {
503
509
  hook: "afterClaim",
504
510
  context: {
@@ -767,6 +773,16 @@ export async function runHeartbeatForAgent(
767
773
  externalAbortSignal: activeRunAbort.signal
768
774
  });
769
775
  executionSummary = execution.summary;
776
+ const normalizedUsage = execution.usage ?? {
777
+ inputTokens: Math.max(0, execution.tokenInput),
778
+ cachedInputTokens: 0,
779
+ outputTokens: Math.max(0, execution.tokenOutput),
780
+ ...(execution.usdCost > 0 ? { costUsd: execution.usdCost } : {}),
781
+ ...(execution.summary ? { summary: execution.summary } : {})
782
+ };
783
+ const effectiveTokenInput = normalizedUsage.inputTokens + normalizedUsage.cachedInputTokens;
784
+ const effectiveTokenOutput = normalizedUsage.outputTokens;
785
+ const effectiveRuntimeUsdCost = normalizedUsage.costUsd ?? (execution.usdCost > 0 ? execution.usdCost : 0);
770
786
  const afterAdapterHook = await runPluginHook(db, {
771
787
  hook: "afterAdapterExecute",
772
788
  context: {
@@ -787,6 +803,29 @@ export async function runHeartbeatForAgent(
787
803
  }
788
804
  emitCanonicalResultEvent(executionSummary, "completed");
789
805
  executionTrace = execution.trace ?? null;
806
+ const runtimeModelId = resolveRuntimeModelId({
807
+ runtimeModel: persistedRuntime.runtimeModel,
808
+ stateBlob: agent.stateBlob
809
+ });
810
+ const effectivePricingProviderType = execution.pricingProviderType ?? agent.providerType;
811
+ const effectivePricingModelId = execution.pricingModelId ?? runtimeModelId;
812
+ const costDecision = await appendFinishedRunCostEntry({
813
+ db,
814
+ companyId,
815
+ providerType: agent.providerType,
816
+ runtimeModelId: effectivePricingModelId ?? runtimeModelId,
817
+ pricingProviderType: effectivePricingProviderType,
818
+ pricingModelId: effectivePricingModelId,
819
+ tokenInput: effectiveTokenInput,
820
+ tokenOutput: effectiveTokenOutput,
821
+ runtimeUsdCost: effectiveRuntimeUsdCost,
822
+ failureType: readTraceString(execution.trace, "failureType"),
823
+ issueId: primaryIssueId,
824
+ projectId: primaryProjectId,
825
+ agentId,
826
+ status: execution.status
827
+ });
828
+ const executionUsdCost = costDecision.usdCost;
790
829
  const parsedOutcome = ExecutionOutcomeSchema.safeParse(execution.outcome);
791
830
  executionOutcome = parsedOutcome.success ? parsedOutcome.data : null;
792
831
  const persistedMemory = await persistHeartbeatMemory({
@@ -835,32 +874,20 @@ export async function runHeartbeatForAgent(
835
874
  }
836
875
  }
837
876
 
838
- if (execution.tokenInput > 0 || execution.tokenOutput > 0 || execution.usdCost > 0) {
839
- await appendCost(db, {
840
- companyId,
841
- providerType: agent.providerType,
842
- tokenInput: execution.tokenInput,
843
- tokenOutput: execution.tokenOutput,
844
- usdCost: execution.usdCost.toFixed(6),
845
- issueId: workItems[0]?.id ?? null,
846
- projectId: workItems[0]?.project_id ?? null,
847
- agentId
848
- });
849
- }
850
-
851
877
  if (
852
878
  execution.nextState ||
853
- execution.usdCost > 0 ||
854
- execution.tokenInput > 0 ||
855
- execution.tokenOutput > 0 ||
879
+ executionUsdCost > 0 ||
880
+ effectiveTokenInput > 0 ||
881
+ effectiveTokenOutput > 0 ||
856
882
  execution.status !== "skipped"
857
883
  ) {
858
884
  await db
859
885
  .update(agents)
860
886
  .set({
861
887
  stateBlob: JSON.stringify(execution.nextState ?? state),
862
- usedBudgetUsd: sql`${agents.usedBudgetUsd} + ${execution.usdCost}`,
863
- tokenUsage: sql`${agents.tokenUsage} + ${execution.tokenInput + execution.tokenOutput}`,
888
+ runtimeModel: effectivePricingModelId ?? persistedRuntime.runtimeModel ?? null,
889
+ usedBudgetUsd: sql`${agents.usedBudgetUsd} + ${executionUsdCost}`,
890
+ tokenUsage: sql`${agents.tokenUsage} + ${effectiveTokenInput + effectiveTokenOutput}`,
864
891
  updatedAt: new Date()
865
892
  })
866
893
  .where(and(eq(agents.companyId, companyId), eq(agents.id, agentId)));
@@ -868,9 +895,9 @@ export async function runHeartbeatForAgent(
868
895
 
869
896
  const shouldAdvanceIssuesToReview = shouldPromoteIssuesToReview({
870
897
  summary: execution.summary,
871
- tokenInput: execution.tokenInput,
872
- tokenOutput: execution.tokenOutput,
873
- usdCost: execution.usdCost,
898
+ tokenInput: effectiveTokenInput,
899
+ tokenOutput: effectiveTokenOutput,
900
+ usdCost: executionUsdCost,
874
901
  trace: executionTrace,
875
902
  outcome: executionOutcome
876
903
  });
@@ -914,9 +941,9 @@ export async function runHeartbeatForAgent(
914
941
  summary: execution.summary,
915
942
  outcome: executionOutcome,
916
943
  usage: {
917
- tokenInput: execution.tokenInput,
918
- tokenOutput: execution.tokenOutput,
919
- usdCost: execution.usdCost
944
+ tokenInput: effectiveTokenInput,
945
+ tokenOutput: effectiveTokenOutput,
946
+ usdCost: executionUsdCost
920
947
  }
921
948
  }
922
949
  });
@@ -1047,9 +1074,9 @@ export async function runHeartbeatForAgent(
1047
1074
  outcome: executionOutcome,
1048
1075
  issueIds,
1049
1076
  usage: {
1050
- tokenInput: execution.tokenInput,
1051
- tokenOutput: execution.tokenOutput,
1052
- usdCost: execution.usdCost,
1077
+ tokenInput: effectiveTokenInput,
1078
+ tokenOutput: effectiveTokenOutput,
1079
+ usdCost: executionUsdCost,
1053
1080
  source: readTraceString(execution.trace, "usageSource") ?? "unknown"
1054
1081
  },
1055
1082
  trace: execution.trace ?? null,
@@ -1109,6 +1136,24 @@ export async function runHeartbeatForAgent(
1109
1136
  cwd: runtimeLaunchSummary.cwd ?? null
1110
1137
  };
1111
1138
  }
1139
+ const runtimeModelId = resolveRuntimeModelId({
1140
+ runtimeModel: persistedRuntime.runtimeModel,
1141
+ stateBlob: agent.stateBlob
1142
+ });
1143
+ await appendFinishedRunCostEntry({
1144
+ db,
1145
+ companyId,
1146
+ providerType: agent.providerType,
1147
+ runtimeModelId,
1148
+ pricingProviderType: agent.providerType,
1149
+ pricingModelId: runtimeModelId,
1150
+ tokenInput: 0,
1151
+ tokenOutput: 0,
1152
+ issueId: primaryIssueId,
1153
+ projectId: primaryProjectId,
1154
+ agentId,
1155
+ status: "failed"
1156
+ });
1112
1157
  await db
1113
1158
  .update(heartbeatRuns)
1114
1159
  .set({
@@ -2122,6 +2167,7 @@ function buildHeartbeatRuntimeEnv(input: {
2122
2167
  BOPODEV_RUN_ID: input.heartbeatRunId,
2123
2168
  BOPODEV_FORCE_MANAGED_CODEX_HOME: "false",
2124
2169
  BOPODEV_API_BASE_URL: apiBaseUrl,
2170
+ BOPODEV_API_URL: apiBaseUrl,
2125
2171
  BOPODEV_ACTOR_TYPE: "agent",
2126
2172
  BOPODEV_ACTOR_ID: input.agentId,
2127
2173
  BOPODEV_ACTOR_COMPANIES: input.companyId,
@@ -2135,7 +2181,9 @@ function buildHeartbeatRuntimeEnv(input: {
2135
2181
  }
2136
2182
 
2137
2183
  function resolveControlPlaneApiBaseUrl() {
2138
- const configured = resolveControlPlaneProcessEnv("API_BASE_URL") ?? process.env.NEXT_PUBLIC_API_URL;
2184
+ // Agent runtimes must call the control-plane API directly; do not inherit
2185
+ // browser-facing NEXT_PUBLIC_API_URL (can point to non-runtime endpoints).
2186
+ const configured = resolveControlPlaneProcessEnv("API_BASE_URL");
2139
2187
  return normalizeControlPlaneApiBaseUrl(configured) ?? "http://127.0.0.1:4020";
2140
2188
  }
2141
2189
 
@@ -2248,7 +2296,8 @@ function shouldRequireControlPlanePreflight(
2248
2296
  providerType === "codex" ||
2249
2297
  providerType === "claude_code" ||
2250
2298
  providerType === "cursor" ||
2251
- providerType === "opencode"
2299
+ providerType === "opencode" ||
2300
+ providerType === "gemini_cli"
2252
2301
  );
2253
2302
  }
2254
2303
 
@@ -2367,6 +2416,87 @@ function resolveControlPlaneHeaders(runtimeEnv: Record<string, string>):
2367
2416
  return { ok: true, headers: jsonHeadersResult.data };
2368
2417
  }
2369
2418
 
2419
+ function resolveRuntimeModelId(input: { runtimeModel?: string; stateBlob?: string | null }) {
2420
+ const runtimeModel = input.runtimeModel?.trim();
2421
+ if (runtimeModel) {
2422
+ return runtimeModel;
2423
+ }
2424
+ if (!input.stateBlob) {
2425
+ return null;
2426
+ }
2427
+ try {
2428
+ const parsed = JSON.parse(input.stateBlob) as { runtime?: { model?: unknown } };
2429
+ const modelId = parsed.runtime?.model;
2430
+ return typeof modelId === "string" && modelId.trim().length > 0 ? modelId.trim() : null;
2431
+ } catch {
2432
+ return null;
2433
+ }
2434
+ }
2435
+
2436
+ async function appendFinishedRunCostEntry(input: {
2437
+ db: BopoDb;
2438
+ companyId: string;
2439
+ providerType: string;
2440
+ runtimeModelId: string | null;
2441
+ pricingProviderType?: string | null;
2442
+ pricingModelId?: string | null;
2443
+ tokenInput: number;
2444
+ tokenOutput: number;
2445
+ runtimeUsdCost?: number;
2446
+ failureType?: string | null;
2447
+ issueId?: string | null;
2448
+ projectId?: string | null;
2449
+ agentId?: string | null;
2450
+ status: "ok" | "failed" | "skipped";
2451
+ }) {
2452
+ const pricingDecision = await calculateModelPricedUsdCost({
2453
+ db: input.db,
2454
+ companyId: input.companyId,
2455
+ providerType: input.providerType,
2456
+ pricingProviderType: input.pricingProviderType ?? input.providerType,
2457
+ modelId: input.pricingModelId ?? input.runtimeModelId,
2458
+ tokenInput: input.tokenInput,
2459
+ tokenOutput: input.tokenOutput
2460
+ });
2461
+
2462
+ const shouldPersist = input.status === "ok" || input.status === "failed";
2463
+ const runtimeUsdCost = Math.max(0, input.runtimeUsdCost ?? 0);
2464
+ const pricedUsdCost = Math.max(0, pricingDecision.usdCost);
2465
+ const shouldUseRuntimeUsdCost = pricedUsdCost <= 0 && runtimeUsdCost > 0;
2466
+ const baseUsdCost = shouldUseRuntimeUsdCost ? runtimeUsdCost : pricedUsdCost;
2467
+ const effectiveUsdCost =
2468
+ baseUsdCost > 0
2469
+ ? baseUsdCost
2470
+ : input.status === "failed" && input.failureType !== "spawn_error"
2471
+ ? 0.000001
2472
+ : 0;
2473
+ const effectivePricingSource = pricingDecision.pricingSource;
2474
+ const shouldPersistWithUsage =
2475
+ shouldPersist && (input.tokenInput > 0 || input.tokenOutput > 0 || effectiveUsdCost > 0);
2476
+ if (shouldPersistWithUsage) {
2477
+ await appendCost(input.db, {
2478
+ companyId: input.companyId,
2479
+ providerType: input.providerType,
2480
+ runtimeModelId: input.runtimeModelId,
2481
+ pricingProviderType: pricingDecision.pricingProviderType,
2482
+ pricingModelId: pricingDecision.pricingModelId,
2483
+ pricingSource: effectivePricingSource,
2484
+ tokenInput: input.tokenInput,
2485
+ tokenOutput: input.tokenOutput,
2486
+ usdCost: effectiveUsdCost.toFixed(6),
2487
+ issueId: input.issueId ?? null,
2488
+ projectId: input.projectId ?? null,
2489
+ agentId: input.agentId ?? null
2490
+ });
2491
+ }
2492
+
2493
+ return {
2494
+ ...pricingDecision,
2495
+ pricingSource: effectivePricingSource,
2496
+ usdCost: effectiveUsdCost
2497
+ };
2498
+ }
2499
+
2370
2500
  function isHeartbeatDue(cronExpression: string, lastRunAt: Date | null, now: Date) {
2371
2501
  const normalizedNow = truncateToMinute(now);
2372
2502
  if (!matchesCronExpression(cronExpression, normalizedNow)) {
@@ -0,0 +1,217 @@
1
+ import type { BopoDb } from "bopodev-db";
2
+ import { getModelPricing, upsertModelPricing } from "bopodev-db";
3
+
4
+ type SeedModelPricingRow = {
5
+ providerType: "openai_api" | "anthropic_api" | "gemini_api";
6
+ modelId: string;
7
+ displayName: string;
8
+ inputUsdPer1M: number;
9
+ outputUsdPer1M: number;
10
+ };
11
+
12
+ const OPENAI_MODEL_BASE_PRICES: Array<{
13
+ modelId: string;
14
+ displayName: string;
15
+ inputUsdPer1M: number;
16
+ outputUsdPer1M: number;
17
+ }> = [
18
+ { modelId: "gpt-5.2", displayName: "GPT-5.2", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
19
+ { modelId: "gpt-5.1", displayName: "GPT-5.1", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
20
+ { modelId: "gpt-5", displayName: "GPT-5", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
21
+ { modelId: "gpt-5-mini", displayName: "GPT-5 Mini", inputUsdPer1M: 0.25, outputUsdPer1M: 2 },
22
+ { modelId: "gpt-5-nano", displayName: "GPT-5 Nano", inputUsdPer1M: 0.05, outputUsdPer1M: 0.4 },
23
+ { modelId: "gpt-5.3-chat-latest", displayName: "GPT-5.3 Chat Latest", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
24
+ { modelId: "gpt-5.2-chat-latest", displayName: "GPT-5.2 Chat Latest", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
25
+ { modelId: "gpt-5.1-chat-latest", displayName: "GPT-5.1 Chat Latest", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
26
+ { modelId: "gpt-5-chat-latest", displayName: "GPT-5 Chat Latest", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
27
+ { modelId: "gpt-5.4", displayName: "GPT-5.4", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
28
+ { modelId: "gpt-5.3-codex", displayName: "GPT-5.3 Codex", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
29
+ { modelId: "gpt-5.3-codex-spark", displayName: "GPT-5.3 Codex Spark", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
30
+ { modelId: "gpt-5.2-codex", displayName: "GPT-5.2 Codex", inputUsdPer1M: 1.75, outputUsdPer1M: 14 },
31
+ { modelId: "gpt-5.1-codex-max", displayName: "GPT-5.1 Codex Max", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
32
+ { modelId: "gpt-5.1-codex-mini", displayName: "GPT-5.1 Codex Mini", inputUsdPer1M: 0.25, outputUsdPer1M: 2 },
33
+ { modelId: "gpt-5.1-codex", displayName: "GPT-5.1 Codex", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
34
+ { modelId: "gpt-5-codex", displayName: "GPT-5 Codex", inputUsdPer1M: 1.25, outputUsdPer1M: 10 },
35
+ { modelId: "gpt-5.2-pro", displayName: "GPT-5.2 Pro", inputUsdPer1M: 21, outputUsdPer1M: 168 },
36
+ { modelId: "gpt-5-pro", displayName: "GPT-5 Pro", inputUsdPer1M: 15, outputUsdPer1M: 120 },
37
+ { modelId: "gpt-4.1", displayName: "GPT-4.1", inputUsdPer1M: 2, outputUsdPer1M: 8 },
38
+ { modelId: "gpt-4.1-mini", displayName: "GPT-4.1 Mini", inputUsdPer1M: 0.4, outputUsdPer1M: 1.6 },
39
+ { modelId: "gpt-4.1-nano", displayName: "GPT-4.1 Nano", inputUsdPer1M: 0.1, outputUsdPer1M: 0.4 },
40
+ { modelId: "gpt-4o", displayName: "GPT-4o", inputUsdPer1M: 2.5, outputUsdPer1M: 10 },
41
+ { modelId: "gpt-4o-2024-05-13", displayName: "GPT-4o 2024-05-13", inputUsdPer1M: 5, outputUsdPer1M: 15 },
42
+ { modelId: "gpt-4o-mini", displayName: "GPT-4o Mini", inputUsdPer1M: 0.15, outputUsdPer1M: 0.6 },
43
+ { modelId: "gpt-realtime", displayName: "GPT Realtime", inputUsdPer1M: 4, outputUsdPer1M: 16 },
44
+ { modelId: "gpt-realtime-1.5", displayName: "GPT Realtime 1.5", inputUsdPer1M: 4, outputUsdPer1M: 16 },
45
+ { modelId: "gpt-realtime-mini", displayName: "GPT Realtime Mini", inputUsdPer1M: 0.6, outputUsdPer1M: 2.4 },
46
+ { modelId: "gpt-4o-realtime-preview", displayName: "GPT-4o Realtime Preview", inputUsdPer1M: 5, outputUsdPer1M: 20 },
47
+ { modelId: "gpt-4o-mini-realtime-preview", displayName: "GPT-4o Mini Realtime Preview", inputUsdPer1M: 0.6, outputUsdPer1M: 2.4 },
48
+ { modelId: "gpt-audio", displayName: "GPT Audio", inputUsdPer1M: 2.5, outputUsdPer1M: 10 },
49
+ { modelId: "gpt-audio-1.5", displayName: "GPT Audio 1.5", inputUsdPer1M: 2.5, outputUsdPer1M: 10 },
50
+ { modelId: "gpt-audio-mini", displayName: "GPT Audio Mini", inputUsdPer1M: 0.6, outputUsdPer1M: 2.4 },
51
+ { modelId: "gpt-4o-audio-preview", displayName: "GPT-4o Audio Preview", inputUsdPer1M: 2.5, outputUsdPer1M: 10 },
52
+ { modelId: "gpt-4o-mini-audio-preview", displayName: "GPT-4o Mini Audio Preview", inputUsdPer1M: 0.15, outputUsdPer1M: 0.6 },
53
+ { modelId: "o1", displayName: "o1", inputUsdPer1M: 15, outputUsdPer1M: 60 },
54
+ { modelId: "o1-pro", displayName: "o1-pro", inputUsdPer1M: 150, outputUsdPer1M: 600 },
55
+ { modelId: "o3-pro", displayName: "o3-pro", inputUsdPer1M: 20, outputUsdPer1M: 80 },
56
+ { modelId: "o3", displayName: "o3", inputUsdPer1M: 2, outputUsdPer1M: 8 },
57
+ { modelId: "o3-deep-research", displayName: "o3 Deep Research", inputUsdPer1M: 10, outputUsdPer1M: 40 },
58
+ { modelId: "o4-mini", displayName: "o4-mini", inputUsdPer1M: 1.1, outputUsdPer1M: 4.4 },
59
+ { modelId: "o4-mini-deep-research", displayName: "o4-mini Deep Research", inputUsdPer1M: 2, outputUsdPer1M: 8 },
60
+ { modelId: "o3-mini", displayName: "o3-mini", inputUsdPer1M: 1.1, outputUsdPer1M: 4.4 }
61
+ ];
62
+
63
+ const CLAUDE_MODEL_BASE_PRICES: Array<{
64
+ modelId: string;
65
+ displayName: string;
66
+ inputUsdPer1M: number;
67
+ outputUsdPer1M: number;
68
+ }> = [
69
+ // Runtime ids currently used in provider model selectors.
70
+ { modelId: "claude-opus-4-6", displayName: "Claude Opus 4.6", inputUsdPer1M: 5, outputUsdPer1M: 25 },
71
+ { modelId: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", inputUsdPer1M: 3, outputUsdPer1M: 15 },
72
+ { modelId: "claude-sonnet-4-6-1m", displayName: "Claude Sonnet 4.6 (1M context)", inputUsdPer1M: 6, outputUsdPer1M: 22.5 },
73
+ { modelId: "claude-opus-4-6-1m", displayName: "Claude Opus 4.6 (1M context)", inputUsdPer1M: 10, outputUsdPer1M: 37.5 },
74
+ { modelId: "claude-haiku-4-5", displayName: "Claude Haiku 4.5", inputUsdPer1M: 1, outputUsdPer1M: 5 },
75
+ // Legacy / alternate ids
76
+ { modelId: "claude-sonnet-4-5-20250929", displayName: "Claude Sonnet 4.5", inputUsdPer1M: 3, outputUsdPer1M: 15 },
77
+ { modelId: "claude-haiku-4-5-20251001", displayName: "Claude Haiku 4.5", inputUsdPer1M: 1, outputUsdPer1M: 5 },
78
+ { modelId: "claude-opus-4.6", displayName: "Claude Opus 4.6", inputUsdPer1M: 5, outputUsdPer1M: 25 },
79
+ { modelId: "claude-opus-4.5", displayName: "Claude Opus 4.5", inputUsdPer1M: 5, outputUsdPer1M: 25 },
80
+ { modelId: "claude-opus-4.1", displayName: "Claude Opus 4.1", inputUsdPer1M: 15, outputUsdPer1M: 75 },
81
+ { modelId: "claude-opus-4", displayName: "Claude Opus 4", inputUsdPer1M: 15, outputUsdPer1M: 75 },
82
+ { modelId: "claude-sonnet-4.6", displayName: "Claude Sonnet 4.6", inputUsdPer1M: 3, outputUsdPer1M: 15 },
83
+ { modelId: "claude-sonnet-4.5", displayName: "Claude Sonnet 4.5", inputUsdPer1M: 3, outputUsdPer1M: 15 },
84
+ { modelId: "claude-sonnet-4", displayName: "Claude Sonnet 4", inputUsdPer1M: 3, outputUsdPer1M: 15 },
85
+ { modelId: "claude-sonnet-3.7", displayName: "Claude Sonnet 3.7", inputUsdPer1M: 3, outputUsdPer1M: 15 },
86
+ { modelId: "claude-haiku-4.5", displayName: "Claude Haiku 4.5", inputUsdPer1M: 1, outputUsdPer1M: 5 },
87
+ { modelId: "claude-haiku-3.5", displayName: "Claude Haiku 3.5", inputUsdPer1M: 0.8, outputUsdPer1M: 4 },
88
+ { modelId: "claude-opus-3", displayName: "Claude Opus 3", inputUsdPer1M: 15, outputUsdPer1M: 75 },
89
+ { modelId: "claude-haiku-3", displayName: "Claude Haiku 3", inputUsdPer1M: 0.25, outputUsdPer1M: 1.25 }
90
+ ];
91
+
92
+ const GEMINI_MODEL_BASE_PRICES: Array<{
93
+ modelId: string;
94
+ displayName: string;
95
+ inputUsdPer1M: number;
96
+ outputUsdPer1M: number;
97
+ }> = [
98
+ { modelId: "gemini-3.1-flash-lite", displayName: "Gemini 3.1 Flash Lite", inputUsdPer1M: 0.25, outputUsdPer1M: 1.5 },
99
+ { modelId: "gemini-3-flash", displayName: "Gemini 3 Flash", inputUsdPer1M: 0.5, outputUsdPer1M: 3 },
100
+ { modelId: "gemini-3-pro", displayName: "Gemini 3 Pro", inputUsdPer1M: 2, outputUsdPer1M: 12 },
101
+ { modelId: "gemini-3-pro-200k", displayName: "Gemini 3 Pro (>200k context)", inputUsdPer1M: 4, outputUsdPer1M: 18 },
102
+ { modelId: "gemini-2.5-flash-lite", displayName: "Gemini 2.5 Flash Lite", inputUsdPer1M: 0.1, outputUsdPer1M: 0.4 },
103
+ { modelId: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", inputUsdPer1M: 0.3, outputUsdPer1M: 2.5 },
104
+ { modelId: "gemini-2.5-pro", displayName: "Gemini 2.5 Pro", inputUsdPer1M: 1.25, outputUsdPer1M: 10 }
105
+ ];
106
+
107
+ const DEFAULT_MODEL_PRICING_ROWS: SeedModelPricingRow[] = [
108
+ ...OPENAI_MODEL_BASE_PRICES.map((row) => ({ ...row, providerType: "openai_api" as const })),
109
+ ...CLAUDE_MODEL_BASE_PRICES.map((row) => ({ ...row, providerType: "anthropic_api" as const })),
110
+ ...GEMINI_MODEL_BASE_PRICES.map((row) => ({ ...row, providerType: "gemini_api" as const }))
111
+ ];
112
+
113
+ export async function ensureCompanyModelPricingDefaults(db: BopoDb, companyId: string) {
114
+ for (const row of DEFAULT_MODEL_PRICING_ROWS) {
115
+ await upsertModelPricing(db, {
116
+ companyId,
117
+ providerType: row.providerType,
118
+ modelId: row.modelId,
119
+ displayName: row.displayName,
120
+ inputUsdPer1M: row.inputUsdPer1M.toFixed(6),
121
+ outputUsdPer1M: row.outputUsdPer1M.toFixed(6),
122
+ currency: "USD",
123
+ updatedBy: "system:onboarding-defaults"
124
+ });
125
+ }
126
+ }
127
+
128
+ export async function calculateModelPricedUsdCost(input: {
129
+ db: BopoDb;
130
+ companyId: string;
131
+ providerType: string;
132
+ pricingProviderType?: string | null;
133
+ modelId: string | null;
134
+ tokenInput: number;
135
+ tokenOutput: number;
136
+ }) {
137
+ const normalizedProviderType = (input.pricingProviderType ?? input.providerType).trim();
138
+ const normalizedModelId = input.modelId?.trim() ?? "";
139
+ const canonicalPricingProviderType = resolveCanonicalPricingProvider(normalizedProviderType);
140
+ if (!normalizedModelId || !canonicalPricingProviderType) {
141
+ return {
142
+ usdCost: 0,
143
+ pricingSource: "missing" as const,
144
+ pricingProviderType: canonicalPricingProviderType,
145
+ pricingModelId: normalizedModelId || null
146
+ };
147
+ }
148
+ const pricing = await getModelPricing(input.db, {
149
+ companyId: input.companyId,
150
+ providerType: canonicalPricingProviderType,
151
+ modelId: normalizedModelId
152
+ });
153
+ if (!pricing) {
154
+ return {
155
+ usdCost: 0,
156
+ pricingSource: "missing" as const,
157
+ pricingProviderType: canonicalPricingProviderType,
158
+ pricingModelId: normalizedModelId
159
+ };
160
+ }
161
+ const inputUsdPer1M = Number(pricing.inputUsdPer1M ?? 0);
162
+ const outputUsdPer1M = Number(pricing.outputUsdPer1M ?? 0);
163
+ if (!Number.isFinite(inputUsdPer1M) || !Number.isFinite(outputUsdPer1M)) {
164
+ return {
165
+ usdCost: 0,
166
+ pricingSource: "missing" as const,
167
+ pricingProviderType: canonicalPricingProviderType,
168
+ pricingModelId: normalizedModelId
169
+ };
170
+ }
171
+ const normalizedTokenInput = Math.max(0, input.tokenInput);
172
+ const normalizedTokenOutput = Math.max(0, input.tokenOutput);
173
+ if (normalizedTokenInput === 0 && normalizedTokenOutput === 0) {
174
+ return {
175
+ usdCost: 0,
176
+ pricingSource: "missing" as const,
177
+ pricingProviderType: canonicalPricingProviderType,
178
+ pricingModelId: normalizedModelId
179
+ };
180
+ }
181
+ const computedUsd =
182
+ (normalizedTokenInput / 1_000_000) * inputUsdPer1M +
183
+ (normalizedTokenOutput / 1_000_000) * outputUsdPer1M;
184
+ return {
185
+ usdCost: Number.isFinite(computedUsd) ? computedUsd : 0,
186
+ pricingSource: Number.isFinite(computedUsd) ? ("exact" as const) : ("missing" as const),
187
+ pricingProviderType: canonicalPricingProviderType,
188
+ pricingModelId: normalizedModelId
189
+ };
190
+ }
191
+
192
+ export function resolveCanonicalPricingProvider(providerType: string | null | undefined) {
193
+ const normalizedProvider = providerType?.trim() ?? "";
194
+ if (!normalizedProvider) {
195
+ return null;
196
+ }
197
+ if (
198
+ normalizedProvider === "openai_api" ||
199
+ normalizedProvider === "anthropic_api" ||
200
+ normalizedProvider === "opencode" ||
201
+ normalizedProvider === "gemini_api"
202
+ ) {
203
+ return normalizedProvider;
204
+ }
205
+ if (normalizedProvider === "codex" || normalizedProvider === "cursor") {
206
+ return "openai_api";
207
+ }
208
+ if (normalizedProvider === "claude_code") {
209
+ return "anthropic_api";
210
+ }
211
+ if (normalizedProvider === "gemini_cli") {
212
+ return "gemini_api";
213
+ }
214
+ return null;
215
+ }
216
+
217
+