Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
17 changes: 12 additions & 5 deletions packages/ai/src/decision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -232,7 +233,9 @@ export const DecisionModelSchema = Schema.declare((value): value is DecisionMode
expected: "Decision.Model",
})

export type DecisionModelOptions<Model> = Model extends DecisionModel<infer Options> ? Options : never
/** The model's typed options; a `"provider/model"` string takes untyped `DecisionOptions`. */
export type DecisionModelOptions<Model> =
Model extends DecisionModel<infer Options> ? Options : Model extends string ? DecisionOptions : never

// ---------------------------------------------------------------------------
// Request and response
Expand All @@ -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
Expand Down Expand Up @@ -305,11 +309,14 @@ export function request(input: DecisionRequest | DecisionRequestInput) {
})
}

export function generate<const Model extends DecisionModel, const Questions extends DecisionQuestions>(
export function generate<const Model extends DecisionModel | string, const Questions extends DecisionQuestions>(
input: DecisionRequestInput<Model, Questions>,
): Effect.Effect<DecisionResponseFor<Questions>, AIError, Service>
export function generate(input: DecisionRequest | DecisionRequestInput) {
return tryRequest(() => request(input)).pipe(Effect.flatMap(DecisionClient.generate))
export function generate(input: DecisionRequestInput<DecisionModel | string>) {
return ModelString.decision(input.model).pipe(
Effect.flatMap((model) => tryRequest(() => request({ ...input, model }))),
Effect.flatMap(DecisionClient.generate),
)
}

export const Decision = {
Expand Down
34 changes: 23 additions & 11 deletions packages/ai/src/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -80,9 +81,12 @@ export type ImageRequestFor<Options extends ImageOptions = ImageOptions> = Omit<
readonly providerOptions?: Options
}

export type ImageModelOptions<Model> = Model extends ImageModel<infer Options> ? Options : never
/** The model's typed options; a `"provider/model"` string takes untyped `ImageOptions`. */
export type ImageModelOptions<Model> =
Model extends ImageModel<infer Options> ? Options : Model extends string ? ImageOptions : never

export type ImageRequestInput<Model extends ImageModel = ImageModel> = Omit<
/** Calls also accept a `"provider/model"` string as the model. */
export type ImageRequestInput<Model extends ImageModel | string = ImageModel> = Omit<
ConstructorParameters<typeof ImageRequest>[0],
"model" | "providerOptions" | "http" | "resolution"
> & {
Expand Down Expand Up @@ -172,34 +176,42 @@ export function request(input: ImageRequest | ImageRequestInput) {
})
}

const requestEffect = (input: ImageRequest | ImageRequestInput) => tryRequest(() => request(input))
type AnyRequestInput = ImageRequestInput<ImageModel | string>

export function generate<const Model extends ImageModel>(
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<const Model extends ImageModel | string>(
input: ImageRequest | ImageRequestInput<Model>,
options?: AwaitOptions,
): Effect.Effect<ImageResponse, AIError, Service>
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<const Model extends ImageModel>(
export function stream<const Model extends ImageModel | string>(
input: ImageRequest | ImageRequestInput<Model>,
options?: AwaitOptions,
): Stream.Stream<ImageEvent, AIError, Service>
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<const Model extends ImageModel>(
export function start<const Model extends ImageModel | string>(
input: ImageRequest | ImageRequestInput<Model>,
): Effect.Effect<Generation<ImageResponse>, 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<Generation<ImageResponse>, AIError, Service> =>
ImageClient.resume(model, token)
export const resume = (
model: ImageModel | string,
token: unknown,
): Effect.Effect<Generation<ImageResponse>, AIError, Service> =>
ModelString.image(model).pipe(Effect.flatMap((model) => ImageClient.resume(model, token)))

export const Image = {
request,
Expand Down
32 changes: 19 additions & 13 deletions packages/ai/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,18 @@ export const make = <R = never>(options: Options<R> = {}) => {
),
)

function generateObject<const Model extends LanguageModel, S extends ToolSchema<any>>(
function generateObject<const Model extends LanguageModel | string, S extends ToolSchema<any>>(
input: GenerateObjectOptions<S, Model>,
options?: RunOptions,
): Promise<GenerateObjectResponse<Schema.Schema.Type<S>>>
function generateObject<const Model extends LanguageModel>(
function generateObject<const Model extends LanguageModel | string>(
input: GenerateObjectDynamicOptions<Model>,
options?: RunOptions,
): Promise<GenerateObjectResponse<unknown>>
function generateObject(
input: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions,
input:
| GenerateObjectOptions<ToolSchema<any>, LanguageModel | string>
| GenerateObjectDynamicOptions<LanguageModel | string>,
options?: RunOptions,
) {
return run("schema" in input ? LLM.generateObject(input) : LLM.generateObject(input), options)
Expand Down Expand Up @@ -196,40 +198,44 @@ export const make = <R = never>(options: Options<R> = {}) => {
)
},
request: LLM.request,
generate: <const Model extends LanguageModel>(
generate: <const Model extends LanguageModel | string>(
input: RequestInput<Model> | LLMRequest,
options?: StreamOptions & RunOptions,
) => run(LLM.generate(input, options), options),
stream: <const Model extends LanguageModel>(
stream: <const Model extends LanguageModel | string>(
input: RequestInput<Model> | 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: <const Model extends LanguageModel>(input: LoopInput<Model>, options?: StreamOptions & RunOptions) =>
iterate(LLM.loop(input, options), options),
loop: <const Model extends LanguageModel | string>(
input: LoopInput<Model>,
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"`. */
compact,
canCompact: LLM.canCompact,
image: {
request: Image.request,
generate: <const Model extends ImageModel>(
generate: <const Model extends ImageModel | string>(
input: ImageRequestInput<Model> | ImageRequest,
options?: AwaitOptions & RunOptions,
) => run(Image.generate(input, options), options),
stream: <const Model extends ImageModel>(
stream: <const Model extends ImageModel | string>(
input: ImageRequestInput<Model> | ImageRequest,
options?: AwaitOptions & RunOptions,
) => iterate(Image.stream(input, options), options),
start: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: RunOptions) =>
run(Image.start(input), options).then(handle),
resume: (model: ImageModel, token: unknown, options?: RunOptions) =>
start: <const Model extends ImageModel | string>(
input: ImageRequestInput<Model> | 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: <const Model extends DecisionModel, const Questions extends DecisionQuestions>(
generate: <const Model extends DecisionModel | string, const Questions extends DecisionQuestions>(
input: DecisionRequestInput<Model, Questions>,
options?: RunOptions,
) => run(Decision.generate(input), options),
Expand Down
54 changes: 34 additions & 20 deletions packages/ai/src/llm.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<SelectedLanguageModel extends LanguageModel = LanguageModel> = 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<SelectedLanguageModel extends LanguageModel | string = LanguageModel> = Omit<
ConstructorParameters<typeof LLMRequest>[0],
"model" | "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
> & {
Expand All @@ -38,23 +42,23 @@ export type RequestInput<SelectedLanguageModel extends LanguageModel = LanguageM
readonly http?: HttpOptions.Input
}

export function generate<const Model extends LanguageModel>(
export function generate<const Model extends LanguageModel | string>(
input: RequestInput<Model> | LLMRequest,
options?: StreamOptions,
): Effect.Effect<LLMResponse, AIError, Service>
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<const Model extends LanguageModel>(
export function stream<const Model extends LanguageModel | string>(
input: RequestInput<Model> | LLMRequest,
options?: StreamOptions,
): Stream.Stream<LLMEvent, AIError, Service>
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<SelectedLanguageModel extends LanguageModel = LanguageModel> = Omit<
export type LoopInput<SelectedLanguageModel extends LanguageModel | string = LanguageModel> = Omit<
RequestInput<SelectedLanguageModel>,
"tools"
> & {
Expand All @@ -67,11 +71,11 @@ export type LoopInput<SelectedLanguageModel extends LanguageModel = LanguageMode
* Stream model calls, running the model's tool calls between them, until a step has no client-executed tool calls,
* reports a provider error, or `until` returns `true`. Each step's `step-start` and `step-finish` carry its step index.
*/
export function loop<const Model extends LanguageModel>(
export function loop<const Model extends LanguageModel | string>(
input: LoopInput<Model>,
options?: StreamOptions,
): Stream.Stream<LLMEvent, AIError, Service>
export function loop(input: LoopInput, options?: StreamOptions) {
export function loop(input: LoopInput<LanguageModel | string>, options?: StreamOptions) {
const { tools, until, ...rest } = input
const step = (request: LLMRequest, steps: ReadonlyArray<LLMResponse>): Stream.Stream<LLMEvent, AIError, Service> =>
Stream.suspend(() => {
Expand Down Expand Up @@ -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<LanguageModel | string>

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 = <const SelectedLanguageModel extends LanguageModel>(
input: RequestInput<SelectedLanguageModel>,
Expand Down Expand Up @@ -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<SelectedLanguageModel extends LanguageModel = LanguageModel> = Omit<
type GenerateObjectBase<SelectedLanguageModel extends LanguageModel | string = LanguageModel> = Omit<
RequestInput<SelectedLanguageModel>,
"tools" | "toolChoice"
>
Expand All @@ -176,20 +184,22 @@ export class GenerateObjectResponse<T> {

export interface GenerateObjectOptions<
S extends ToolSchema<any>,
SelectedLanguageModel extends LanguageModel = LanguageModel,
SelectedLanguageModel extends LanguageModel | string = LanguageModel,
> extends GenerateObjectBase<SelectedLanguageModel> {
readonly schema: S
}

export interface GenerateObjectDynamicOptions<SelectedLanguageModel extends LanguageModel = LanguageModel>
export interface GenerateObjectDynamicOptions<SelectedLanguageModel extends LanguageModel | string = LanguageModel>
extends GenerateObjectBase<SelectedLanguageModel> {
/** 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<LanguageModel | string>,
tool: Tool,
) {
const generateRequest = LLMRequest.update(yield* requestEffect(options), {
tools: toDefinitions({ [GENERATE_OBJECT_TOOL_NAME]: tool }),
toolChoice: ToolChoice.named(GENERATE_OBJECT_TOOL_NAME),
})
Expand Down Expand Up @@ -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<const SelectedLanguageModel extends LanguageModel, S extends ToolSchema<any>>(
export function generateObject<const SelectedLanguageModel extends LanguageModel | string, S extends ToolSchema<any>>(
options: GenerateObjectOptions<S, SelectedLanguageModel>,
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, AIError, Service>
export function generateObject<const SelectedLanguageModel extends LanguageModel>(
export function generateObject<const SelectedLanguageModel extends LanguageModel | string>(
options: GenerateObjectDynamicOptions<SelectedLanguageModel>,
): Effect.Effect<GenerateObjectResponse<unknown>, AIError, Service>
export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
export function generateObject(
options:
| GenerateObjectOptions<ToolSchema<any>, LanguageModel | string>
| GenerateObjectDynamicOptions<LanguageModel | string>,
) {
if ("schema" in options) {
const { schema, ...rest } = options
return runGenerateObject(rest, tool({ description: GENERATE_OBJECT_TOOL_DESCRIPTION, parameters: schema }))
Expand Down
Loading
Loading