Skip to content

OpenRouter: generated schemas do not match live API responses (GetGeneration200, ChatGenerationTokenUsage) #6553

Description

@jchild3rs

Generated schemas do not match live OpenRouter responses (GetGeneration200, ChatGenerationTokenUsage)

Package: @effect/ai-openrouter@4.0.0-beta.98 (with effect@4.0.0-beta.98)

Two generated schemas diverge from what the OpenRouter API actually returns. Both were verified against live responses from https://openrouter.ai/api/v1. The first makes an endpoint unusable through the typed client; the second silently drops the per request cost.


1. GetGeneration200 marks nullable fields as required, so client.getGeneration throws on valid responses

GetGeneration200.data (dist/Generated.js) declares several fields as required and non nullable:

"app_id": Schema.Number...,
"moderation_latency": Schema.Number...,
"external_user": Schema.String...,
"num_media_prompt": Schema.Number...,
"num_input_audio_prompt": Schema.Number...,
"num_search_results": Schema.Number...,

The live API returns every one of these as null on a normal, successful generation. Actual response body (trimmed):

{
  "data": {
    "id": "gen-...",
    "total_cost": 0.003294,
    "app_id": null,
    "moderation_latency": null,
    "external_user": null,
    "num_media_prompt": null,
    "num_input_audio_prompt": null,
    "num_search_results": null,
    "model": "openai/gpt-5.6-sol-20260709",
    "streamed": true
  }
}

Because the schema requires non null values for those keys, decoding a valid 200 response fails with a SchemaError, so client.getGeneration cannot be used to read generation stats (including total_cost) at all.

Repro

import { OpenRouterClient } from "@effect/ai-openrouter"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"

const program = Effect.gen(function* () {
  const { client } = yield* OpenRouterClient.OpenRouterClient
  // any completed generation id from an earlier request
  const res = yield* client.getGeneration({ params: { id: "gen-..." } })
  console.log(res.data.total_cost)
})

program.pipe(
  Effect.provide(
    OpenRouterClient.layerConfig({ apiKey: /* redacted */ }).pipe(
      Layer.provide(FetchHttpClient.layer)
    )
  ),
  Effect.runPromise
) // fails: SchemaError, app_id/moderation_latency/external_user/... expected number|string, got null

Suggested fix: make these fields nullable (and/or optional) to match the API, for example Schema.NullOr(Schema.Number). It likely applies to more of the data fields than the six above; the safe move is to audit GetGeneration200.data against a real response.


2. ChatGenerationTokenUsage omits cost (and is_byok) that the client itself opts into

OpenRouterClient sends stream_options.include_usage: true on every streamed completion (dist/OpenRouterClient.js):

stream_options: {
  include_usage: true
}

With that flag OpenRouter includes cost on the final streamed usage object. Actual usage chunk on the wire:

"usage": {
  "prompt_tokens": 7,
  "completion_tokens": 11,
  "total_tokens": 18,
  "cost": 0.000365,
  "is_byok": false,
  "prompt_tokens_details": { "cached_tokens": 0, "cache_write_tokens": 0 }
}

But ChatGenerationTokenUsage (dist/Generated.js) declares only:

Schema.Struct({
  "completion_tokens": Schema.Number...,
  "prompt_tokens": Schema.Number...,
  "total_tokens": Schema.Number...,
  "completion_tokens_details": Schema.optionalKey(...),
  "prompt_tokens_details": Schema.optionalKey(...)
})

With Effect Schema's default onExcessProperty: "ignore", cost and is_byok are stripped during decode. The result is that a consumer cannot read per request dollar cost even though the client requested it, and it never surfaces on the finish part's metadata.openrouter.usage.

The endpoint in issue 1 does expose total_cost, but only after a delay of roughly 9 seconds post generation (it returns 404 until then), so the inline stream field is the only way to get cost promptly.

Suggested fix: add the fields OpenRouter sends on the streaming usage object, at minimum:

"cost": Schema.optionalKey(Schema.Number.check(Schema.isFinite())),
"is_byok": Schema.optionalKey(Schema.Boolean),

Notes

  • Both root causes look like OpenAPI spec drift: OpenRouter's cost is a non standard extension and the nullable generation fields may not be documented as nullable, so the generator produced schemas stricter or narrower than reality. Faithful generation, incomplete source spec.
  • Workaround in use: a local patch adds cost back to ChatGenerationTokenUsage. Issue 1 is worked around by not using client.getGeneration and reading the inline stream cost instead.

Environment: @effect/ai-openrouter@4.0.0-beta.98, effect@4.0.0-beta.98, Node (fetch based FetchHttpClient), verified against https://openrouter.ai/api/v1 with model route openrouter/auto.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions