auggy 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/LICENSE +201 -0
  3. package/README.md +161 -0
  4. package/package.json +76 -0
  5. package/src/agent-card.ts +39 -0
  6. package/src/agent.ts +283 -0
  7. package/src/agentmail-client.ts +138 -0
  8. package/src/augments/bash/index.ts +463 -0
  9. package/src/augments/bash/skill/SKILL.md +156 -0
  10. package/src/augments/budgets/budget-store.ts +513 -0
  11. package/src/augments/budgets/index.ts +134 -0
  12. package/src/augments/budgets/preamble.ts +93 -0
  13. package/src/augments/budgets/types.ts +89 -0
  14. package/src/augments/file-memory/index.ts +71 -0
  15. package/src/augments/filesystem/index.ts +533 -0
  16. package/src/augments/filesystem/skill/SKILL.md +142 -0
  17. package/src/augments/filesystem/skill/references/mount-permissions.md +81 -0
  18. package/src/augments/layered-memory/extractor/buffer.ts +56 -0
  19. package/src/augments/layered-memory/extractor/frequency.ts +79 -0
  20. package/src/augments/layered-memory/extractor/inject-handler.ts +103 -0
  21. package/src/augments/layered-memory/extractor/parse.ts +75 -0
  22. package/src/augments/layered-memory/extractor/prompt.md +26 -0
  23. package/src/augments/layered-memory/index.ts +757 -0
  24. package/src/augments/layered-memory/skill/SKILL.md +153 -0
  25. package/src/augments/layered-memory/storage/migrations/README.md +16 -0
  26. package/src/augments/layered-memory/storage/migrations/supabase-add-fact-fields.sql +9 -0
  27. package/src/augments/layered-memory/storage/sqlite-store.ts +352 -0
  28. package/src/augments/layered-memory/storage/supabase-store.ts +263 -0
  29. package/src/augments/layered-memory/storage/types.ts +98 -0
  30. package/src/augments/link/index.ts +489 -0
  31. package/src/augments/link/translate.ts +261 -0
  32. package/src/augments/notify/adapters/agentmail.ts +70 -0
  33. package/src/augments/notify/adapters/telegram.ts +60 -0
  34. package/src/augments/notify/adapters/webhook.ts +55 -0
  35. package/src/augments/notify/index.ts +284 -0
  36. package/src/augments/notify/skill/SKILL.md +150 -0
  37. package/src/augments/org-context/index.ts +721 -0
  38. package/src/augments/org-context/skill/SKILL.md +96 -0
  39. package/src/augments/skills/index.ts +103 -0
  40. package/src/augments/supabase-memory/index.ts +151 -0
  41. package/src/augments/telegram-transport/index.ts +312 -0
  42. package/src/augments/telegram-transport/polling.ts +55 -0
  43. package/src/augments/telegram-transport/webhook.ts +56 -0
  44. package/src/augments/turn-control/index.ts +61 -0
  45. package/src/augments/turn-control/skill/SKILL.md +155 -0
  46. package/src/augments/visitor-auth/email-validation.ts +66 -0
  47. package/src/augments/visitor-auth/index.ts +779 -0
  48. package/src/augments/visitor-auth/rate-limiter.ts +90 -0
  49. package/src/augments/visitor-auth/skill/SKILL.md +55 -0
  50. package/src/augments/visitor-auth/storage/sqlite-store.ts +398 -0
  51. package/src/augments/visitor-auth/storage/types.ts +164 -0
  52. package/src/augments/visitor-auth/types.ts +123 -0
  53. package/src/augments/visitor-auth/verify-page.ts +179 -0
  54. package/src/augments/web-fetch/index.ts +331 -0
  55. package/src/augments/web-fetch/skill/SKILL.md +100 -0
  56. package/src/cli/agent-index.ts +289 -0
  57. package/src/cli/augment-catalog.ts +320 -0
  58. package/src/cli/augment-resolver.ts +597 -0
  59. package/src/cli/commands/add-skill.ts +194 -0
  60. package/src/cli/commands/add.ts +87 -0
  61. package/src/cli/commands/chat.ts +207 -0
  62. package/src/cli/commands/create.ts +462 -0
  63. package/src/cli/commands/dev.ts +139 -0
  64. package/src/cli/commands/eval.ts +180 -0
  65. package/src/cli/commands/ls.ts +66 -0
  66. package/src/cli/commands/remove.ts +95 -0
  67. package/src/cli/commands/restart.ts +40 -0
  68. package/src/cli/commands/start.ts +123 -0
  69. package/src/cli/commands/status.ts +104 -0
  70. package/src/cli/commands/stop.ts +84 -0
  71. package/src/cli/commands/visitors-revoke.ts +155 -0
  72. package/src/cli/commands/visitors.ts +101 -0
  73. package/src/cli/config-parser.ts +1034 -0
  74. package/src/cli/engine-resolver.ts +68 -0
  75. package/src/cli/index.ts +178 -0
  76. package/src/cli/model-picker.ts +89 -0
  77. package/src/cli/pid-registry.ts +146 -0
  78. package/src/cli/plist-generator.ts +117 -0
  79. package/src/cli/resolve-config.ts +56 -0
  80. package/src/cli/scaffold-skills.ts +158 -0
  81. package/src/cli/scaffold.ts +291 -0
  82. package/src/cli/skill-frontmatter.ts +51 -0
  83. package/src/cli/skill-validator.ts +151 -0
  84. package/src/cli/types.ts +228 -0
  85. package/src/cli/yaml-helpers.ts +66 -0
  86. package/src/engines/_shared/cost.ts +55 -0
  87. package/src/engines/_shared/schema-normalize.ts +75 -0
  88. package/src/engines/anthropic/pricing.ts +117 -0
  89. package/src/engines/anthropic.ts +483 -0
  90. package/src/engines/openai/pricing.ts +67 -0
  91. package/src/engines/openai.ts +446 -0
  92. package/src/engines/openrouter/pricing.ts +83 -0
  93. package/src/engines/openrouter.ts +185 -0
  94. package/src/helpers.ts +24 -0
  95. package/src/http.ts +387 -0
  96. package/src/index.ts +165 -0
  97. package/src/kernel/capability-table.ts +172 -0
  98. package/src/kernel/context-allocator.ts +161 -0
  99. package/src/kernel/history-manager.ts +198 -0
  100. package/src/kernel/lifecycle-manager.ts +106 -0
  101. package/src/kernel/output-validator.ts +35 -0
  102. package/src/kernel/preamble.ts +23 -0
  103. package/src/kernel/route-collector.ts +97 -0
  104. package/src/kernel/timeout.ts +21 -0
  105. package/src/kernel/tool-selector.ts +47 -0
  106. package/src/kernel/trace-emitter.ts +66 -0
  107. package/src/kernel/transport-queue.ts +147 -0
  108. package/src/kernel/turn-loop.ts +1148 -0
  109. package/src/memory/context-synthesis.ts +83 -0
  110. package/src/memory/memory-bus.ts +61 -0
  111. package/src/memory/registry.ts +80 -0
  112. package/src/memory/tools.ts +320 -0
  113. package/src/memory/types.ts +8 -0
  114. package/src/parts.ts +30 -0
  115. package/src/scaffold-templates/identity.md +31 -0
  116. package/src/telegram-client.ts +145 -0
  117. package/src/tokenizer.ts +14 -0
  118. package/src/transports/ag-ui-events.ts +253 -0
  119. package/src/transports/visitor-token.ts +82 -0
  120. package/src/transports/web-transport.ts +948 -0
  121. package/src/types.ts +1009 -0
@@ -0,0 +1,446 @@
1
+ import OpenAI from "openai";
2
+ import { normalizeSchema } from "./_shared/schema-normalize";
3
+ import { lookup, getFreshness, priceOpenAIResponse } from "./openai/pricing";
4
+ import type {
5
+ AssembledPrompt,
6
+ Message,
7
+ ModelClient,
8
+ ModelDelta,
9
+ ModelResponse,
10
+ ToolDefinition,
11
+ } from "../types";
12
+
13
+ /**
14
+ * OpenAI engine — a ModelClient adapter that drives the agent's reasoning
15
+ * via OpenAI's Chat Completions API.
16
+ *
17
+ * Responsibilities:
18
+ * - Translate AssembledPrompt into Chat Completions request shape
19
+ * (system message, conversation messages, tools)
20
+ * - Run the API call (buffered; no streaming)
21
+ * - Translate the response back into ModelResponse
22
+ *
23
+ * The engine is stateless beyond the underlying SDK. Retries, timeouts, and
24
+ * rate-limit handling live in the SDK; everything above (queue, history,
25
+ * context budgeting) is the kernel's job.
26
+ *
27
+ * Token counting uses a character/4 approximation, matching the Anthropic
28
+ * engine and Auggy's default tokenizer. The kernel does not call this
29
+ * method on hot paths (it uses src/tokenizer.ts directly), so accuracy is
30
+ * not load-bearing.
31
+ */
32
+ export interface OpenAIEngineOptions {
33
+ /** API key. Defaults to OPENAI_API_KEY env (read by the SDK). */
34
+ apiKey?: string;
35
+ /** Model ID (e.g. "gpt-5", "gpt-5.1", "o3", "o4-mini"). */
36
+ model: string;
37
+ /** Total context window in tokens. Defaults to 128_000.
38
+ * Set this per model — the kernel uses it for context budgeting and
39
+ * doesn't validate against the actual API limit. */
40
+ maxContextTokens?: number;
41
+ /** Per-turn output cap, sent as `max_completion_tokens`. Defaults to 4096.
42
+ * Note: `max_tokens` is deprecated in v6 SDK and rejected by o-series. */
43
+ maxTokens?: number;
44
+ /** Optional base URL override (for proxies or compatible providers). */
45
+ baseURL?: string;
46
+ /** Reasoning effort for reasoning-capable models.
47
+ * - `none`: gpt-5.1 only
48
+ * - `minimal | low | medium | high`: universally supported on reasoning models
49
+ * - `xhigh`: gpt-5.1-codex-max and later
50
+ * Older Chat Completions models (e.g. gpt-4) do NOT support this — the API
51
+ * returns an error which propagates through `complete()`. */
52
+ reasoningEffort?: OpenAI.Chat.ChatCompletionReasoningEffort;
53
+ /**
54
+ * Override pricing for cost estimation. If set, the adapter uses these rates
55
+ * instead of the built-in pricing table. Useful for unknown models or custom
56
+ * pricing arrangements. USD per million tokens.
57
+ *
58
+ * Accepts the full Pricing shape; cache fields are accepted for type symmetry
59
+ * with Anthropic but not used by the OpenAI adapter today (no cache-token
60
+ * usage is parsed from OpenAI Chat Completions responses).
61
+ */
62
+ costOverride?: import("./_shared/cost").Pricing;
63
+ }
64
+
65
+ export function createOpenAIEngine(opts: OpenAIEngineOptions): ModelClient {
66
+ const client = new OpenAI({
67
+ apiKey: opts.apiKey,
68
+ baseURL: opts.baseURL,
69
+ });
70
+
71
+ const maxContextTokens = opts.maxContextTokens ?? 128_000;
72
+ const maxOutputTokens = opts.maxTokens ?? 4096;
73
+
74
+ // Pricing freshness + availability warning at startup. Fires once at
75
+ // factory time, not per-turn.
76
+ if (!opts.costOverride) {
77
+ const rates = lookup(opts.model);
78
+ if (!rates) {
79
+ // eslint-disable-next-line no-console
80
+ console.warn(
81
+ `[engines/openai] No pricing entry for model "${opts.model}" and no costOverride configured. ` +
82
+ `costUsd will be undefined; dailyBudgetUsd cannot enforce against this model. ` +
83
+ `Add the model to src/engines/openai/pricing.ts or configure engine.costOverride in agent.yaml.`,
84
+ );
85
+ } else {
86
+ const f = getFreshness();
87
+ if (f.stale) {
88
+ // eslint-disable-next-line no-console
89
+ console.warn(
90
+ `[engines/openai] Pricing table verifiedAt ${f.verifiedAt} is more than 90 days old. ` +
91
+ `Cost estimates may be drifting from actual billing. Verify rates and update src/engines/openai/pricing.ts.`,
92
+ );
93
+ }
94
+ }
95
+ } else if (
96
+ opts.costOverride.cacheWriteUsdPerMtok !== undefined ||
97
+ opts.costOverride.cacheReadUsdPerMtok !== undefined
98
+ ) {
99
+ // Operator set cache rates on OpenAI override. Today's adapter does not
100
+ // parse cache tokens from OpenAI Chat Completions responses, so cache
101
+ // rates would be silently ignored. Warn loudly rather than silently
102
+ // under-report — operators provisioning these rates should know they
103
+ // don't take effect.
104
+ // eslint-disable-next-line no-console
105
+ console.warn(
106
+ `[engines/openai] costOverride.cacheWriteUsdPerMtok/cacheReadUsdPerMtok set but ignored — ` +
107
+ `the OpenAI adapter does not parse cache tokens from upstream responses. Cache rates will not contribute to costUsd.`,
108
+ );
109
+ }
110
+
111
+ return {
112
+ maxContextTokens,
113
+
114
+ countTokens(text: string): number {
115
+ return Math.ceil(text.length / 4);
116
+ },
117
+
118
+ async complete(
119
+ prompt: AssembledPrompt,
120
+ _opts?: { onDelta?: (delta: ModelDelta) => void },
121
+ ): Promise<ModelResponse> {
122
+ const systemMessage = assembleOpenAISystemMessage(prompt);
123
+ const messages = convertOpenAIMessages(prompt.messages);
124
+ const tools = convertOpenAITools(prompt.tools);
125
+
126
+ const allMessages: OpenAI.Chat.ChatCompletionMessageParam[] = systemMessage
127
+ ? [systemMessage, ...messages]
128
+ : messages;
129
+
130
+ const params: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming = {
131
+ model: opts.model,
132
+ max_completion_tokens: maxOutputTokens,
133
+ messages: allMessages,
134
+ ...(tools.length > 0 ? { tools } : {}),
135
+ ...(opts.reasoningEffort ? { reasoning_effort: opts.reasoningEffort } : {}),
136
+ };
137
+
138
+ let completion: OpenAI.Chat.ChatCompletion;
139
+ try {
140
+ completion = await client.chat.completions.create(params);
141
+ } catch (err) {
142
+ // Wrap the SDK error so logs identify which engine + model failed,
143
+ // not just "OpenAIError: 429". `cause` preserves the original SDK
144
+ // error (including `.status`) for callers that introspect.
145
+ const msg = err instanceof Error ? err.message : String(err);
146
+ throw new Error(`OpenAI engine (${opts.model}) failed: ${msg}`, {
147
+ cause: err,
148
+ });
149
+ }
150
+ const response = buildOpenAIModelResponse(completion, opts.model);
151
+ const result = priceOpenAIResponse(opts.model, opts.costOverride, {
152
+ prompt_tokens: completion.usage?.prompt_tokens ?? response.inputTokens,
153
+ completion_tokens: completion.usage?.completion_tokens ?? response.outputTokens,
154
+ reasoning_tokens: (completion.usage as Record<string, unknown> | null | undefined)
155
+ ?.reasoning_tokens as number | undefined,
156
+ });
157
+ return result.priced
158
+ ? { ...response, costUsd: result.costUsd }
159
+ : { ...response, costUsd: undefined, unpricedReason: result.reason };
160
+ },
161
+ };
162
+ }
163
+
164
+ // ===========================================================================
165
+ // AssembledPrompt → OpenAI request translation
166
+ // ===========================================================================
167
+
168
+ /** Join system + context + assistant-preamble blocks into a single system
169
+ * message, or return null if there is nothing to say. OpenAI has no
170
+ * separate slot for context-block content, so it folds into system. */
171
+ export function assembleOpenAISystemMessage(
172
+ prompt: AssembledPrompt,
173
+ ): OpenAI.Chat.ChatCompletionSystemMessageParam | null {
174
+ const parts: string[] = [];
175
+ if (prompt.systemBlocks.length > 0) {
176
+ parts.push(prompt.systemBlocks.join("\n\n"));
177
+ }
178
+ if (prompt.contextBlocks.length > 0) {
179
+ parts.push(prompt.contextBlocks.join("\n\n"));
180
+ }
181
+ if (prompt.assistantPreamble && prompt.assistantPreamble.length > 0) {
182
+ parts.push(prompt.assistantPreamble.join("\n\n"));
183
+ }
184
+ if (parts.length === 0) return null;
185
+ return { role: "system", content: parts.join("\n\n") };
186
+ }
187
+
188
+ /** Parse the JSON-stringified tool-call payload that the kernel writes to
189
+ * `Message.content` for tool_use messages. The kernel writes
190
+ * `JSON.stringify({ name, arguments: object })` (see turn-loop.ts:518),
191
+ * so the recovered shape is `{ name: string, arguments: Record }`.
192
+ * Returns null defensively on malformed JSON. */
193
+ export function safeParseToolCall(
194
+ content: string,
195
+ ): { name: string; arguments: Record<string, unknown> } | null {
196
+ try {
197
+ const parsed = JSON.parse(content) as {
198
+ name?: unknown;
199
+ arguments?: unknown;
200
+ };
201
+ if (
202
+ parsed &&
203
+ typeof parsed.name === "string" &&
204
+ parsed.arguments &&
205
+ typeof parsed.arguments === "object" &&
206
+ !Array.isArray(parsed.arguments)
207
+ ) {
208
+ return {
209
+ name: parsed.name,
210
+ arguments: parsed.arguments as Record<string, unknown>,
211
+ };
212
+ }
213
+ } catch {
214
+ /* fall through */
215
+ }
216
+ return null;
217
+ }
218
+
219
+ /** Walk Auggy's flat message list and produce OpenAI's grouped format.
220
+ *
221
+ * Auggy stores tool_use and tool_result as separate flat messages with
222
+ * matching toolCallIds. OpenAI wants:
223
+ * - `tool_use` entries folded into the preceding assistant message's
224
+ * `tool_calls` array
225
+ * - `tool_result` entries each emitted as a standalone `role: "tool"`
226
+ * message (no batching needed — OpenAI accepts consecutive tool messages)
227
+ *
228
+ * Consecutive `user` messages are coalesced (joined with \n\n) since OpenAI
229
+ * expects user/assistant alternation in most contexts.
230
+ */
231
+ export function convertOpenAIMessages(
232
+ messages: Message[],
233
+ ): OpenAI.Chat.ChatCompletionMessageParam[] {
234
+ const result: OpenAI.Chat.ChatCompletionMessageParam[] = [];
235
+ let i = 0;
236
+
237
+ while (i < messages.length) {
238
+ const msg = messages[i]!;
239
+
240
+ if (msg.role === "user") {
241
+ result.push({ role: "user", content: msg.content });
242
+ i++;
243
+ continue;
244
+ }
245
+
246
+ if (msg.role === "assistant") {
247
+ const text = msg.content;
248
+ const toolCalls: OpenAI.Chat.ChatCompletionMessageFunctionToolCall[] = [];
249
+ i++;
250
+ while (i < messages.length && messages[i]!.role === "tool_use") {
251
+ const tu = messages[i]!;
252
+ const parsed = safeParseToolCall(tu.content);
253
+ if (parsed && tu.toolCallId) {
254
+ toolCalls.push({
255
+ id: tu.toolCallId,
256
+ type: "function",
257
+ function: {
258
+ name: parsed.name,
259
+ arguments: JSON.stringify(parsed.arguments),
260
+ },
261
+ });
262
+ } else {
263
+ // Malformed history is a kernel-side bug or storage corruption.
264
+ // Drop the entry rather than fail the turn, but emit a warning so
265
+ // operators can grep for `[Auggy:openai]` and trace the cause.
266
+ warnDroppedToolUse(tu, parsed);
267
+ }
268
+ i++;
269
+ }
270
+ const assistantMsg: OpenAI.Chat.ChatCompletionAssistantMessageParam = {
271
+ role: "assistant",
272
+ content: text || null,
273
+ };
274
+ if (toolCalls.length > 0) {
275
+ assistantMsg.tool_calls = toolCalls;
276
+ }
277
+ // OpenAI rejects assistant messages that have neither content nor tool_calls.
278
+ if (assistantMsg.content || assistantMsg.tool_calls) {
279
+ result.push(assistantMsg);
280
+ }
281
+ continue;
282
+ }
283
+
284
+ if (msg.role === "tool_use") {
285
+ // Orphaned tool_use (no preceding assistant text). Emit as a standalone
286
+ // assistant message with only the tool call.
287
+ const parsed = safeParseToolCall(msg.content);
288
+ if (parsed && msg.toolCallId) {
289
+ result.push({
290
+ role: "assistant",
291
+ content: null,
292
+ tool_calls: [
293
+ {
294
+ id: msg.toolCallId,
295
+ type: "function",
296
+ function: {
297
+ name: parsed.name,
298
+ arguments: JSON.stringify(parsed.arguments),
299
+ },
300
+ },
301
+ ],
302
+ });
303
+ } else {
304
+ warnDroppedToolUse(msg, parsed);
305
+ }
306
+ i++;
307
+ continue;
308
+ }
309
+
310
+ if (msg.role === "tool_result") {
311
+ if (msg.toolCallId) {
312
+ result.push({
313
+ role: "tool",
314
+ tool_call_id: msg.toolCallId,
315
+ content: msg.content,
316
+ });
317
+ }
318
+ i++;
319
+ continue;
320
+ }
321
+
322
+ // Unknown role — skip defensively.
323
+ i++;
324
+ }
325
+
326
+ return coalesceConsecutiveUsers(result);
327
+ }
328
+
329
+ /** Surface dropped tool_use entries to operators. The kernel writes these
330
+ * payloads itself, so a malformed entry indicates a kernel bug or storage
331
+ * corruption — silent drops would let the agent re-call tools without
332
+ * knowing. We log instead of throwing because failing the entire turn
333
+ * over one bad history entry is too aggressive. */
334
+ function warnDroppedToolUse(
335
+ m: Message,
336
+ parsed: { name: string; arguments: Record<string, unknown> } | null,
337
+ ): void {
338
+ const reason = parsed === null ? "parse failed" : "missing toolCallId";
339
+ const preview = m.content.slice(0, 80);
340
+ // eslint-disable-next-line no-console
341
+ console.warn(
342
+ `[Auggy:openai] dropping malformed tool_use msg=${m.id} reason=${reason} content=${JSON.stringify(preview)}`,
343
+ );
344
+ }
345
+
346
+ function coalesceConsecutiveUsers(
347
+ messages: OpenAI.Chat.ChatCompletionMessageParam[],
348
+ ): OpenAI.Chat.ChatCompletionMessageParam[] {
349
+ if (messages.length <= 1) return messages;
350
+ const out: OpenAI.Chat.ChatCompletionMessageParam[] = [messages[0]!];
351
+ for (let i = 1; i < messages.length; i++) {
352
+ const prev = out[out.length - 1]!;
353
+ const curr = messages[i]!;
354
+ if (prev.role === "user" && curr.role === "user") {
355
+ const prevContent = typeof prev.content === "string" ? prev.content : "";
356
+ const currContent = typeof curr.content === "string" ? curr.content : "";
357
+ (prev as OpenAI.Chat.ChatCompletionUserMessageParam).content =
358
+ `${prevContent}\n\n${currContent}`;
359
+ } else {
360
+ out.push(curr);
361
+ }
362
+ }
363
+ return out;
364
+ }
365
+
366
+ /** Convert Auggy ToolDefinitions to OpenAI Chat Completions tool format.
367
+ * Schema normalization strips JSON Schema metadata keys (`$schema`, `$id`)
368
+ * that the API ignores or rejects. */
369
+ export function convertOpenAITools(toolDefs: ToolDefinition[]): OpenAI.Chat.ChatCompletionTool[] {
370
+ return toolDefs.map((td) => ({
371
+ type: "function",
372
+ function: {
373
+ name: td.name,
374
+ description: td.description,
375
+ parameters: normalizeSchema(td.inputSchema),
376
+ },
377
+ }));
378
+ }
379
+
380
+ // ===========================================================================
381
+ // OpenAI response → ModelResponse translation
382
+ // ===========================================================================
383
+
384
+ /** Defensive JSON.parse that returns `{}` on malformed input. Used to recover
385
+ * tool call argument objects from the SDK's string-form `function.arguments`. */
386
+ export function safeParseJson(s: string): Record<string, unknown> {
387
+ try {
388
+ const parsed = JSON.parse(s);
389
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
390
+ return parsed as Record<string, unknown>;
391
+ }
392
+ } catch {
393
+ /* fall through */
394
+ }
395
+ return {};
396
+ }
397
+
398
+ export function buildOpenAIModelResponse(
399
+ completion: OpenAI.Chat.ChatCompletion,
400
+ modelLabel: string = "openai",
401
+ ): ModelResponse {
402
+ const choice = completion.choices[0];
403
+ if (!choice) {
404
+ // Empty choices array — rare API edge case (typically a content policy
405
+ // rejection that was caught before generation). We throw rather than
406
+ // return an empty response because a silent empty turn means the agent
407
+ // sends nothing back to the user with no explanation. The thrown error
408
+ // wraps up through the kernel's transport layer.
409
+ throw new Error(
410
+ `OpenAI engine (${modelLabel}) returned no choices in completion ` +
411
+ `(usage=${JSON.stringify(completion.usage)}, id=${completion.id ?? "?"}). ` +
412
+ `Likely a content-policy rejection — inspect the prompt for blocked content.`,
413
+ );
414
+ }
415
+
416
+ const message = choice.message;
417
+ const content = message.content ?? "";
418
+ const toolCalls = (message.tool_calls ?? [])
419
+ .map((tc) => {
420
+ // OpenAI v6 may return either function or custom tool calls. Auggy
421
+ // only emits function tools, so non-function results are dropped.
422
+ if (tc.type === "function") {
423
+ return {
424
+ name: tc.function.name,
425
+ arguments: safeParseJson(tc.function.arguments),
426
+ };
427
+ }
428
+ return null;
429
+ })
430
+ .filter((x): x is { name: string; arguments: Record<string, unknown> } => x !== null);
431
+
432
+ const finishReason: ModelResponse["finishReason"] =
433
+ choice.finish_reason === "tool_calls" || choice.finish_reason === "function_call"
434
+ ? "tool_use"
435
+ : choice.finish_reason === "length"
436
+ ? "max_tokens"
437
+ : "end_turn";
438
+
439
+ return {
440
+ content,
441
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
442
+ inputTokens: completion.usage?.prompt_tokens ?? 0,
443
+ outputTokens: completion.usage?.completion_tokens ?? 0,
444
+ finishReason,
445
+ };
446
+ }
@@ -0,0 +1,83 @@
1
+ import {
2
+ type Pricing,
3
+ type CostResult,
4
+ type PricingFreshness,
5
+ computeCostUsd,
6
+ } from "../_shared/cost";
7
+ import * as anthropicPricing from "../anthropic/pricing";
8
+ import * as openaiPricing from "../openai/pricing";
9
+
10
+ interface ResolvedPricing {
11
+ rates: Pricing;
12
+ resolvedProvider: "anthropic" | "openai" | "openrouter";
13
+ freshness: PricingFreshness;
14
+ }
15
+
16
+ /**
17
+ * Resolve an OpenRouter model slug ("<provider>/<model>") to a pricing entry.
18
+ *
19
+ * v0 SCOPE: anthropic/* and openai/* slugs only. Other providers return null.
20
+ * The resolved freshness binds to the upstream provider's table verifiedAt,
21
+ * not OpenRouter's own (which has no table entries).
22
+ */
23
+ export function resolveSlug(model: string): ResolvedPricing | null {
24
+ const slashIdx = model.indexOf("/");
25
+ if (slashIdx === -1) return null;
26
+ const prefix = model.slice(0, slashIdx);
27
+ const tail = model.slice(slashIdx + 1);
28
+
29
+ if (prefix === "anthropic") {
30
+ const rates = anthropicPricing.lookup(tail);
31
+ if (!rates) return null;
32
+ return { rates, resolvedProvider: "anthropic", freshness: anthropicPricing.getFreshness() };
33
+ }
34
+
35
+ if (prefix === "openai") {
36
+ const rates = openaiPricing.lookup(tail);
37
+ if (!rates) return null;
38
+ return { rates, resolvedProvider: "openai", freshness: openaiPricing.getFreshness() };
39
+ }
40
+
41
+ return null;
42
+ }
43
+
44
+ export interface OpenRouterUsage {
45
+ prompt_tokens: number;
46
+ completion_tokens: number;
47
+ }
48
+
49
+ /**
50
+ * Price an OpenRouter response.
51
+ *
52
+ * Resolution order:
53
+ * 1. If costOverride is set, use it directly.
54
+ * 2. Try to resolve the model slug via anthropic/* or openai/* delegation.
55
+ * 3. If neither applies, return unpriced (out of v0 scope).
56
+ */
57
+ export function priceOpenRouterResponse(
58
+ model: string,
59
+ override: Pricing | undefined,
60
+ usage: OpenRouterUsage,
61
+ ): CostResult {
62
+ if (override) {
63
+ const costUsd = computeCostUsd(override, {
64
+ inputTokens: usage.prompt_tokens,
65
+ outputTokens: usage.completion_tokens,
66
+ });
67
+ return { priced: true, costUsd };
68
+ }
69
+
70
+ const resolved = resolveSlug(model);
71
+ if (!resolved) {
72
+ return {
73
+ priced: false,
74
+ reason: `openrouter: slug "${model}" outside v0 scope (anthropic/* and openai/* only)`,
75
+ };
76
+ }
77
+
78
+ const costUsd = computeCostUsd(resolved.rates, {
79
+ inputTokens: usage.prompt_tokens,
80
+ outputTokens: usage.completion_tokens,
81
+ });
82
+ return { priced: true, costUsd };
83
+ }