bopodev-api 0.1.13 → 0.1.14

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.14",
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-db": "0.1.14",
21
+ "bopodev-contracts": "0.1.14",
22
+ "bopodev-agent-sdk": "0.1.14"
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: {
@@ -787,6 +793,27 @@ export async function runHeartbeatForAgent(
787
793
  }
788
794
  emitCanonicalResultEvent(executionSummary, "completed");
789
795
  executionTrace = execution.trace ?? null;
796
+ const runtimeModelId = resolveRuntimeModelId({
797
+ runtimeModel: persistedRuntime.runtimeModel,
798
+ stateBlob: agent.stateBlob
799
+ });
800
+ const effectivePricingProviderType = execution.pricingProviderType ?? agent.providerType;
801
+ const effectivePricingModelId = execution.pricingModelId ?? runtimeModelId;
802
+ const costDecision = await appendFinishedRunCostEntry({
803
+ db,
804
+ companyId,
805
+ providerType: agent.providerType,
806
+ runtimeModelId: effectivePricingModelId ?? runtimeModelId,
807
+ pricingProviderType: effectivePricingProviderType,
808
+ pricingModelId: effectivePricingModelId,
809
+ tokenInput: execution.tokenInput,
810
+ tokenOutput: execution.tokenOutput,
811
+ issueId: primaryIssueId,
812
+ projectId: primaryProjectId,
813
+ agentId,
814
+ status: execution.status
815
+ });
816
+ const executionUsdCost = costDecision.usdCost;
790
817
  const parsedOutcome = ExecutionOutcomeSchema.safeParse(execution.outcome);
791
818
  executionOutcome = parsedOutcome.success ? parsedOutcome.data : null;
792
819
  const persistedMemory = await persistHeartbeatMemory({
@@ -835,22 +862,9 @@ export async function runHeartbeatForAgent(
835
862
  }
836
863
  }
837
864
 
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
865
  if (
852
866
  execution.nextState ||
853
- execution.usdCost > 0 ||
867
+ executionUsdCost > 0 ||
854
868
  execution.tokenInput > 0 ||
855
869
  execution.tokenOutput > 0 ||
856
870
  execution.status !== "skipped"
@@ -859,7 +873,8 @@ export async function runHeartbeatForAgent(
859
873
  .update(agents)
860
874
  .set({
861
875
  stateBlob: JSON.stringify(execution.nextState ?? state),
862
- usedBudgetUsd: sql`${agents.usedBudgetUsd} + ${execution.usdCost}`,
876
+ runtimeModel: effectivePricingModelId ?? persistedRuntime.runtimeModel ?? null,
877
+ usedBudgetUsd: sql`${agents.usedBudgetUsd} + ${executionUsdCost}`,
863
878
  tokenUsage: sql`${agents.tokenUsage} + ${execution.tokenInput + execution.tokenOutput}`,
864
879
  updatedAt: new Date()
865
880
  })
@@ -870,7 +885,7 @@ export async function runHeartbeatForAgent(
870
885
  summary: execution.summary,
871
886
  tokenInput: execution.tokenInput,
872
887
  tokenOutput: execution.tokenOutput,
873
- usdCost: execution.usdCost,
888
+ usdCost: executionUsdCost,
874
889
  trace: executionTrace,
875
890
  outcome: executionOutcome
876
891
  });
@@ -916,7 +931,7 @@ export async function runHeartbeatForAgent(
916
931
  usage: {
917
932
  tokenInput: execution.tokenInput,
918
933
  tokenOutput: execution.tokenOutput,
919
- usdCost: execution.usdCost
934
+ usdCost: executionUsdCost
920
935
  }
921
936
  }
922
937
  });
@@ -1109,6 +1124,24 @@ export async function runHeartbeatForAgent(
1109
1124
  cwd: runtimeLaunchSummary.cwd ?? null
1110
1125
  };
1111
1126
  }
1127
+ const runtimeModelId = resolveRuntimeModelId({
1128
+ runtimeModel: persistedRuntime.runtimeModel,
1129
+ stateBlob: agent.stateBlob
1130
+ });
1131
+ await appendFinishedRunCostEntry({
1132
+ db,
1133
+ companyId,
1134
+ providerType: agent.providerType,
1135
+ runtimeModelId,
1136
+ pricingProviderType: agent.providerType,
1137
+ pricingModelId: runtimeModelId,
1138
+ tokenInput: 0,
1139
+ tokenOutput: 0,
1140
+ issueId: primaryIssueId,
1141
+ projectId: primaryProjectId,
1142
+ agentId,
1143
+ status: "failed"
1144
+ });
1112
1145
  await db
1113
1146
  .update(heartbeatRuns)
