refactor(ai): reuse JsonObject, ProviderMetadata, and responseHttp across protocols and gateways (#57615)

This commit is contained in:
Aiden Cline 2026-10-20 22:01:28 -05:00 • committed by GitHub
parent 678e49d828
commit 6843fb7b9c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 47 additions and 45 deletions

View file

@ -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 = <A>(
: 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)))

View file

@ -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 = <Options extends EvaluationOptions = EvaluationOptions>(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 = <Options extends EvaluationOptions = EvaluationOptions>(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,

View file

@ -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,
}),
}),

View file

@ -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 {

View file

@ -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({

View file

@ -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<RequestExecutorServi
) {}
export const Service = RequestExecutorService
export type Service = RequestExecutorService
export const headerDetails = (headers: Headers.Headers) =>
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),
})

View file

@ -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<string, string>, 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.

View file

@ -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) =>