Skip to content

feat(ai/models): enrich response with context_window + cost#518

Merged
sweetmantech merged 2 commits intotestfrom
feat/ai-models-enrich-context-cost
May 5, 2026
Merged

feat(ai/models): enrich response with context_window + cost#518
sweetmantech merged 2 commits intotestfrom
feat/ai-models-enrich-context-cost

Conversation

@sweetmantech
Copy link
Copy Markdown
Contributor

@sweetmantech sweetmantech commented May 5, 2026

Summary

api's GET /api/ai/models now returns two extra fields on each model entry:

  • context_window (integer) — max tokens per request
  • cost ({input, output}) — per-million-token pricing in USD

Both are sourced from the public models.dev catalog and merged into the gateway response. Documented in recoupable/docs#188 (merged).

Why

Pre-step for cutting open-agents' model picker over to api. The frontend reads both fields:

  • apps/web/lib/models.ts filters out models with no context_window
  • apps/web/lib/model-options.ts surfaces cost in the model selector

Without these, redirecting useModels() to api strips the model picker.

Architecture

TDD'd in three small focused helpers:

File Responsibility
lib/ai/parseModelsDevMetadata.ts pure unknown → Map<id, {context_window, cost}>
lib/ai/fetchModelsDevMetadata.ts 750ms-bounded fetch + parse
lib/ai/enrichGatewayModel.ts pure, non-mutating merge

lib/ai/getAvailableModels.ts orchestrates: gateway models + metadata fetched in parallel, then each non-embed model is mapped through enrichment.

Best-effort enrichment

Every failure mode of fetchModelsDevMetadata (timeout, non-2xx, malformed JSON, network throw) returns an empty map — the api response stays available with un-enriched models. Today's clients aren't affected.

TDD

  • parseModelsDevMetadata.test.ts — 8 cases (well-formed entry, namespace handling, missing-id fallback, partial cost, non-positive context, malformed providers/models)
  • fetchModelsDevMetadata.test.ts — 4 cases (200, non-200, throw, JSON parse error)
  • enrichGatewayModel.test.ts — 4 cases (no metadata, context-only, cost-only, no mutation)
  • getAvailableModels.test.ts updated — 5 cases now mock both gateway + metadata

28/28 ai tests pass. Format + lint:check clean.

Test plan

  • CI green
  • Preview deployment: GET /api/ai/models returns models with context_window + cost populated for entries that exist in models.dev
  • Field-by-field comparison vs sandbox.recoupable.com/api/models response

🤖 Generated with Claude Code


Summary by cubic

Adds context and pricing to model listings so the frontend can filter by max tokens and show costs. GET /api/ai/models now returns enriched models and still works if models.dev is down.

  • New Features

    • Adds context_window (max tokens) and cost {input, output} (USD per million) per model, sourced from models.dev.
    • Applies enrichment to non-embed models when metadata is available.
  • Refactors

    • Fetches @ai-sdk/gateway models and models.dev metadata in parallel; on failure, returns plain gateway models. Fixes typing in enrichGatewayModel to accept gateway entries.
    • Introduces helpers to parse, fetch (750ms timeout), and non-mutating merge of metadata; extracts isRecord into its own utility.

Written for commit 51ee19e. Summary will update on new commits.

Summary by CodeRabbit

  • New Features
    • Models now display additional information including context window and cost details, automatically sourced from an external service. This enrichment provides users with better visibility into model capabilities, constraints, and pricing information, helping them make more informed decisions when selecting models.

…ls.dev

api's GET /api/ai/models previously returned just the gateway entries.
Open-agents' frontend depends on two extra fields per model that come
from the public models.dev catalog:

  - context_window (integer) — gates model selection in the picker
  - cost ({input, output}) — per-million-token pricing for display