1114
1147
  .set({
@@ -2122,6 +2155,7 @@ function buildHeartbeatRuntimeEnv(input: {
2122
2155
  BOPODEV_RUN_ID: input.heartbeatRunId,
2123
2156
  BOPODEV_FORCE_MANAGED_CODEX_HOME: "false",
2124
2157
  BOPODEV_API_BASE_URL: apiBaseUrl,
2158
+ BOPODEV_API_URL: apiBaseUrl,
2125
2159
  BOPODEV_ACTOR_TYPE: "agent",
2126
2160
  BOPODEV_ACTOR_ID: input.agentId,
2127
2161
  BOPODEV_ACTOR_COMPANIES: input.companyId,
@@ -2135,7 +2169,9 @@ function buildHeartbeatRuntimeEnv(input: {
2135
2169
  }
2136
2170
 
2137
2171
  function resolveControlPlaneApiBaseUrl() {
2138
- const configured = resolveControlPlaneProcessEnv("API_BASE_URL") ?? process.env.NEXT_PUBLIC_API_URL;
2172
+ // Agent runtimes must call the control-plane API directly; do not inherit
2173
+ // browser-facing NEXT_PUBLIC_API_URL (can point to non-runtime endpoints).
2174
+ const configured = resolveControlPlaneProcessEnv("API_BASE_URL");
2139
2175
  return normalizeControlPlaneApiBaseUrl(configured) ?? "http://127.0.0.1:4020";
2140
2176
  }
2141
2177
 
@@ -2248,7 +2284,8 @@ function shouldRequireControlPlanePreflight(
2248
2284
  providerType === "codex" ||
2249
2285
  providerType === "claude_code" ||
2250
2286
  providerType === "cursor" ||
2251
- providerType === "opencode"
2287
+ providerType === "opencode" ||
2288
+ providerType === "gemini_cli"
2252
2289
  );
2253
2290
  }
2254
2291
 
@@ -2367,6 +2404,68 @@ function resolveControlPlaneHeaders(runtimeEnv: Record<string, string>):
2367
2404
  return { ok: true, headers: jsonHeadersResult.data };
2368
2405
  }
2369
2406
 
2407
+ function resolveRuntimeModelId(input: { runtimeModel?: string; stateBlob?: string | null }) {
2408
+ const runtimeModel = input.runtimeModel?.trim();
2409
+ if (runtimeModel) {
2410
+ return runtimeModel;
2411
+ }
2412
+ if (!input.stateBlob) {
2413
+ return null;
2414
+ }
2415
+ try {
2416
+ const parsed = JSON.parse(input.stateBlob) as { runtime?: { model?: unknown } };
2417
+ const modelId = parsed.runtime?.model;
2418
+ return typeof modelId === "string" && modelId.trim().length > 0 ? modelId.trim() : null;
2419
+ } catch {
2420
+ return null;
2421
+ }
2422
+ }
2423
+
2424
+ async function appendFinishedRunCostEntry(input: {
2425
+ db: BopoDb;
2426
+ companyId: string;
2427
+ providerType: string;
2428
+ runtimeModelId: string | null;
2429
+ pricingProviderType?: string | null;
2430
+ pricingModelId?: string | null;
2431
+ tokenInput: number;
2432
+ tokenOutput: number;
2433
+ issueId?: string | null;
2434
+ projectId?: string | null;
2435
+ agentId?: string | null;
2436
+ status: "ok" | "failed" | "skipped";
2437
+ }) {
2438
+ const pricingDecision = await calculateModelPricedUsdCost({
2439
+ db: input.db,
2440
+ companyId: input.companyId,
2441
+ providerType: input.providerType,
2442
+ pricingProviderType: input.pricingProviderType ?? input.providerType,
2443
+ modelId: input.pricingModelId ?? input.runtimeModelId,
2444
+ tokenInput: input.tokenInput,
2445
+ tokenOutput: input.tokenOutput
2446
+ });
2447
+
2448
+ const shouldPersist = input.status === "ok" || input.status === "failed";
2449
+ if (shouldPersist) {
2450
+ await appendCost(input.db, {
2451
+ companyId: input.companyId,
2452
+ providerType: input.providerType,
2453
+ runtimeModelId: input.runtimeModelId,
2454
+ pricingProviderType: pricingDecision.pricingProviderType,
2455
+ pricingModelId: pricingDecision.pricingModelId,
2456
+ pricingSource: pricingDecision.pricingSource,
2457
+ tokenInput: input.tokenInput,
2458
+ tokenOutput: input.tokenOutput,
2459
+ usdCost: pricingDecision.usdCost.toFixed(6),
2460
+ issueId: input.issueId ?? null,
2461
+ projectId: input.projectId ?? null,
2462
+ agentId: input.agentId ?? null
2463
+ });
2464
+ }
2465
+
2466
+ return pricingDecision;
2467
+ }
2468
+
2370
2469
  function isHeartbeatDue(cronExpression: string, lastRunAt: Date | null, now: Date) {
2371
2470
  const normalizedNow = truncateToMinute(now);
2372
2471
  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
+