From 6843fb7b9c8bbb196d429024ca7f45ca2201f4ef Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 20 Oct 2026 22:01:28 -0500 Subject: [PATCH] refactor(ai): reuse JsonObject, ProviderMetadata, and responseHttp across protocols and gateways (#57615) --- packages/ai/src/experimental/realtime.ts | 4 ++-- packages/ai/src/experimental/system-one.ts | 21 +++++++++++-------- packages/ai/src/media.ts | 6 +++--- packages/ai/src/protocols/venice-chat.ts | 12 +++++------ .../ai/src/providers/vercel-ai-gateway.ts | 13 ++++++------ packages/ai/src/route/executor-service.ts | 14 +++++++++++-- packages/ai/src/route/executor.ts | 14 ++----------- packages/ai/src/route/media-protocol.ts | 8 +++---- 8 files changed, 47 insertions(+), 45 deletions(-) diff --git a/packages/ai/src/experimental/realtime.ts b/packages/ai/src/experimental/realtime.ts index 0473512d95ea..a7bd681984ed 100644 --- a/packages/ai/src/experimental/realtime.ts +++ b/packages/ai/src/experimental/realtime.ts @@ -1,7 +1,7 @@ import { Clock, Effect, Redacted, Schema } from "effect" import { HttpClientRequest } from "effect/http" import { RequestExecutor } from "../route/executor.js" -import { AIError, HttpContext, InvalidProviderOutputError, InvalidRequestError } from "../schema/index.js" +import { AIError, InvalidProviderOutputError, InvalidRequestError } from "../schema/index.js" import { encodeJson } from "../utils/json.js" const OPENAI_BASE_URL = "https://api.openai.com/v1" @@ -157,7 +157,7 @@ const post = ( : HttpClientRequest.bodyText(encodeJson(body), "application/json"), ), ) - const http = new HttpContext({ url: res.request.url, status: res.status, headers: res.headers }) + const http = RequestExecutor.responseHttp(res) const fail = (message: string, cause?: unknown, raw?: string) => new AIError({ reason: new InvalidProviderOutputError({ route, message, body: raw, http, cause }) }) const text = yield* res.text.pipe(Effect.mapError((cause) => fail(`Failed to read ${route} response`, cause))) diff --git a/packages/ai/src/experimental/system-one.ts b/packages/ai/src/experimental/system-one.ts index 3e924f2810e4..e3be4e109cb0 100644 --- a/packages/ai/src/experimental/system-one.ts +++ b/packages/ai/src/experimental/system-one.ts @@ -11,13 +11,15 @@ import { type EvaluationOptions, } from "./evaluation.js" import { Auth, type Definition as AuthDefinition } from "../route/auth.js" +import { RequestExecutor } from "../route/executor.js" +import { JsonObject, ProviderShared } from "../protocols/shared.js" import { AIError, - HttpContext, HttpOptions, InvalidProviderOutputError, InvalidRequestError, ModelID, + ProviderMetadata, Usage, mergeJsonRecords, } from "../schema/index.js" @@ -51,7 +53,7 @@ const Request = Schema.StructWithRest( state: EvaluationInput, questions: Schema.Record(Schema.String, Question), }), - [Schema.Record(Schema.String, Schema.Any)], + [JsonObject], ) const Probability = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 })) @@ -75,7 +77,7 @@ const NativeUsage = Schema.StructWithRest( input_tokens: Schema.optional(Schema.Number), output_tokens: Schema.optional(Schema.Number), }), - [Schema.Record(Schema.String, Schema.Unknown)], + [JsonObject], ) const Response = Schema.Struct({ model: Schema.String, @@ -83,7 +85,7 @@ const Response = Schema.Struct({ usage: Schema.optional(NativeUsage), id: Schema.optional(Schema.String), provider: Schema.optional(Schema.String), - provider_metadata: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))), + provider_metadata: Schema.optional(ProviderMetadata), }) export interface ModelInput { @@ -132,7 +134,7 @@ export const model = (cfg HttpClientRequest.bodyText(body, "application/json"), ), ) - const http = new HttpContext({ url: res.request.url, status: res.status, headers: res.headers }) + const http = RequestExecutor.responseHttp(res) const fail = (message: string, cause: unknown, body?: string) => new AIError({ reason: new InvalidProviderOutputError({ route: "system-one", message, body, http, cause }) }) const text = yield* res.text.pipe( @@ -182,10 +184,11 @@ export const model = (cfg ? new Usage({ inputTokens: data.usage.input_tokens, outputTokens: data.usage.output_tokens, - totalTokens: - data.usage.input_tokens === undefined && data.usage.output_tokens === undefined - ? undefined - : (data.usage.input_tokens ?? 0) + (data.usage.output_tokens ?? 0), + totalTokens: ProviderShared.totalTokens( + data.usage.input_tokens, + data.usage.output_tokens, + undefined, + ), providerMetadata: { [cfg.providerMetadataKey]: data.usage }, }) : undefined, diff --git a/packages/ai/src/media.ts b/packages/ai/src/media.ts index 91c0d832c9eb..0de96d0d21e9 100644 --- a/packages/ai/src/media.ts +++ b/packages/ai/src/media.ts @@ -4,9 +4,9 @@ import { Effect, FileSystem, Schema, SchemaGetter } from "effect" import { Base64 } from "effect/encoding" import { HttpClientRequest } from "effect/http" import { ProviderID } from "./schema/ids.js" -import { AIError, HttpContext, InvalidProviderOutputError, InvalidRequestError } from "./schema/errors.js" +import { AIError, InvalidProviderOutputError, InvalidRequestError } from "./schema/errors.js" import { ProviderMetadata } from "./schema/options.js" -import { Service } from "./route/executor-service.js" +import { responseHttp, Service } from "./route/executor-service.js" import { detectMediaType, fileMediaType } from "./utils/media-type.js" export { detectMediaType } from "./utils/media-type.js" @@ -254,7 +254,7 @@ const download = Effect.fn("Media.download")(function* ( new AIError({ reason: new InvalidProviderOutputError({ message: `Failed to read media from ${source.url}`, - http: new HttpContext({ url: response.request.url, status: response.status, headers: response.headers }), + http: responseHttp(response), cause, }), }), diff --git a/packages/ai/src/protocols/venice-chat.ts b/packages/ai/src/protocols/venice-chat.ts index 01bf830ae1c0..cd2c1b36ecfe 100644 --- a/packages/ai/src/protocols/venice-chat.ts +++ b/packages/ai/src/protocols/venice-chat.ts @@ -87,7 +87,7 @@ const Assistant = Schema.StructWithRest( tool_calls: optionalArray(ToolCall), thought_signature: Schema.optional(Schema.String), }), - [Schema.Record(Schema.String, Schema.Unknown)], + [JsonObject], ) const Messages = Schema.Array( Schema.Union([ @@ -130,11 +130,11 @@ const Delta = Schema.StructWithRest( tool_calls: optionalNull(Schema.Array(ToolDelta)), thought_signature: optionalNull(Schema.String), }), - [Schema.Record(Schema.String, Schema.Unknown)], + [JsonObject], ) const Choice = Schema.StructWithRest( Schema.Struct({ ...OpenAIChat.OpenAIChatChoice.schema.fields, delta: optionalNull(Delta) }), - [Schema.Record(Schema.String, Schema.Unknown)], + [JsonObject], ) const Usage = Schema.StructWithRest( Schema.Struct({ @@ -146,11 +146,11 @@ const Usage = Schema.StructWithRest( cache_write_tokens: optionalNull(Schema.Number), cache_creation_input_tokens: optionalNull(Schema.Number), }), - [Schema.Record(Schema.String, Schema.Unknown)], + [JsonObject], ), ), }), - [Schema.Record(Schema.String, Schema.Unknown)], + [JsonObject], ) const Event = Schema.StructWithRest( Schema.Struct({ @@ -165,7 +165,7 @@ const Event = Schema.StructWithRest( }), ), }), - [Schema.Record(Schema.String, Schema.Unknown)], + [JsonObject], ) interface State { diff --git a/packages/ai/src/providers/vercel-ai-gateway.ts b/packages/ai/src/providers/vercel-ai-gateway.ts index 50111c0ab4a0..ff8dd854c5fc 100644 --- a/packages/ai/src/providers/vercel-ai-gateway.ts +++ b/packages/ai/src/providers/vercel-ai-gateway.ts @@ -11,18 +11,18 @@ import { import { AnthropicMessages } from "../protocols/anthropic-messages.js" import { OpenAIChat } from "../protocols/openai-chat.js" import { OpenResponses } from "../protocols/open-responses.js" -import { optionalNull, ProviderShared } from "../protocols/shared.js" +import { JsonObject, optionalNull, ProviderShared } from "../protocols/shared.js" import { gatewayProtocol } from "../protocols/utils/gateway-protocol.js" import type { ProviderPackage } from "../provider-package.js" import { Auth } from "../route/auth.js" import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js" import { Route, type RouteDefaultsInput } from "../route/client.js" import { Endpoint } from "../route/endpoint.js" +import { RequestExecutor } from "../route/executor.js" import { Framing } from "../route/framing.js" import type { Protocol } from "../route/protocol.js" import { AIError, - HttpContext, HttpOptions, InvalidProviderOutputError, InvalidRequestError, @@ -30,6 +30,7 @@ import { ModelID, ProviderID, ProviderMetadata, + ProviderOptions, ReasoningEffort, Usage, } from "../schema/index.js" @@ -125,7 +126,7 @@ export type Settings = ProviderPackage.Settings & ProviderOptionsInput & { reado const GatewayOptionsSchema = Schema.Struct({ gateway: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), - upstream: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))), + upstream: Schema.optional(ProviderMetadata), reasoningEffort: Schema.optional(ReasoningEffort), thinking: Schema.optional( Schema.Struct({ @@ -233,9 +234,9 @@ const Request = Schema.StructWithRest( model: Schema.String, state: EvaluationInput, questions: Schema.Record(Schema.String, EvaluationQuestion), - providerOptions: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + providerOptions: Schema.optional(ProviderOptions), }), - [Schema.Record(Schema.String, Schema.Any)], + [JsonObject], ) const Response = Schema.Struct({ model: optionalNull(Schema.String), @@ -310,7 +311,7 @@ export const configure = (input: Options = {}) => { HttpClientRequest.bodyText(body, "application/json"), ), ) - const http = new HttpContext({ url: res.request.url, status: res.status, headers: res.headers }) + const http = RequestExecutor.responseHttp(res) const fail = (message: string, cause: unknown, body?: string) => new AIError({ reason: new InvalidProviderOutputError({ diff --git a/packages/ai/src/route/executor-service.ts b/packages/ai/src/route/executor-service.ts index 8087867c9bdc..d81d4e20bb7f 100644 --- a/packages/ai/src/route/executor-service.ts +++ b/packages/ai/src/route/executor-service.ts @@ -1,6 +1,6 @@ import { Context, type Effect } from "effect" -import type { HttpClientRequest, HttpClientResponse } from "effect/http" -import type { AIError } from "../schema/errors.js" +import type { Headers, HttpClientRequest, HttpClientResponse } from "effect/http" +import { HttpContext, type AIError } from "../schema/errors.js" // The service tag lives in its own leaf module so `Media.Asset` (imported by the schema layer) can require the // executor without pulling the full executor implementation, and therefore the schema barrel, into a cycle. @@ -24,3 +24,13 @@ export class RequestExecutorService extends Context.Service + Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, String(value)])) + +export const responseHttp = (response: HttpClientResponse.HttpClientResponse) => + new HttpContext({ + url: response.request.url, + status: response.status, + headers: headerDetails(response.headers), + }) diff --git a/packages/ai/src/route/executor.ts b/packages/ai/src/route/executor.ts index b4ee4246fed2..65850f5e55bc 100644 --- a/packages/ai/src/route/executor.ts +++ b/packages/ai/src/route/executor.ts @@ -9,14 +9,11 @@ import { } from "effect/http" import { HttpContext, HttpRateLimitDetails, AIError, TransportError } from "../schema/index.js" import { classifyProviderFailure, providerErrorMessage } from "../provider-error.js" -import { Service, type HttpMiddleware, type Interface } from "./executor-service.js" +import { headerDetails, responseHttp, Service, type HttpMiddleware, type Interface } from "./executor-service.js" -export { Service } from "./executor-service.js" +export { responseHttp, Service } from "./executor-service.js" export type { HttpHandler, HttpMiddleware, Interface } from "./executor-service.js" -const headerDetails = (headers: Headers.Headers) => - Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, String(value)])) - const normalizedHeaders = (headers: Headers.Headers) => Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value])) @@ -79,13 +76,6 @@ const rateLimitDetails = (headers: Record, retryAfter: number | }) } -export const responseHttp = (response: HttpClientResponse.HttpClientResponse) => - new HttpContext({ - url: response.request.url, - status: response.status, - headers: headerDetails(response.headers), - }) - const MAX_BODY_CHARS = 2000 // Without a recognized message, show the raw body so the provider's explanation is never dropped. diff --git a/packages/ai/src/route/media-protocol.ts b/packages/ai/src/route/media-protocol.ts index 47514a352c99..615c4fa91e42 100644 --- a/packages/ai/src/route/media-protocol.ts +++ b/packages/ai/src/route/media-protocol.ts @@ -3,11 +3,12 @@ import { HttpClientResponse } from "effect/http" import type { Snapshot, Status } from "../generation.js" import { Media } from "../media.js" import type { AuthInput } from "./auth.js" +import { RequestExecutor } from "./executor.js" import { AIError, AuthenticationError, ContentPolicyError, - HttpContext, + type HttpContext, InvalidProviderOutputError, InvalidRequestError, ProviderID, @@ -200,9 +201,6 @@ const FAILURES = { export type Failure = keyof typeof FAILURES -const context = (response: HttpClientResponse.HttpClientResponse) => - new HttpContext({ url: response.request.url, status: response.status, headers: response.headers }) - /** One protocol's route id, display name, and provider, with the decoders and errors that carry them. */ export const identity = (input: { readonly id: string; readonly name: string; readonly provider: string }) => { const provider = ProviderID.make(input.provider) @@ -217,7 +215,7 @@ export const identity = (input: { readonly id: string; readonly name: string; re * `contentPolicy` is a moderated result. */ const text = Effect.fn("MediaProtocol.text")(function* (response: HttpClientResponse.HttpClientResponse) { - const http = context(response) + const http = RequestExecutor.responseHttp(response) const body = yield* response.text.pipe( Effect.mapError( (cause) =>