From 9d039e8131b322b09050362c5bd624c9e9bf67ec Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 30 Oct 2026 01:54:49 -0500 Subject: [PATCH] feat(ai): accept "provider/model" strings as models --- packages/ai/README.md | 8 ++ packages/ai/src/decision.ts | 17 ++- packages/ai/src/image.ts | 34 ++++-- packages/ai/src/index.ts | 32 +++--- packages/ai/src/llm.ts | 54 +++++---- packages/ai/src/model-string.ts | 88 ++++++++++++++ packages/ai/src/schema/options.ts | 3 +- packages/ai/test/model-string.test.ts | 151 +++++++++++++++++++++++++ packages/ai/test/model-string.types.ts | 28 +++++ 9 files changed, 365 insertions(+), 50 deletions(-) create mode 100644 packages/ai/src/model-string.ts create mode 100644 packages/ai/test/model-string.test.ts create mode 100644 packages/ai/test/model-string.types.ts diff --git a/packages/ai/README.md b/packages/ai/README.md index 4a3622110ed3..13a1493713c1 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -22,6 +22,14 @@ for await (const event of ai.stream(ai.request(input))) { } ``` +A `"provider/model"` string works anywhere a text, image, or decision model does. It loads that provider's default +model with its env key and no settings, so `"openai/gpt-5.4"` behaves like `OpenAI.model("gpt-5.4")`; the model id is +everything after the first `/`. `ai.request` needs a model object, since it builds the request synchronously. + +```ts +await ai.generate({ model: "openai/gpt-5.4", prompt: "Hi" }) +``` + The Effect API lives at `@opencode/ai/effect`. Its `ai` has the same namespaces and methods, returning `Effect` or `Stream`: diff --git a/packages/ai/src/decision.ts b/packages/ai/src/decision.ts index 6a0daa9ff4b2..082f31d57503 100644 --- a/packages/ai/src/decision.ts +++ b/packages/ai/src/decision.ts @@ -2,6 +2,7 @@ import { Effect, Schema } from "effect" import { DecisionClient, Service, type Execute } from "./decision-client.js" import { Media } from "./media.js" import { tryRequest } from "./media-model.js" +import { ModelString } from "./model-string.js" import { AIError, HttpOptions, @@ -232,7 +233,9 @@ export const DecisionModelSchema = Schema.declare((value): value is DecisionMode expected: "Decision.Model", }) -export type DecisionModelOptions = Model extends DecisionModel ? Options : never +/** The model's typed options; a `"provider/model"` string takes untyped `DecisionOptions`. */ +export type DecisionModelOptions = + Model extends DecisionModel ? Options : Model extends string ? DecisionOptions : never // --------------------------------------------------------------------------- // Request and response @@ -257,8 +260,9 @@ export type DecisionRequestFor< readonly providerOptions?: Options } +/** Calls also accept a `"provider/model"` string as the model. */ export type DecisionRequestInput< - Model extends DecisionModel = DecisionModel, + Model extends DecisionModel | string = DecisionModel, Questions extends DecisionQuestions = DecisionQuestions, > = { readonly model: Model @@ -305,11 +309,14 @@ export function request(input: DecisionRequest | DecisionRequestInput) { }) } -export function generate( +export function generate( input: DecisionRequestInput, ): Effect.Effect, AIError, Service> -export function generate(input: DecisionRequest | DecisionRequestInput) { - return tryRequest(() => request(input)).pipe(Effect.flatMap(DecisionClient.generate)) +export function generate(input: DecisionRequestInput) { + return ModelString.decision(input.model).pipe( + Effect.flatMap((model) => tryRequest(() => request({ ...input, model }))), + Effect.flatMap(DecisionClient.generate), + ) } export const Decision = { diff --git a/packages/ai/src/image.ts b/packages/ai/src/image.ts index f91078f8b4e4..0d2a044b5160 100644 --- a/packages/ai/src/image.ts +++ b/packages/ai/src/image.ts @@ -2,6 +2,7 @@ import { Effect, Schema, Stream } from "effect" import { Generation, ProgressEvent, QueuedEvent, type AwaitOptions } from "./generation.js" import { Media } from "./media.js" import { MediaModel, composeRoute, tryRequest } from "./media-model.js" +import { ModelString } from "./model-string.js" import { MediaRoute } from "./route/media.js" import { AIError, HttpOptions, MediaUsage, ProviderMetadata, ProviderOptions, type OpenString } from "./schema/index.js" import { ImageClient, Service } from "./image-client.js" @@ -80,9 +81,12 @@ export type ImageRequestFor = Omit< readonly providerOptions?: Options } -export type ImageModelOptions = Model extends ImageModel ? Options : never +/** The model's typed options; a `"provider/model"` string takes untyped `ImageOptions`. */ +export type ImageModelOptions = + Model extends ImageModel ? Options : Model extends string ? ImageOptions : never -export type ImageRequestInput = Omit< +/** Calls also accept a `"provider/model"` string as the model. */ +export type ImageRequestInput = Omit< ConstructorParameters[0], "model" | "providerOptions" | "http" | "resolution" > & { @@ -172,34 +176,42 @@ export function request(input: ImageRequest | ImageRequestInput) { }) } -const requestEffect = (input: ImageRequest | ImageRequestInput) => tryRequest(() => request(input)) +type AnyRequestInput = ImageRequestInput -export function generate( +const requestEffect = (input: ImageRequest | AnyRequestInput) => + input instanceof ImageRequest + ? Effect.succeed(input) + : ModelString.image(input.model).pipe(Effect.flatMap((model) => tryRequest(() => request({ ...input, model })))) + +export function generate( input: ImageRequest | ImageRequestInput, options?: AwaitOptions, ): Effect.Effect -export function generate(input: ImageRequest | ImageRequestInput, options?: AwaitOptions) { +export function generate(input: ImageRequest | AnyRequestInput, options?: AwaitOptions) { return requestEffect(input).pipe(Effect.flatMap((request) => ImageClient.generate(request, options))) } -export function stream( +export function stream( input: ImageRequest | ImageRequestInput, options?: AwaitOptions, ): Stream.Stream -export function stream(input: ImageRequest | ImageRequestInput, options?: AwaitOptions) { +export function stream(input: ImageRequest | AnyRequestInput, options?: AwaitOptions) { return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => ImageClient.stream(request, options)))) } /** Inline and streaming routes fail with `UnsupportedOperation`. */ -export function start( +export function start( input: ImageRequest | ImageRequestInput, ): Effect.Effect, AIError, Service> -export function start(input: ImageRequest | ImageRequestInput) { +export function start(input: ImageRequest | AnyRequestInput) { return requestEffect(input).pipe(Effect.flatMap((request) => ImageClient.start(request))) } -export const resume = (model: ImageModel, token: unknown): Effect.Effect, AIError, Service> => - ImageClient.resume(model, token) +export const resume = ( + model: ImageModel | string, + token: unknown, +): Effect.Effect, AIError, Service> => + ModelString.image(model).pipe(Effect.flatMap((model) => ImageClient.resume(model, token))) export const Image = { request, diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts index 9770a5a9f3e5..95fd8b150a93 100644 --- a/packages/ai/src/index.ts +++ b/packages/ai/src/index.ts @@ -115,16 +115,18 @@ export const make = (options: Options = {}) => { ), ) - function generateObject>( + function generateObject>( input: GenerateObjectOptions, options?: RunOptions, ): Promise>> - function generateObject( + function generateObject( input: GenerateObjectDynamicOptions, options?: RunOptions, ): Promise> function generateObject( - input: GenerateObjectOptions> | GenerateObjectDynamicOptions, + input: + | GenerateObjectOptions, LanguageModel | string> + | GenerateObjectDynamicOptions, options?: RunOptions, ) { return run("schema" in input ? LLM.generateObject(input) : LLM.generateObject(input), options) @@ -196,17 +198,19 @@ export const make = (options: Options = {}) => { ) }, request: LLM.request, - generate: ( + generate: ( input: RequestInput | LLMRequest, options?: StreamOptions & RunOptions, ) => run(LLM.generate(input, options), options), - stream: ( + stream: ( input: RequestInput | LLMRequest, options?: StreamOptions & RunOptions, ) => iterate(LLM.stream(input, options), options), /** Stream model calls, running their tool calls, until the model stops calling tools or `until` is true. */ - loop: (input: LoopInput, options?: StreamOptions & RunOptions) => - iterate(LLM.loop(input, options), options), + loop: ( + input: LoopInput, + options?: StreamOptions & RunOptions, + ) => iterate(LLM.loop(input, options), options), /** Decode the model's output against `schema` (or `jsonSchema`, unvalidated). */ generateObject, /** Compact a Responses request: a replacement window, or a checkpoint with `mechanism: "trigger"`. */ @@ -214,22 +218,24 @@ export const make = (options: Options = {}) => { canCompact: LLM.canCompact, image: { request: Image.request, - generate: ( + generate: ( input: ImageRequestInput | ImageRequest, options?: AwaitOptions & RunOptions, ) => run(Image.generate(input, options), options), - stream: ( + stream: ( input: ImageRequestInput | ImageRequest, options?: AwaitOptions & RunOptions, ) => iterate(Image.stream(input, options), options), - start: (input: ImageRequestInput | ImageRequest, options?: RunOptions) => - run(Image.start(input), options).then(handle), - resume: (model: ImageModel, token: unknown, options?: RunOptions) => + start: ( + input: ImageRequestInput | ImageRequest, + options?: RunOptions, + ) => run(Image.start(input), options).then(handle), + resume: (model: ImageModel | string, token: unknown, options?: RunOptions) => run(Image.resume(model, token), options).then(handle), }, decision: { request: Decision.request, - generate: ( + generate: ( input: DecisionRequestInput, options?: RunOptions, ) => run(Decision.generate(input), options), diff --git a/packages/ai/src/llm.ts b/packages/ai/src/llm.ts index 46580ae7d129..3eef8de29f7f 100644 --- a/packages/ai/src/llm.ts +++ b/packages/ai/src/llm.ts @@ -1,5 +1,6 @@ import { Effect, JsonSchema, Schema, Stream } from "effect" import { tryRequest } from "./media-model.js" +import { ModelString } from "./model-string.js" import { LLMClient, Service, type StreamOptions } from "./route/client.js" import { GenerationOptions, @@ -22,8 +23,11 @@ import { import { tool, toDefinitions, type ExecutableTool, type Tool, type ToolSchema } from "./tool.js" import { ToolRuntime } from "./tool-runtime.js" -/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */ -export type RequestInput = Omit< +/** + * Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. Calls also accept a + * `"provider/model"` string as the model. + */ +export type RequestInput = Omit< ConstructorParameters[0], "model" | "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions" > & { @@ -38,23 +42,23 @@ export type RequestInput( +export function generate( input: RequestInput | LLMRequest, options?: StreamOptions, ): Effect.Effect -export function generate(input: RequestInput | LLMRequest, options?: StreamOptions) { +export function generate(input: AnyRequestInput | LLMRequest, options?: StreamOptions) { return requestEffect(input).pipe(Effect.flatMap((request) => LLMClient.generate(request, options))) } -export function stream( +export function stream( input: RequestInput | LLMRequest, options?: StreamOptions, ): Stream.Stream -export function stream(input: RequestInput | LLMRequest, options?: StreamOptions) { +export function stream(input: AnyRequestInput | LLMRequest, options?: StreamOptions) { return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => LLMClient.stream(request, options)))) } -export type LoopInput = Omit< +export type LoopInput = Omit< RequestInput, "tools" > & { @@ -67,11 +71,11 @@ export type LoopInput( +export function loop( input: LoopInput, options?: StreamOptions, ): Stream.Stream -export function loop(input: LoopInput, options?: StreamOptions) { +export function loop(input: LoopInput, options?: StreamOptions) { const { tools, until, ...rest } = input const step = (request: LLMRequest, steps: ReadonlyArray): Stream.Stream => Stream.suspend(() => { @@ -121,8 +125,12 @@ export function loop(input: LoopInput, options?: StreamOptions) { ) } -const requestEffect = (input: RequestInput | LLMRequest) => - input instanceof LLMRequest ? Effect.succeed(input) : tryRequest(() => request(input)) +type AnyRequestInput = RequestInput + +const requestEffect = (input: AnyRequestInput | LLMRequest) => + input instanceof LLMRequest + ? Effect.succeed(input) + : ModelString.language(input.model).pipe(Effect.flatMap((model) => tryRequest(() => request({ ...input, model })))) export const request = ( input: RequestInput, @@ -154,7 +162,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object" const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool." -type GenerateObjectBase = Omit< +type GenerateObjectBase = Omit< RequestInput, "tools" | "toolChoice" > @@ -176,20 +184,22 @@ export class GenerateObjectResponse { export interface GenerateObjectOptions< S extends ToolSchema, - SelectedLanguageModel extends LanguageModel = LanguageModel, + SelectedLanguageModel extends LanguageModel | string = LanguageModel, > extends GenerateObjectBase { readonly schema: S } -export interface GenerateObjectDynamicOptions +export interface GenerateObjectDynamicOptions extends GenerateObjectBase { /** Raw JSON Schema object describing the expected output shape. */ readonly jsonSchema: JsonSchema.JsonSchema } -const runGenerateObject = Effect.fn("LLM.generateObject")(function* (options: GenerateObjectBase, tool: Tool) { - const baseRequest = request(options) - const generateRequest = LLMRequest.update(baseRequest, { +const runGenerateObject = Effect.fn("LLM.generateObject")(function* ( + options: GenerateObjectBase, + tool: Tool, +) { + const generateRequest = LLMRequest.update(yield* requestEffect(options), { tools: toDefinitions({ [GENERATE_OBJECT_TOOL_NAME]: tool }), toolChoice: ToolChoice.named(GENERATE_OBJECT_TOOL_NAME), }) @@ -228,13 +238,17 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (options: Ge * 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when * the schema is only available at runtime (MCP, plugin manifests). Caller validates. */ -export function generateObject>( +export function generateObject>( options: GenerateObjectOptions, ): Effect.Effect>, AIError, Service> -export function generateObject( +export function generateObject( options: GenerateObjectDynamicOptions, ): Effect.Effect, AIError, Service> -export function generateObject(options: GenerateObjectOptions> | GenerateObjectDynamicOptions) { +export function generateObject( + options: + | GenerateObjectOptions, LanguageModel | string> + | GenerateObjectDynamicOptions, +) { if ("schema" in options) { const { schema, ...rest } = options return runGenerateObject(rest, tool({ description: GENERATE_OBJECT_TOOL_DESCRIPTION, parameters: schema })) diff --git a/packages/ai/src/model-string.ts b/packages/ai/src/model-string.ts new file mode 100644 index 000000000000..e8f5edd6ea60 --- /dev/null +++ b/packages/ai/src/model-string.ts @@ -0,0 +1,88 @@ +import { Effect } from "effect" +import type { DecisionModel } from "./decision.js" +import type { ImageModel } from "./image.js" +import { tryRequest } from "./media-model.js" +import { AIError, InvalidRequestError, type LanguageModel } from "./schema/index.js" + +/** + * Resolves `"provider/model"` strings to a provider's default model with env auth and no settings. The provider + * module is imported only when a string names it. Until a bundled catalog exists, the provider is the text before + * the first `/` and the model id is the rest. + */ +interface Loaders { + model?(modelID: string, settings: {}): LanguageModel + imageModel?(modelID: string, settings: {}): ImageModel + decisionModel?(modelID: string, settings: {}): DecisionModel +} + +const providers: Record Promise> = { + alibaba: () => import("./providers/alibaba.js"), + "amazon-bedrock": () => import("./providers/amazon-bedrock.js"), + anthropic: () => import("./providers/anthropic.js"), + azure: () => import("./providers/azure.js"), + baseten: () => import("./providers/baseten.js"), + cerebras: () => import("./providers/cerebras.js"), + "cloudflare-ai-gateway": () => import("./providers/cloudflare-ai-gateway.js"), + "cloudflare-workers-ai": () => import("./providers/cloudflare-workers-ai.js"), + cohere: () => import("./providers/cohere.js"), + deepinfra: () => import("./providers/deepinfra.js"), + deepseek: () => import("./providers/deepseek.js"), + digitalocean: () => import("./providers/digitalocean.js"), + fireworks: () => import("./providers/fireworks.js"), + google: () => import("./providers/google.js"), + "google-vertex": () => import("./providers/google-vertex.js"), + groq: () => import("./providers/groq.js"), + meta: () => import("./providers/meta.js"), + minimax: () => import("./providers/minimax.js"), + mistral: () => import("./providers/mistral.js"), + moonshotai: () => import("./providers/moonshot.js"), + openai: () => import("./providers/openai.js"), + opencode: () => import("./providers/opencode.js"), + openrouter: () => import("./providers/openrouter.js"), + togetherai: () => import("./providers/togetherai.js"), + "typesafe-ai": () => import("./providers/typesafe-ai.js"), + venice: () => import("./providers/venice.js"), + "vercel-ai-gateway": () => import("./providers/vercel-ai-gateway.js"), + xai: () => import("./providers/xai.js"), + zai: () => import("./providers/zai.js"), + "zai-coding-plan": () => import("./providers/zai-coding-plan.js"), + // models.dev ids that differ from the provider module's id. + "fireworks-ai": () => import("./providers/fireworks.js"), + "google-vertex-anthropic": () => import("./providers/google-vertex-messages.js"), + vercel: () => import("./providers/vercel-ai-gateway.js"), +} + +export const providerIDs = Object.keys(providers) + +const kinds = { model: "text", imageModel: "image", decisionModel: "decision" } as const + +const resolve = (kind: Kind, model: string) => + Effect.gen(function* () { + const slash = model.indexOf("/") + const provider = model.slice(0, slash) + if (slash <= 0 || !Object.hasOwn(providers, provider)) + return yield* invalid( + `Unknown provider in model "${model}". Use "provider/model" with one of: ${providerIDs.join(", ")}`, + ) + const module = yield* Effect.tryPromise({ + try: () => providers[provider](), + catch: (cause) => invalid(`Failed to load the ${provider} provider`, cause), + }) + const make = module[kind] + if (make === undefined) return yield* invalid(`Provider "${provider}" has no ${kinds[kind]} models`) + return yield* tryRequest(() => make(model.slice(slash + 1), {}) as ReturnType>) + }) + +const invalid = (message: string, cause?: unknown) => + new AIError({ reason: new InvalidRequestError({ message, cause }) }) + +export const language = (model: LanguageModel | string) => + typeof model === "string" ? resolve("model", model) : Effect.succeed(model) + +export const image = (model: ImageModel | string) => + typeof model === "string" ? resolve("imageModel", model) : Effect.succeed(model) + +export const decision = (model: DecisionModel | string) => + typeof model === "string" ? resolve("decisionModel", model) : Effect.succeed(model) + +export * as ModelString from "./model-string.js" diff --git a/packages/ai/src/schema/options.ts b/packages/ai/src/schema/options.ts index 4e69d6c708fd..829e32b8b398 100644 --- a/packages/ai/src/schema/options.ts +++ b/packages/ai/src/schema/options.ts @@ -335,8 +335,9 @@ export namespace LanguageModel { export type LanguageModelInput = LanguageModel.Input +/** The model's typed options; a `"provider/model"` string takes untyped `ProviderOptions`. */ export type LanguageModelProviderOptions = - SelectedModel extends LanguageModel ? Options : never + SelectedModel extends LanguageModel ? Options : SelectedModel extends string ? ProviderOptions : never export const LanguageModelSchema = Schema.declare((value): value is LanguageModel => value instanceof LanguageModel, { expected: "LLM.LanguageModel", diff --git a/packages/ai/test/model-string.test.ts b/packages/ai/test/model-string.test.ts new file mode 100644 index 000000000000..51c84346b5cf --- /dev/null +++ b/packages/ai/test/model-string.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test" +import { ConfigProvider, Effect, Layer, Schema, Stream } from "effect" +import { AIClient, AIError, ai } from "../src/effect.js" +import { AI } from "../src/index.js" +import { ModelString } from "../src/model-string.js" +import { RequestExecutor } from "../src/route.js" +import { handlerLayer, json, observe, type Call } from "./lib/http.js" +import { it } from "./lib/effect.js" + +// Every request is answered with a 400 so each test sees which endpoint, model id and env credential a string chose. +const calls: Array = [] +const executor = RequestExecutor.layer.pipe( + Layer.provide( + handlerLayer((input) => + observe(calls, input).pipe(Effect.map(() => json(input, { error: { message: "stop" } }, { status: 400 }))), + ), + ), +) +const env = { + OPENAI_API_KEY: "env-openai", + ANTHROPIC_API_KEY: "env-anthropic", + OPENROUTER_API_KEY: "env-openrouter", + FIREWORKS_API_KEY: "env-fireworks", +} + +const lastCall = (effect: Effect.Effect) => + Effect.gen(function* () { + calls.length = 0 + const error = yield* Effect.flip(effect) + expect(calls).toHaveLength(1) + return { error, call: calls[0], body: JSON.parse(calls[0].body) } + }).pipe( + Effect.provide(AIClient.layerWith(executor)), + Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))), + ) + +const failure = (effect: Effect.Effect) => + Effect.flip(effect).pipe( + Effect.map((error) => error.reason), + Effect.provide(AIClient.layerWith(executor)), + ) + +const questions = [{ name: "spam", type: "predicate", instructions: "Is this spam?" }] as const + +describe("ModelString", () => { + test("covers every provider with text, image or decision models", async () => { + const ids = Object.values(await import("../src/providers.js")) + .filter((provider) => ["model", "imageModel", "decisionModel"].some((kind) => kind in provider)) + .map((provider) => provider.id) + .filter((id) => !id.endsWith("-compatible")) + expect(ModelString.providerIDs).toEqual(expect.arrayContaining(ids)) + }) + + it.effect("resolves an OpenAI text model with its env key", () => + Effect.gen(function* () { + const result = yield* lastCall(ai.generate({ model: "openai/gpt-5.4", prompt: "Hi" })) + expect(result.call.url).toBe("https://api.openai.com/v1/responses") + expect(result.call.headers.get("authorization")).toBe("Bearer env-openai") + expect(result.body.model).toBe("gpt-5.4") + }), + ) + + it.effect("resolves an Anthropic text model when streaming", () => + Effect.gen(function* () { + const result = yield* lastCall( + Stream.runDrain( + ai.stream({ model: "anthropic/claude-haiku-4-5", prompt: "Hi", generation: { maxTokens: 8 } }), + ), + ) + expect(result.call.url).toStartWith("https://api.anthropic.com/v1/messages") + expect(result.call.headers.get("x-api-key")).toBe("env-anthropic") + expect(result.body.model).toBe("claude-haiku-4-5") + }), + ) + + it.effect("keeps the slashes after the provider in the model id", () => + Effect.gen(function* () { + const result = yield* lastCall( + ai.generateObject({ model: "openrouter/openai/gpt-5.4", prompt: "Hi", schema: Schema.Struct({}) }), + ) + expect(result.call.url).toBe("https://openrouter.ai/api/v1/chat/completions") + expect(result.call.headers.get("authorization")).toBe("Bearer env-openrouter") + expect(result.body.model).toBe("openai/gpt-5.4") + }), + ) + + it.effect("accepts models.dev provider ids", () => + Effect.gen(function* () { + const result = yield* lastCall( + ai.generate({ model: "fireworks-ai/accounts/fireworks/models/kimi-k2", prompt: "Hi" }), + ) + expect(result.call.url).toBe("https://api.fireworks.ai/inference/v1/chat/completions") + expect(result.call.headers.get("authorization")).toBe("Bearer env-fireworks") + expect(result.body.model).toBe("accounts/fireworks/models/kimi-k2") + }), + ) + + it.effect("resolves image and decision models", () => + Effect.gen(function* () { + const image = yield* lastCall(ai.image.generate({ model: "openai/gpt-image-1", prompt: "A cat" })) + expect(image.call.url).toBe("https://api.openai.com/v1/images/generations") + expect(image.body.model).toBe("gpt-image-1") + + const decision = yield* lastCall( + ai.decision.generate({ model: "openai/gpt-5.4-nano", input: { message: "Buy now" }, questions }), + ) + expect(decision.call.url).toBe("https://api.openai.com/v1/decisions") + expect(decision.body.model).toBe("gpt-5.4-nano") + }), + ) + + it.effect("fails on an unknown provider or a missing provider prefix", () => + Effect.gen(function* () { + for (const model of ["nope/gpt-5.4", "gpt-5.4", "constructor/x"]) { + const reason = yield* failure(ai.generate({ model, prompt: "Hi" })) + expect(reason._tag).toBe("InvalidRequest") + expect(reason.message).toContain(`Unknown provider in model "${model}"`) + expect(reason.message).toContain("anthropic, ") + } + }), + ) + + it.effect("fails when the provider has no model of that kind", () => + Effect.gen(function* () { + const image = yield* failure(ai.image.generate({ model: "anthropic/claude-haiku-4-5", prompt: "A cat" })) + expect(image.message).toBe('Provider "anthropic" has no image models') + const decision = yield* failure( + ai.decision.generate({ model: "groq/llama", input: { message: "Buy now" }, questions }), + ) + expect(decision.message).toBe('Provider "groq" has no decision models') + }), + ) + + it.effect("reports settings a provider needs", () => + Effect.gen(function* () { + const reason = yield* failure(ai.generate({ model: "azure/gpt-5.4", prompt: "Hi" })) + expect(reason._tag).toBe("InvalidRequest") + expect(reason.message).toContain("resourceName") + }), + ) + + it.live("rejects unknown providers through the Promise client", () => + Effect.promise(async () => { + const client = AI.make({ layer: executor }) + const error = await client.generate({ model: "nope/gpt-5.4", prompt: "Hi" }).catch((error) => error) + await client.dispose() + expect(error).toBeInstanceOf(AIError) + expect(error.reason._tag).toBe("InvalidRequest") + }), + ) +}) diff --git a/packages/ai/test/model-string.types.ts b/packages/ai/test/model-string.types.ts new file mode 100644 index 000000000000..e0933aeefbad --- /dev/null +++ b/packages/ai/test/model-string.types.ts @@ -0,0 +1,28 @@ +import { ai as effect } from "../src/effect.js" +import { ai } from "../src/index.js" +import { OpenAI } from "../src/providers.js" + +const ids = ["openai/gpt-5.4-nano", "anthropic/claude-haiku-4-5"] as const +declare const configured: string + +effect.generate({ model: "openai/gpt-5.4", prompt: "Hi", providerOptions: { store: false } }) +effect.stream({ model: configured, prompt: "Hi" }) +effect.loop({ model: ids[0], prompt: "Hi", tools: {} }) +void Promise.all(ids.map((model) => ai.generate({ model, prompt: "Hi" }))) +void ai.generateObject({ model: "openai/gpt-5.4", prompt: "Hi", jsonSchema: { type: "object" } }) +effect.image.generate({ model: "openai/gpt-image-1", prompt: "A cat", providerOptions: { quality: "high" } }) +effect.image.resume("openai/gpt-image-1", {}) +effect.decision.generate({ + model: "openai/gpt-5.4-nano", + input: { message: "Buy now" }, + questions: [{ name: "spam", type: "predicate", instructions: "Is this spam?" }], +}) + +// A model object keeps its typed provider options. +effect.generate({ model: OpenAI.model("gpt-5.4"), prompt: "Hi", providerOptions: { store: false } }) +// @ts-expect-error OpenAI options keep their known value types. +effect.generate({ model: OpenAI.model("gpt-5.4"), prompt: "Hi", providerOptions: { store: "no" } }) +// @ts-expect-error `ai.request` builds a request synchronously, so it needs a model object. +effect.request({ model: "openai/gpt-5.4", prompt: "Hi" }) +// @ts-expect-error A model is a string or a model object. +effect.generate({ model: 1, prompt: "Hi" })