diff --git a/packages/ai/src/llm.ts b/packages/ai/src/llm.ts index f28804927b1a..8c57165a9bfb 100644 --- a/packages/ai/src/llm.ts +++ b/packages/ai/src/llm.ts @@ -37,19 +37,17 @@ export type RequestInput( - input: RequestInput, + input: RequestInput | LLMRequest, options?: StreamOptions, ): Effect.Effect -export function generate(input: LLMRequest, options?: StreamOptions): Effect.Effect export function generate(input: RequestInput | LLMRequest, options?: StreamOptions) { return requestEffect(input).pipe(Effect.flatMap((request) => LLMClient.generate(request, options))) } export function stream( - input: RequestInput, + input: RequestInput | LLMRequest, options?: StreamOptions, ): Stream.Stream -export function stream(input: LLMRequest, options?: StreamOptions): Stream.Stream export function stream(input: RequestInput | LLMRequest, options?: StreamOptions) { return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => LLMClient.stream(request, options)))) } diff --git a/packages/ai/src/promise.ts b/packages/ai/src/promise.ts index d4dd8dddc606..fe13b006cc06 100644 --- a/packages/ai/src/promise.ts +++ b/packages/ai/src/promise.ts @@ -4,9 +4,8 @@ import type { AwaitOptions, Event, Generation, Snapshot } from "./generation.js" import { Image, type ImageModel, type ImageRequest, type ImageRequestInput } from "./image.js" import { LLM } from "./index.js" import { Media } from "./media.js" -import { tryRequest } from "./media-model.js" import { RequestExecutor } from "./route/executor.js" -import { AIError, InvalidRequestError, LanguageModel, LLMRequest } from "./schema/index.js" +import { AIError, InvalidRequestError, LanguageModel, type LLMRequest } from "./schema/index.js" import type { RequestInput } from "./llm.js" import { Speech, type SpeechModel, type SpeechRequest, type SpeechRequestInput } from "./speech.js" import { @@ -93,9 +92,6 @@ export const make = (options: Options = {}) => { cancel: (options) => run(generation.cancel(), options), }) - const llmRequest = (input: RequestInput | LLMRequest) => - input instanceof LLMRequest ? Effect.succeed(input) : tryRequest(() => LLM.request(input)) - return { run, /** Decoded asset bytes, downloading `url` sources through the executor. */ @@ -137,9 +133,9 @@ export const make = (options: Options = {}) => { llm: { request: LLM.request, generate: (input: RequestInput | LLMRequest, options?: RunOptions) => - run(Effect.flatMap(llmRequest(input), LLM.generate), options), + run(LLM.generate(input), options), stream: (input: RequestInput | LLMRequest, options?: RunOptions) => - iterate(Stream.unwrap(Effect.map(llmRequest(input), LLM.stream)), options), + iterate(LLM.stream(input), options), }, image: { request: Image.request, diff --git a/packages/ai/src/protocols/utils/partial-json.ts b/packages/ai/src/protocols/utils/partial-json.ts index 345a19e4dcaa..d2567ba7dd27 100644 --- a/packages/ai/src/protocols/utils/partial-json.ts +++ b/packages/ai/src/protocols/utils/partial-json.ts @@ -26,13 +26,14 @@ */ import { Schema } from "effect" +import { decodeJson } from "../../utils/json.js" import { Allow } from "./partial-json-options.js" export * from "./partial-json-options.js" export class PartialJSON extends Error {} export class MalformedJSON extends Error {} -const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)) +const decodeJsonString = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.String)) /** Parse complete or incomplete JSON, restricted by the supplied partial-value flags. */ export function parseJSON(jsonString: string, allowPartial = Allow.ALL): unknown { @@ -175,16 +176,16 @@ const _parseJSON = (jsonString: string, allow: number) => { } if (jsonString.charAt(index) === '"') { try { - return decodeJson(jsonString.substring(start, ++index - Number(escape))) as string + return decodeJsonString(jsonString.substring(start, ++index - Number(escape))) } catch (error) { throwMalformedError(String(error)) } } if (Allow.STR & allow) { try { - return decodeJson(`${jsonString.substring(start, index - Number(escape))}"`) as string + return decodeJsonString(`${jsonString.substring(start, index - Number(escape))}"`) } catch { - return decodeJson(`${jsonString.substring(start, jsonString.lastIndexOf("\\"))}"`) as string + return decodeJsonString(`${jsonString.substring(start, jsonString.lastIndexOf("\\"))}"`) } } return markPartialJSON("Unterminated string literal") diff --git a/packages/ai/src/provider-error.ts b/packages/ai/src/provider-error.ts index a902dc022a39..286a5ae41f28 100644 --- a/packages/ai/src/provider-error.ts +++ b/packages/ai/src/provider-error.ts @@ -12,6 +12,8 @@ import { type HttpContext, type HttpRateLimitDetails, } from "./schema/index.js" +import { Json } from "./utils/json.js" +import { isRecord } from "./utils/record.js" const patterns = [ /prompt is too long/i, @@ -115,7 +117,7 @@ export const isRetryable = (error: AIError) => { } } -const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)) +const decodeJson = Schema.decodeUnknownOption(Json) // OpenCode Zen reports account caps as typed 429/402 errors that are not throttles. const QUOTA_CODES = new Set([ "insufficient_quota", @@ -333,7 +335,3 @@ function providerCodes(value: unknown) { exception?.type, ].filter((value): value is string => typeof value === "string") } - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null -} diff --git a/packages/ai/src/providers/cloudflare-ai-gateway.ts b/packages/ai/src/providers/cloudflare-ai-gateway.ts index d5beba1bc12e..9ed255f992a3 100644 --- a/packages/ai/src/providers/cloudflare-ai-gateway.ts +++ b/packages/ai/src/providers/cloudflare-ai-gateway.ts @@ -1,10 +1,11 @@ -import { Schema, type Config, type Redacted } from "effect" +import type { Config, Redacted } from "effect" import type { ProviderPackage } from "../provider-package.js" import { AnthropicMessages, OpenAIChat, OpenAIResponses } from "../protocols/index.js" import { Auth } from "../route/auth.js" import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js" import type { RouteDefaultsInput } from "../route/client.js" import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js" +import { encodeJson } from "../utils/json.js" import type { OpenAIProviderOptionsInput } from "./openai-options.js" export const id = ProviderID.make("cloudflare-ai-gateway") @@ -86,9 +87,7 @@ const credential = (input: LanguageModelOptions) => .orElse(Auth.config(authEnvVars[1])) const headers = (input: LanguageModelOptions) => ({ - ...(input.metadata === undefined - ? {} - : { "cf-aig-metadata": Schema.encodeSync(Schema.fromJsonString(Schema.Unknown))(input.metadata) }), + ...(input.metadata === undefined ? {} : { "cf-aig-metadata": encodeJson(input.metadata) }), ...(input.cacheTtl === undefined ? {} : { "cf-aig-cache-ttl": String(input.cacheTtl) }), ...(input.cacheKey === undefined ? {} : { "cf-aig-cache-key": input.cacheKey }), ...(input.skipCache === undefined ? {} : { "cf-aig-skip-cache": String(input.skipCache) }), diff --git a/packages/ai/src/route/transport/http.ts b/packages/ai/src/route/transport/http.ts index e8cae2000bc7..a9385338130c 100644 --- a/packages/ai/src/route/transport/http.ts +++ b/packages/ai/src/route/transport/http.ts @@ -32,13 +32,6 @@ export interface HttpPrepared { readonly middleware?: HttpMiddleware } -const applyQuery = (url: string, query: Record | undefined) => { - if (!query) return url - const next = new URL(url) - Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value)) - return next.toString() -} - const bodyWithOverlay = (body: Body, request: LLMRequest, encodeBody: (body: Body) => string) => Effect.gen(function* () { if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) } @@ -51,10 +44,11 @@ const bodyWithOverlay = (body: Body, request: LLMRequest, encodeBody: (bod export const jsonRequestParts = (input: JsonRequestInput) => Effect.gen(function* () { - const url = applyQuery( - renderEndpoint(input.endpoint, { request: input.request, body: input.body }).toString(), - input.request.http?.query, - ) + const endpoint = renderEndpoint(input.endpoint, { request: input.request, body: input.body }) + if (input.request.http?.query) { + Object.entries(input.request.http.query).forEach(([key, value]) => endpoint.searchParams.set(key, value)) + } + const url = endpoint.toString() const body = yield* bodyWithOverlay(input.body, input.request, input.encodeBody) const headers = yield* Auth.toEffect(input.auth)({ request: input.request,