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 +1 -1
- package/package.json +4 -4
- package/src/lib/agent-config.ts +36 -1
- package/src/lib/opencode-model.ts +2 -26
- package/src/routes/agents.ts +22 -0
- package/src/routes/companies.ts +2 -0
- package/src/routes/observability.ts +50 -1
- package/src/scripts/onboard-seed.ts +4 -1
- package/src/services/governance-service.ts +9 -0
- package/src/services/heartbeat-service.ts +159 -29
- package/src/services/model-pricing.ts +217 -0
package/LICENSE
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bopodev-api",
|
|
3
|
-
"version": "0.1.
|
|
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-
|
|
21
|
-
"bopodev-
|
|
22
|
-
"bopodev-db": "0.1.
|
|
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",
|
package/src/lib/agent-config.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/src/routes/agents.ts
CHANGED
|
@@ -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
|
}
|
package/src/routes/companies.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
863
|
-
|
|
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:
|
|
872
|
-
tokenOutput:
|
|
873
|
-
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:
|
|
918
|
-
tokenOutput:
|
|
919
|
-
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:
|
|
1051
|
-
tokenOutput:
|
|
1052
|
-
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
|
-
|
|
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
|
+
|