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.
Generated schemas do not match live OpenRouter responses (
GetGeneration200,ChatGenerationTokenUsage)Package:
@effect/ai-openrouter@4.0.0-beta.98(witheffect@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.
GetGeneration200marks nullable fields as required, soclient.getGenerationthrows on valid responsesGetGeneration200.data(dist/Generated.js) declares several fields as required and non nullable:The live API returns every one of these as
nullon 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, soclient.getGenerationcannot be used to read generation stats (includingtotal_cost) at all.Repro
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 thedatafields than the six above; the safe move is to auditGetGeneration200.dataagainst a real response.2.
ChatGenerationTokenUsageomitscost(andis_byok) that the client itself opts intoOpenRouterClientsendsstream_options.include_usage: trueon every streamed completion (dist/OpenRouterClient.js):With that flag OpenRouter includes
coston the final streamed usage object. Actual usage chunk on the wire:But
ChatGenerationTokenUsage(dist/Generated.js) declares only:With Effect Schema's default
onExcessProperty: "ignore",costandis_byokare 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'smetadata.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:
Notes
costis 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.costback toChatGenerationTokenUsage. Issue 1 is worked around by not usingclient.getGenerationand reading the inline stream cost instead.Environment:
@effect/ai-openrouter@4.0.0-beta.98,effect@4.0.0-beta.98, Node (fetch basedFetchHttpClient), verified againsthttps://openrouter.ai/api/v1with model routeopenrouter/auto.