Adds three pure helpers (TDD'd individually) plus a small refactor of
the existing fetcher to merge metadata in:

  - lib/ai/parseModelsDevMetadata.ts: tolerant unknown→Map parser
  - lib/ai/fetchModelsDevMetadata.ts: 750ms-bounded fetch with full
    error swallowing (metadata is best-effort, must never gate the
    underlying gateway response)
  - lib/ai/enrichGatewayModel.ts: pure, non-mutating merge

getAvailableModels now fetches gateway + metadata in parallel and
maps each non-embed model through enrichGatewayModel. If models.dev
is unreachable the response is identical to today (gateway models
unenriched).

Documented in recoupable/docs#188 (merged). Unblocks the eventual
open-agents frontend cutover for the model picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview May 5, 2026 1:59pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

📝 Walkthrough

Walkthrough

This PR introduces utilities to enrich gateway models with metadata fetched from the models.dev API. It adds parsing, type-guarding, and enrichment functions that conditionally augment models with context window and cost information while gracefully handling fetch failures.

Changes

Model Metadata Enrichment

Layer / File(s) Summary
Data Shape
lib/ai/parseModelsDevMetadata.ts, lib/ai/enrichGatewayModel.ts
ModelsDevMetadata interface defines optional context_window and cost fields. GatewayModelLike constrains enrichable inputs to objects with an id property.
Parsing & Type Guards
lib/ai/isRecord.ts, lib/ai/parseModelsDevMetadata.ts
isRecord type guard safely narrows unknown payloads to plain objects. parseModelsDevMetadata defensively extracts metadata from a nested provider/model structure, validating numeric fields and constructing a Map keyed by provider/model ids.
Fetching & Enrichment
lib/ai/fetchModelsDevMetadata.ts, lib/ai/enrichGatewayModel.ts
fetchModelsDevMetadata fetches from models.dev/api.json with a 750ms timeout and returns a Map (empty on any failure). enrichGatewayModel clones input models and conditionally copies context_window and cost when present in metadata.
Integration
lib/ai/getAvailableModels.ts
getAvailableModels now concurrently fetches gateway models and metadata, filters non-embed models, and enriches each via enrichGatewayModel.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

✨ Models grow richer with data from the cloud,
Context and cost now wear metadata proud,
Silent graceful failures, no thunder or sound,
Enriched gateway gateways on solid ground! 🌟

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Solid & Clean Code ⚠️ Warning Cost validation missing non-negative checks. The cost.input/output lack >= 0 validation that context_window has, risking invalid negative pricing in API output. Add >= 0 validation to cost.input and cost.output in parseModelsDevMetadata.ts (lines 43-44)
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ai-models-enrich-context-cost

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 8 files

Confidence score: 4/5

  • This PR looks safe to merge with minimal risk: the reported items are moderate-to-low severity (5/10, 4/10, 3/10) and mostly input-validation/style hardening rather than clear breakage.
  • The most significant issue is in lib/ai/parseModelsDevMetadata.ts: context_window should be validated as a positive integer, otherwise malformed metadata could be enriched and lead to incorrect model limits downstream.
  • Also in lib/ai/parseModelsDevMetadata.ts, negative cost.input/cost.output values should be rejected to prevent invalid pricing metadata from being accepted; the test-file size note in lib/ai/__tests__/parseModelsDevMetadata.test.ts is maintainability-focused and non-blocking.
  • Pay close attention to lib/ai/parseModelsDevMetadata.ts, lib/ai/__tests__/parseModelsDevMetadata.test.ts - tighten numeric validation for metadata fields and address test file-size policy compliance.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/ai/parseModelsDevMetadata.ts">

<violation number="1" location="lib/ai/parseModelsDevMetadata.ts:37">
P2: Validate `context_window` as a positive integer before enrichment.</violation>

<violation number="2" location="lib/ai/parseModelsDevMetadata.ts:45">
P2: Reject negative `cost.input`/`cost.output` values when parsing metadata.</violation>
</file>

<file name="lib/ai/__tests__/parseModelsDevMetadata.test.ts">

<violation number="1" location="lib/ai/__tests__/parseModelsDevMetadata.test.ts:1">
P3: Custom agent: **Enforce Clear Code Style and Maintainability Practices**

This new test file exceeds the repository’s 100-line file-size limit.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as Frontend Client
    participant API as API Route (/api/ai/models)
    participant GAM as getAvailableModels()
    participant Gateway as @ai-sdk/gateway
    participant FMDM as fetchModelsDevMetadata()
    participant ModelsDev as models.dev
    participant PMDM as parseModelsDevMetadata()
    participant EGM as enrichGatewayModel()

    Note over Client,EGM: GET /api/ai/models — Model Listing Request

    Client->>API: GET /api/ai/models

    Note over API,GAM: NEW: Parallel fetch pattern

    API->>GAM: getAvailableModels()

    GAM->>Gateway: gateway.getAvailableModels()
    GAM->>FMDM: fetchModelsDevMetadata()

    activate Gateway
    activate FMDM

    Note over FMDM: NEW: 750ms timeout with AbortController

    FMDM->>ModelsDev: fetch(https://models.dev/api.json)
    ModelsDev-->>FMDM: API.json payload

    alt Response ok & valid JSON
        FMDM->>PMDM: parseModelsDevMetadata(data)
        PMDM-->>FMDM: Map<id, {context_window, cost}>
        FMDM-->>GAM: Metadata map
    else Non-200, timeout, or parse error
        FMDM-->>GAM: Empty Map (best-effort)
    end

    Gateway-->>GAM: List of gateway models

    deactivate FMDM
    deactivate Gateway

    Note over GAM: NEW: Enrich non-embed models

    GAM->>GAM: Filter embed models
    GAM->>EGM: enrichGatewayModel(model, metadataMap)

    Note over EGM: NEW: Non-mutating merge

    alt Metadata found for model
        EGM-->>GAM: Model with context_window + cost
    else No metadata
        EGM-->>GAM: Unchanged model
    end

    GAM-->>API: Enriched model list
    API-->>Client: 200 JSON with context_window + cost
Loading

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.


if (
isRecord(modelValue.cost) &&
typeof modelValue.cost.input === "number" &&
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Reject negative cost.input/cost.output values when parsing metadata.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/ai/parseModelsDevMetadata.ts, line 45:

<comment>Reject negative `cost.input`/`cost.output` values when parsing metadata.</comment>

<file context>
@@ -0,0 +1,58 @@
+
+      if (
+        isRecord(modelValue.cost) &&
+        typeof modelValue.cost.input === "number" &&
+        typeof modelValue.cost.output === "number"
+      ) {
</file context>


if (
isRecord(modelValue.limit) &&
typeof modelValue.limit.context === "number" &&
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Validate context_window as a positive integer before enrichment.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/ai/parseModelsDevMetadata.ts, line 37:

<comment>Validate `context_window` as a positive integer before enrichment.</comment>

<file context>
@@ -0,0 +1,58 @@
+
+      if (
+        isRecord(modelValue.limit) &&
+        typeof modelValue.limit.context === "number" &&
+        modelValue.limit.context > 0
+      ) {
</file context>

@@ -0,0 +1,106 @@
import { describe, it, expect } from "vitest";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Custom agent: Enforce Clear Code Style and Maintainability Practices

This new test file exceeds the repository’s 100-line file-size limit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/ai/__tests__/parseModelsDevMetadata.test.ts, line 1:

<comment>This new test file exceeds the repository’s 100-line file-size limit.</comment>

<file context>
@@ -0,0 +1,106 @@
+import { describe, it, expect } from "vitest";
+import { parseModelsDevMetadata } from "@/lib/ai/parseModelsDevMetadata";
+
</file context>

Comment thread lib/ai/parseModelsDevMetadata.ts Outdated
cost?: { input: number; output: number };
}

function isRecord(value: unknown): value is Record<string, unknown> {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SRP - new lib for isRecord

Per PR feedback: each file should export one primary function.
Pulled isRecord out of parseModelsDevMetadata.ts into
lib/ai/isRecord.ts so the parser file is single-purpose.

Also includes the typecheck fix for enrichGatewayModel — the
`[key: string]: unknown` index signature on its generic constraint
was rejecting `GatewayLanguageModelEntry` and breaking the Vercel
build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sweetmantech
Copy link
Copy Markdown
Contributor Author

3-way comparison: PR preview ↔ open-agents production ↔ docs

Smoke-tested against https://api-git-feat-ai-models-enrich-context-cost-recoup.vercel.app/api/ai/models (commit 51ee19e0). Compared against open-agents production https://sandbox.recoupable.com/api/models and the merged docs spec at https://developers.recoupable.com/api-reference/ai/models.

Coverage on the PR preview

  • 192 models total
  • 189 (98%) include context_window
  • 184 (95%) include cost
  • 200 unauth — endpoint stays public per spec ✓
  • The 3–8 un-enriched models (e.g. minimax/minimax-m2.5-highspeed, openai/gpt-image-1-mini, openai/gpt-image-2) are simply absent from the upstream models.dev catalog. Graceful degradation working as designed — they return without context_window / cost, matching the spec's additionalProperties: true schema.

Single-model side-by-side (alibaba/qwen-3-14b)

This PR's preview:

{
  "id": "alibaba/qwen-3-14b",
  "name": "Qwen3-14B",
  "description": "Qwen3 is the latest generation of large language models in Qwen series…",
  "pricing": { "input": "0.00000012", "output": "0.00000024" },
  "specification": { "specificationVersion": "v2", "provider": "deepinfra", "modelId": "alibaba/qwen-3-14b" },
  "modelType": "language",
  "context_window": 40960,
  "cost": { "input": 0.06, "output": 0.24 }
}

Open-agents production (same model):

{
  "id": "alibaba/qwen-3-14b",
  "name": "Qwen3-14B",
  "description": "Qwen3 is the latest generation of large language models in Qwen series…",
  "pricing": { "input": "0.00000012", "output": "0.00000024" },
  "specification": { "specificationVersion": "v3", "provider": "deepinfra", "modelId": "alibaba/qwen-3-14b" },
  "modelType": "language",
  "context_window": 40960,
  "cost": { "input": 0.06, "output": 0.24 }
}

vs docs spec (recoupable/docs#188 merged)

Field Docs says api preview Match
id, name, description, modelType string string
pricing, specification object (additionalProperties: true) objects
context_window integer 40960
cost {input: number, output: number} {input: 0.06, output: 0.24}
200 response {models: [...]} {models: [...]}
500 response {message: string} (handler returns this on error; not exercised live)
Auth "No authentication required" confirmed 200 unauth

Result: zero divergence from the docs spec.

vs open-agents production

Field open-agents api preview Diff
id, name, description, pricing, modelType identical identical none
context_window, cost (when present) identical numeric values identical none
specification.specificationVersion "v3" "v2" api uses an older @ai-sdk/gateway version — outside this PR's scope. Field-level fix is just pnpm up @ai-sdk/gateway in api.
Model count 193 192 catalog drift (different @ai-sdk/gateway versions surface different upstream sets): api has openai/gpt-5.4-pro, openai/gpt-image-* that open-agents lacks; open-agents has perplexity/sonar*, meituan/longcat-flash-*, zai/glm-4.6v-flash that api lacks.

What this PR fixes vs what remains

  • Closed by this PR: the two fields the open-agents model picker reads (context_window filters models below user's token budget; cost populates the price column). Cutover blocker for the model picker is gone.
  • Outside this PR: @ai-sdk/gateway version pin drift causes specificationVersion and the model-catalog delta. A separate small PR can bump api's gateway dep to match open-agents.

Test sessions cleanup

This endpoint is read-only; no rows created during the smoke test.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
lib/ai/parseModelsDevMetadata.ts (1)

17-56: 🏗️ Heavy lift

Split parser internals into small helpers to keep this function within guideline limits.

parseModelsDevMetadata is doing traversal + id normalization + context parsing + cost parsing in one block. Please extract at least metadata extraction and model-id normalization helpers to reduce size and nesting.

♻️ Proposed refactor sketch
 export function parseModelsDevMetadata(data: unknown): Map<string, ModelsDevMetadata> {
   const map = new Map<string, ModelsDevMetadata>();
   if (!isRecord(data)) return map;

   for (const [providerKey, providerValue] of Object.entries(data)) {
     if (!isRecord(providerValue)) continue;
     if (!isRecord(providerValue.models)) continue;

     for (const [modelKey, modelValue] of Object.entries(providerValue.models)) {
       if (!isRecord(modelValue)) continue;
-      const rawId = typeof modelValue.id === "string" ? modelValue.id : modelKey;
-      const modelId = rawId.includes("/") ? rawId : `${providerKey}/${rawId}`;
-      const meta: ModelsDevMetadata = {};
-      // inline parsing...
+      const modelId = toModelId(providerKey, modelKey, modelValue);
+      const meta = extractMetadata(modelValue);
       if (meta.context_window !== undefined || meta.cost !== undefined) {
         map.set(modelId, meta);
       }
     }
   }

   return map;
 }

As per coding guidelines, **/*.{js,ts,tsx,jsx,py,java,cs,go,rb,php}: “Flag functions longer than 20 lines” and “Keep functions small and focused.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ai/parseModelsDevMetadata.ts` around lines 17 - 56, Split
parseModelsDevMetadata by extracting two helpers: implement a
normalizeModelId(providerKey: string, modelKey: string, modelValue: unknown):
string that returns the correct rawId and prefixes providerKey when needed (used
where modelId is computed), and implement extractModelMeta(modelValue: unknown):
ModelsDevMetadata | undefined that encapsulates the context_window and cost
parsing (move the isRecord checks and numeric validations into this helper and
return undefined when no metadata). Then simplify parseModelsDevMetadata to only
traverse provider/model entries, call normalizeModelId to get modelId and call
extractModelMeta to get meta, and only map.set(modelId, meta) when meta is
returned; keep existing symbol names (parseModelsDevMetadata, modelId, meta) to
make the change local and reduce nesting.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/ai/parseModelsDevMetadata.ts`:
- Around line 41-47: The cost assignment currently accepts any numeric values;
update the guard in parseModelsDevMetadata (the block that checks
isRecord(modelValue.cost) and typeof modelValue.cost.input/output) to also
verify both modelValue.cost.input and modelValue.cost.output are non-negative
(>= 0) before assigning meta.cost; if either is negative, do not set meta.cost
(or explicitly skip/omit it) so invalid negative USD pricing is not propagated.

---

Nitpick comments:
In `@lib/ai/parseModelsDevMetadata.ts`:
- Around line 17-56: Split parseModelsDevMetadata by extracting two helpers:
implement a normalizeModelId(providerKey: string, modelKey: string, modelValue:
unknown): string that returns the correct rawId and prefixes providerKey when
needed (used where modelId is computed), and implement
extractModelMeta(modelValue: unknown): ModelsDevMetadata | undefined that
encapsulates the context_window and cost parsing (move the isRecord checks and
numeric validations into this helper and return undefined when no metadata).
Then simplify parseModelsDevMetadata to only traverse provider/model entries,
call normalizeModelId to get modelId and call extractModelMeta to get meta, and
only map.set(modelId, meta) when meta is returned; keep existing symbol names
(parseModelsDevMetadata, modelId, meta) to make the change local and reduce
nesting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: dd433c78-d661-495a-b395-19754b84b65f

📥 Commits

Reviewing files that changed from the base of the PR and between d525c6d and 51ee19e.

⛔ Files ignored due to path filters (5)
  • lib/ai/__tests__/enrichGatewayModel.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/ai/__tests__/fetchModelsDevMetadata.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/ai/__tests__/getAvailableModels.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/ai/__tests__/isRecord.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/ai/__tests__/parseModelsDevMetadata.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (5)
  • lib/ai/enrichGatewayModel.ts
  • lib/ai/fetchModelsDevMetadata.ts
  • lib/ai/getAvailableModels.ts
  • lib/ai/isRecord.ts
  • lib/ai/parseModelsDevMetadata.ts

Comment on lines +41 to +47
if (
isRecord(modelValue.cost) &&
typeof modelValue.cost.input === "number" &&
typeof modelValue.cost.output === "number"
) {
meta.cost = { input: modelValue.cost.input, output: modelValue.cost.output };
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate cost values as non-negative before adding metadata.

cost.input/cost.output are accepted as any numbers. Negative values would propagate invalid USD pricing into API output.

🛡️ Proposed fix
       if (
         isRecord(modelValue.cost) &&
         typeof modelValue.cost.input === "number" &&
-        typeof modelValue.cost.output === "number"
+        typeof modelValue.cost.output === "number" &&
+        modelValue.cost.input >= 0 &&
+        modelValue.cost.output >= 0
       ) {
         meta.cost = { input: modelValue.cost.input, output: modelValue.cost.output };
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (
isRecord(modelValue.cost) &&
typeof modelValue.cost.input === "number" &&
typeof modelValue.cost.output === "number"
) {
meta.cost = { input: modelValue.cost.input, output: modelValue.cost.output };
}
if (
isRecord(modelValue.cost) &&
typeof modelValue.cost.input === "number" &&
typeof modelValue.cost.output === "number" &&
modelValue.cost.input >= 0 &&
modelValue.cost.output >= 0
) {
meta.cost = { input: modelValue.cost.input, output: modelValue.cost.output };
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/ai/parseModelsDevMetadata.ts` around lines 41 - 47, The cost assignment
currently accepts any numeric values; update the guard in parseModelsDevMetadata
(the block that checks isRecord(modelValue.cost) and typeof
modelValue.cost.input/output) to also verify both modelValue.cost.input and
modelValue.cost.output are non-negative (>= 0) before assigning meta.cost; if
either is negative, do not set meta.cost (or explicitly skip/omit it) so invalid
negative USD pricing is not propagated.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 4 files (changes from recent commits).

Requires human review: Auto-approval blocked by 3 unresolved issues from previous reviews.

@sweetmantech
Copy link
Copy Markdown
Contributor Author

Skipping the cubic + coderabbit suggestions to reject negative cost values and enforce Number.isInteger on context_window. Data source is the public models.dev catalog — a negative price or fractional context window would be a data-quality bug upstream, and silently filtering those entries hides it rather than surfacing it. The existing isRecord + typeof === "number" guards already cover the realistic failure mode (missing/malformed entries). Adding the extra checks is defensive code for scenarios that don't happen.

@sweetmantech sweetmantech merged commit 5752e11 into test May 5, 2026
6 checks passed
@sweetmantech sweetmantech deleted the feat/ai-models-enrich-context-cost branch May 5, 2026 14:09
sweetmantech added a commit that referenced this pull request May 5, 2026
* refactor(sandbox): callers use open-agents abstraction (Phase 2.2) (#509)

* refactor(sandbox): callers use open-agents abstraction (Phase 2.2)

Replaces direct @vercel/sandbox SDK calls with the open-agents sandbox
abstraction layer (inlined in Phase 2.1) for sandbox lifecycle (create
+ reconnect). HTTP response shapes preserved exactly.

Per the agreed Option B (hybrid): only the lifecycle creator helpers
get refactored. installClaudeCode / runClaudeCode / getSandboxStatus
stay on the SDK directly because the abstraction does not cover their
needs (sudo, stdout/stderr streaming, simple status reads). Those
two install/run files are also dead orphans (defined but never called)
and will be removed entirely after the full migration.

Production refactor:
  createSandbox.ts            Sandbox.create(...) -> VercelSandbox.create(...)
                              Input: VercelSandboxConfig (was SDK params)
                              Snapshot trigger: restoreSnapshotId field
                                (was source: { type: "snapshot", ... })
                              Returns VercelSandbox (was SDK Sandbox)
  createSandboxWithFallback.ts cascade — passes restoreSnapshotId to createSandbox
  createSandboxFromSnapshot.ts type cascade only (Sandbox -> VercelSandbox)
  getActiveSandbox.ts         Sandbox.get({name}) -> VercelSandbox.connect(name, {})
                              Status check: sandbox.status -> sandbox.sdkStatus
  getOrCreateSandbox.ts       no code change — type cascades automatically
  processCreateSandbox.ts     reads sandbox.sdkStatus instead of sandbox.status
                              defensive nullish on createdAt

Abstraction extension:
  vercel/sandbox/VercelSandbox.ts adds two readonly getters following
  the existing host/environmentDetails/expiresAt pattern:
    get sdkStatus(): string  — raw SDK session status (running/pending/
                                stopped/failed/aborted/snapshotting),
                                distinct from the abstraction's normalized
                                status getter
    get createdAt(): Date | undefined  — SDK session.createdAt

  These give api callers what they need to construct the existing
  HTTP response shape without breaking the abstraction's interface.

Tests updated:
  createSandbox.test.ts            mocks VercelSandbox.create instead of
                                    Sandbox.create; mock object uses
                                    sdkStatus instead of status
  createSandboxWithFallback.test.ts asserts restoreSnapshotId pass-through
  getActiveSandbox.test.ts         mocks VercelSandbox.connect; sdkStatus
                                    on mock objects
  processCreateSandbox.test.ts     mockSandbox uses sdkStatus

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2391/2391 pass
  - HTTP response shape unchanged: same fields, same enum values for
    sandboxStatus (sourced from the SDK now via sdkStatus, was directly
    via SDK Sandbox.status before — identical strings either way)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address PR #509 review feedback

Three real issues from CodeRabbit + cubic:

1. createdAt staleness (CodeRabbit minor)
   The new `createdAt` getter on VercelSandbox skipped the
   `refreshStateFromCurrentSession()` step that `sdkStatus` uses, so
   readers right after a reconnect could see stale session metadata.
   Add the refresh.

2. Fabricated createdAt (cubic P2)
   Both createSandbox.ts and processCreateSandbox.ts had a
   `?? new Date().toISOString()` fallback that fabricated creation
   metadata when sandbox.createdAt was missing. The SDK guarantees
   createdAt is populated for any reachable instance, so the fallback
   was both wrong (fabricates data) and unnecessary.

   Tighten the getter to return `Date` (not `Date | undefined`) and
   throw with an explicit "SDK contract violation" message if the
   field is missing — fail-fast surfaces a real contract bug instead
   of silently lying.

   Drop the `?? new Date()` fallbacks at both call sites.

3. Misleading snapshot-restore branching (CodeRabbit major)
   createSandbox.ts had two paths — a "snapshot" branch that omitted
   DEFAULT_VCPUS/DEFAULT_RUNTIME (intent: let snapshot dictate), and
   a "fresh" branch that applied defaults. But VercelSandbox.create
   internally defaults vcpus=4 and runtime="node22" regardless, so
   the omission was a no-op — the abstraction always forwarded those
   to the SDK.

   Drop the misleading branching. Document the actual behavior at
   the top of createSandbox: "VercelSandbox.create applies its own
   defaults regardless of source — those apply to the runtime
   resources of the new sandbox even when restoring from a snapshot."

   Updated the snapshot-restore test to assert the actual call shape
   (vcpus + runtime + timeout + restoreSnapshotId) instead of just
   the original SDK-style truncated args.

Verification:
- pnpm lint:check: clean
- pnpm test: 2391/2391 pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(sandbox): delete dead Claude Code helpers (Phase 2.3) (#512)

* chore(sandbox): delete dead Claude Code helpers (Phase 2.3)

installClaudeCode and runClaudeCode were defined but never imported
anywhere in api production code — confirmed by grep on main:

  $ grep -rn "installClaudeCode\b\|runClaudeCode\b" lib/ app/
  lib/sandbox/installClaudeCode.ts:9: export async function installClaudeCode(...)
  lib/sandbox/runClaudeCode.ts:10:    export async function runClaudeCode(...)

Both files were skipped during the Phase 2.2 abstraction refactor
(per the agreed Option B — they used SDK features the abstraction
doesn't expose: sudo, stdout/stderr streaming, batched writes). With
the broader migration moving to Vercel Workflow + open-agents' agent
package for sandbox bootstrap, these orphans have no path to being
called again.

Removed:
  lib/sandbox/installClaudeCode.ts                (32 lines)
  lib/sandbox/runClaudeCode.ts                    (29 lines)
  lib/sandbox/__tests__/installClaudeCode.test.ts (4 tests)
  lib/sandbox/__tests__/runClaudeCode.test.ts     (6 tests)

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2381/2381 pass (was 2391 — net -10 tests from the
    two deleted test files)

Note: getOrCreateSandbox.ts also has zero importers per the audit
and is similarly dead, but is intentionally NOT deleted in this PR
since it was not explicitly flagged as orphan in the migration plan.
Worth a separate follow-up decision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(sandbox): also delete getOrCreateSandbox + getActiveSandbox (YAGNI)

Cascade audit found two more truly-dead helpers per YAGNI:

  getOrCreateSandbox.ts    0 importers (self-only references)
  getActiveSandbox.ts      only called by getOrCreateSandbox — orphan
                            once that goes

Removed:
  lib/sandbox/getOrCreateSandbox.ts                (39 lines)
  lib/sandbox/getActiveSandbox.ts                  (33 lines)
  lib/sandbox/__tests__/getOrCreateSandbox.test.ts (3 tests)
  lib/sandbox/__tests__/getActiveSandbox.test.ts   (4 tests)

Live consumers of related helpers preserved:
  - createSandboxFromSnapshot still used by processCreateSandbox
  - selectAccountSandboxes still used by aggregateAccountSandboxStats,
    buildGetSandboxesParams, getSandboxesHandler, validateGetSandboxesRequest

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2374/2374 pass (was 2381 — net -7 from the two deleted
    test files; -3 from getOrCreateSandbox.test.ts + -4 from
    getActiveSandbox.test.ts)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sessions): port POST /api/sessions from open-agents (#515)

* feat(sessions): port GET /api/sessions/[sessionId] from open-agents (Phase 2.4 — first route)

First route in the route-by-route cutover plan. Strategy: open-agents
frontend stays unchanged in shape; api ports each route it calls in
priority order (simplest first), and the open-agents frontend gets
cut over to api one route at a time.

Why this route first:
- Pure DB read (single-row select by id) — no agent runner, no Vercel
  Workflow, no sandbox runtime
- Hits sessions table already migrated in database PR #20
- Frontend usage: agents-frontend hits /api/sessions/{id} on session
  detail page navigation
- Smallest possible blast radius for proving the cutover pattern

Files added:
  lib/supabase/sessions/selectSession.ts  Single-row helper + SessionRow
                                          type (hand-typed; database.types.ts
                                          regen pending — flagged in code
                                          comment)
  app/api/sessions/[sessionId]/route.ts   GET handler matching open-agents
                                          response shape exactly (camelCase
                                          fields, "userId" preserved on the
                                          wire even though stored as
                                          account_id internally)
  app/api/sessions/[sessionId]/__tests__/route.test.ts (5 tests)

Auth: validateAuthContext (Privy Bearer or x-api-key). Response codes
match open-agents: 200 happy path, 401 no auth, 403 not owner, 404 not
found.

Wire-format translation: snake_case Supabase row -> camelCase response,
with account_id surfaced as userId so the existing open-agents frontend
fetches with zero code changes. Translation lives at the route boundary
(toSessionResponse) where it is easy to remove once chat absorbs this
UI and we can switch to schema-natural naming.

Verification:
- pnpm lint:check: clean
- pnpm test: 2379/2379 pass (5 new for this route)

Up next:
- Cutover step (separate PR in open-agents): point the frontend at
  api's URL for this single route. Validate end-to-end before porting
  the next route.
- Next routes in priority order (still pure DB, no agent/workflow):
  GET /api/sessions (list with unread — needs Postgres RPC for the
  multi-table aggregation), GET /api/sessions/[id]/chats, GET
  /api/sessions/[id]/chats/[chatId].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address PR review — SRP splits + use Tables<\"sessions\"> from regen'd types

Three review comments on PR #514:

1. SRP: extract toSessionResponse to its own file
   was: defined inline in app/api/sessions/[sessionId]/route.ts
   now: lib/sessions/toSessionResponse.ts (one exported fn per file)

2. SRP: add a handler function (mirroring api convention)
   was: GET handler logic inline in route.ts
   now: lib/sessions/getSessionByIdHandler.ts contains all the auth +
        ownership + DB lookup + response logic; route.ts is a thin
        shell that awaits options.params and delegates. Matches the
        pattern used by every other api route (e.g. socials/[id]/scrape,
        artists/[id]/...).

3. DRY: use existing db schema type
   was: hand-typed SessionRow interface in selectSession.ts
   now: Tables<\"sessions\"> from types/database.types.ts (regenerated
        via npx supabase gen types typescript --project-id ...
        --schema public)

The types regen also resolved the preview-build failure
(\"Type instantiation is excessively deep and possibly infinite\") on
the .from(\"sessions\") call — Supabase's type inference was choking
because the table was unknown to the generic.

Files added:
  lib/sessions/toSessionResponse.ts
  lib/sessions/getSessionByIdHandler.ts

Files modified:
  app/api/sessions/[sessionId]/route.ts        thin shell now
  app/api/sessions/[sessionId]/__tests__/
    route.test.ts                              type alias updated
  lib/supabase/sessions/selectSession.ts       Tables<\"sessions\">
  types/database.types.ts                      Supabase regen

Verification:
  - pnpm lint:check: clean
  - pnpm test: 2379/2379 pass (no test changes; same 5 route tests)
  - tsc compile clean (the local pnpm build progresses past compile
    into page-data collection where it fails on missing local env
    vars — Vercel preview will have those set, so the preview rebuild
    should now succeed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sessions): make 404/403 errors emit status:"error" for shape consistency

The 401 returned by validateAuthContext shaped like
{status:"error", error:"..."} but 404/403 from this handler returned
{error:"..."} only. Same endpoint, two error shapes — inconsistent for
clients. Align all error responses on the validateAuthContext shape.

Tests now assert the full error body, not just the status code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sessions): port POST /api/sessions from open-agents

Implements the POST /api/sessions contract documented in
recoupable/docs PR #186 + #187. Creates a session row and an
initial chat row; rolls back the session if chat insert fails so
callers never observe an orphaned session.

Auth: validateAuthContext (Privy Bearer or x-api-key).
Validation: Zod schema + GitHub repo segment regex. Body is
optional — empty body creates a session with sensible defaults
(status=running, lifecycle_state=provisioning, sandbox_state.type=
vercel, title="New session").

Out of scope (will follow once database catches up):
  auto_commit_push_override, auto_create_pr_override, pr_number,
  pr_status — these columns don't yet exist on api's sessions
  table, so the docs spec was trimmed accordingly in docs PR #187.

TDD: 9 handler tests cover 401, 400 (sandboxType / repoOwner /
repoName), 200 happy path, branch generation, title pass-through,
500 (insertSession failure), and 500-with-rollback (insertChat
failure). Plus 1 thin test on the route shell.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sessions): add OPTIONS handler + cache directives to POST route

Match the convention from app/api/sessions/[sessionId]/route.ts:
- OPTIONS handler returning 200 + CORS headers (preflight)
- dynamic="force-dynamic", fetchCache="force-no-store", revalidate=0

POST routes that mutate DB shouldn't be cached, and browsers issuing
preflight checks (POST with JSON body + custom auth headers) need
OPTIONS to respond.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sessions): address PR review feedback

- SRP: extract insert-row construction to lib/sessions/buildSessionInsertRow.ts
- YAGNI: drop generateSessionBranchName + isNewBranch handling (sessions
  commit to whatever branch the client provides; auto-generation was
  speculative)
- Tighten isValidGitHubRepoOwner: GitHub's actual rules are alphanumeric
  + hyphen only (no `_` or `.`), 1-39 chars, no leading/trailing or
  consecutive hyphens
- Tighten isValidGitHubRepoName: reject reserved `.` and `..`, reject
  `.git` suffix, cap at 100 chars
- Add unit tests for both validators (15 cases) and for the new
  buildSessionInsertRow (4 cases)
- Split createSessionHandler tests into auth/validation + persistence
  files; share fixtures via createSessionHandlerFixtures.ts. All test
  files now under 100 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sessions): address second round of PR review

- 500 message: "Failed to create session" → "Internal server error"
  (per cubic.dev standardized 500 envelope feedback)
- SRP: extract failedToCreateSession to lib/sessions/failedToCreateSession.ts
- YAGNI: drop repoOwner from request body and remove
  isValidGitHubRepoOwner helper entirely (recoupable is the only
  owner; no need to validate)
- YAGNI: drop repoName from request body and remove
  isValidGitHubRepoName helper (repo identity is derived server-side
  from the authenticated account, not accepted from user input)
- Single-export per file: split createSessionHandlerFixtures.ts into
  makeCreateSessionReq.ts, baseSessionRow.ts, baseChatRow.ts.
  okAuth constant inlined where used.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sessions): port random-city title fallback from open-agents

Generated session titles now match the open-agents UX — names like
"Anchorage", "Vienna", "Philadelphia" — instead of every untitled
session being called "New session". Closes a wire-shape gap with
open-agents production identified by the head-to-head test on PR.

Pieces:
- lib/sessions/cityNames.ts: ~200-city curated list (verbatim port)
- lib/sessions/getRandomCityName.ts: pick a city not in `usedNames`,
  numeric-suffix fallback when the curated list is exhausted
- lib/supabase/sessions/selectSessionTitlesByAccountId.ts: Supabase
  helper for collision avoidance
- lib/sessions/resolveSessionTitle.ts: orchestrates provided title
  (trimmed) > random city fallback. Async. Kept separate from the
  insert-row builder so that stays synchronous + pure.
- buildSessionInsertRow now takes `title` as a parameter
- createSessionHandler awaits resolveSessionTitle before building the
  row

TDD: 4 tests for getRandomCityName, 4 for resolveSessionTitle. Handler
tests updated to mock resolveSessionTitle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: remove GET-only files (scope this PR to POST)

The GET endpoint + handler + tests live in PR #514 and were
inadvertently brought in when this branch was rebased after #514's
work. This PR is scoped to POST only; GET ships in #514.

Shared infrastructure stays (types/database.types.ts regen +
lib/sessions/toSessionResponse.ts) — both are required by the POST
handler too. When either #514 or this PR merges to test first, the
other will see those files already present and resolve cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sessions): consolidate request validation + DRY supabase select

Two reviewer asks rolled into one commit:

SRP — validateCreateSessionBody now owns the full validation flow.
The handler used to call safeParseJson, validateAuthContext, and the
Zod body schema separately; that was three places to short-circuit
and three places to duplicate the error envelope. Folded them into
validateCreateSessionBody so the handler does one call → success or
NextResponse error. Returns { body, auth } on success.

DRY — replaced lib/supabase/sessions/selectSession.ts and
selectSessionTitlesByAccountId.ts with a single
selectSessions({ id?, accountId? }) that supports both call sites.
resolveSessionTitle now derives titles from the general fetch.

Tests:
- New validateCreateSessionBody.test.ts covers auth-failure / 400 /
  success / malformed-JSON tolerance (4 cases)
- Handler tests now mock validateCreateSessionBody (single mock
  surface instead of three)
- resolveSessionTitle tests mock selectSessions

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(sessions): address automated review feedback

Four small fixes from the latest round:

1. Zod v4 migration: { message } → { error } on the sandboxType
   literal. v4 unified the error customization API; { message } is
   deprecated.

2. Orphan rollback observability: when insertChat fails AND the
   session-rollback delete also fails, log the session id so ops
   can detect orphaned rows. New persistence test asserts the log.

3. Defensive try/catch in selectSessions so a thrown exception
   (network-level rejection, not a Supabase {error} return) doesn't
   bubble up and 500 the entire session-creation flow.

4. Deterministic test for getRandomCityName suffix-increment: pin
   Math.random instead of looping until the random pick lands on
   baseCity. Previous test could pass without ever asserting if the
   loop cap was hit.

Skipped: cubic-dev-ai's note about logging raw sessionId in
selectSession.ts — that file was deleted earlier in this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: prettier format fix on persistence test

The new orphan-session test had a line that exceeded prettier's wrap
width. Auto-format fixed it; format-check now clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sessions): port GET /api/sessions/[sessionId] from open-agents (#514)

Rebased onto current main (which now has the POST endpoint + shared
infra from PR #515). Three pieces of GET-specific work:

- app/api/sessions/[sessionId]/route.ts: thin shell delegating to the
  handler, plus OPTIONS for CORS preflight + cache directives
- lib/sessions/getSessionByIdHandler.ts: validates auth via
  validateAuthContext, reads via selectSessions({id}), enforces
  ownership (403 if account_id mismatch), 404 if missing
- app/api/sessions/[sessionId]/__tests__/route.test.ts: 5 cases —
  401 / 404 / 403 / 200 happy path / OPTIONS smoke

Uses the new general selectSessions({id}) reader rather than the
deleted single-purpose selectSession helper. All other shared infra
(types, toSessionResponse) is already on main from #515.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ai/models): enrich response with context_window + cost (#518)

* feat(ai/models): enrich response with context_window + cost from models.dev

api's GET /api/ai/models previously returned just the gateway entries.
Open-agents' frontend depends on two extra fields per model that come
from the public models.dev catalog:

  - context_window (integer) — gates model selection in the picker
  - cost ({input, output}) — per-million-token pricing for display

Adds three pure helpers (TDD'd individually) plus a small refactor of
the existing fetcher to merge metadata in:

  - lib/ai/parseModelsDevMetadata.ts: tolerant unknown→Map parser
  - lib/ai/fetchModelsDevMetadata.ts: 750ms-bounded fetch with full
    error swallowing (metadata is best-effort, must never gate the
    underlying gateway response)
  - lib/ai/enrichGatewayModel.ts: pure, non-mutating merge

getAvailableModels now fetches gateway + metadata in parallel and
maps each non-embed model through enrichGatewayModel. If models.dev
is unreachable the response is identical to today (gateway models
unenriched).

Documented in recoupable/docs#188 (merged). Unblocks the eventual
open-agents frontend cutover for the model picker.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ai): extract isRecord into its own lib (SRP)

Per PR feedback: each file should export one primary function.
Pulled isRecord out of parseModelsDevMetadata.ts into
lib/ai/isRecord.ts so the parser file is single-purpose.

Also includes the typecheck fix for enrichGatewayModel — the
`[key: string]: unknown` index signature on its generic constraint
was rejecting `GatewayLanguageModelEntry` and breaking the Vercel
build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant