mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-29 22:26:12 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b75187ff59 | ||
|
|
3c259fc552 | ||
|
|
210be4b749 | ||
|
|
464649e67e | ||
|
|
c8dca936b1 | ||
|
|
4f871906bc | ||
|
|
f7ea2fc346 | ||
|
|
5cb633a48e | ||
|
|
b985d2eb8e | ||
|
|
014908a8d7 | ||
|
|
f599f8f3d3 | ||
|
|
b2010220f9 | ||
|
|
c2e975c4e6 | ||
|
|
6aa250ee5d | ||
|
|
8f1e3ff75c | ||
|
|
5438dfb751 | ||
|
|
4bd16d6f47 | ||
|
|
9ee337469d | ||
|
|
813c41ff6c | ||
|
|
247f14f955 | ||
|
|
bd906d468d | ||
|
|
fc11ed3838 | ||
|
|
2a85c861e0 | ||
|
|
9554f9a16e | ||
|
|
d72b428061 | ||
|
|
fea17b4a0e | ||
|
|
9038e44a68 | ||
|
|
3b8299e3f2 | ||
|
|
f5cdf0f056 | ||
|
|
224feff7c4 | ||
|
|
ce2c9e7e26 | ||
|
|
cb80f47112 | ||
|
|
b4ac939537 | ||
|
|
8a96b80aec | ||
|
|
333a090975 | ||
|
|
5a78a17e49 | ||
|
|
9d6af6afa4 | ||
|
|
8c3e06798c | ||
|
|
5882b64612 | ||
|
|
309c4fe6f0 | ||
|
|
d9555f138b |
@@ -2,15 +2,12 @@ import type { Context } from "../../../packages/plugin/src/tui/context"
|
||||
|
||||
export default {
|
||||
id: "test.tui-discovery-smoke",
|
||||
setup(context: Context) {
|
||||
const timer = setTimeout(() => {
|
||||
context.ui.toast.show({
|
||||
title: "TUI plugin discovery works",
|
||||
message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
|
||||
variant: "success",
|
||||
duration: 30_000,
|
||||
})
|
||||
}, 1_000)
|
||||
return () => clearTimeout(timer)
|
||||
setup(_context: Context) {
|
||||
// context.ui.toast.show({
|
||||
// title: "TUI plugin discovery works",
|
||||
// message: "Loaded .opencode/plugins/tui/discovery-smoke.ts",
|
||||
// variant: "success",
|
||||
// duration: 30_000,
|
||||
// })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -600,6 +600,7 @@
|
||||
"zod": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
@@ -611,12 +612,14 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.4.5",
|
||||
"@opentui/keymap": ">=0.4.5",
|
||||
"@opentui/solid": ">=0.4.5",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opencode-ai/theme",
|
||||
"@opentui/core",
|
||||
"@opentui/keymap",
|
||||
"@opentui/solid",
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# V1 to V2 Database Migration
|
||||
|
||||
## Approach
|
||||
|
||||
- Use the `dev` branch database schema and migration registry as the V1 baseline.
|
||||
- Remove migrations that exist only on the V2 branch.
|
||||
- Generate one canonical migration from the `dev` schema to the final V2 schema.
|
||||
- Add explicit data operations to that migration where generated DDL is insufficient.
|
||||
- Test the migration against a populated database at the exact `dev` schema.
|
||||
|
||||
## Preserve
|
||||
|
||||
The canonical V1 data remains in its existing tables. In particular, preserve `session`, `message`, and `part` rows.
|
||||
|
||||
Preserve `workspace` rows and existing `session.workspace_id` values unchanged. The migration must not clear or rebuild
|
||||
workspace relationships.
|
||||
|
||||
Keep the `todo` table and its data unchanged. V2 does not currently migrate todos into another representation, and the
|
||||
generated migration must not drop the table.
|
||||
|
||||
## Truncate
|
||||
|
||||
Truncate these pre-launch V2 tables before applying schema changes:
|
||||
|
||||
- `event`
|
||||
- `event_sequence`
|
||||
- `session_message`
|
||||
|
||||
These rows are not canonical V1 data. Truncating `event` before adding the required `event.created` column means the
|
||||
column needs neither a backfill nor a default. After truncation, rebuild `session_message` from canonical V1 `message`
|
||||
and `part` rows rather than retaining its pre-launch V2 contents.
|
||||
|
||||
## Message Backfill
|
||||
|
||||
Backfill canonical V1 history from `message` and `part` into `session_message`. This is the main data transformation in
|
||||
the migration. Preserving the V1 tables alone keeps the data safe but does not make existing history visible through the
|
||||
V2 session APIs, which read `session_message`.
|
||||
|
||||
Reuse each V1 `message.id` as the corresponding `session_message.id`. Stable IDs keep the migration deterministic and
|
||||
avoid rewriting other persisted state that may refer to a message.
|
||||
|
||||
Within each session, order V1 messages by `time_created` and then `id`, matching the existing V1 message index. Assign
|
||||
contiguous `session_message.seq` values starting at `0`.
|
||||
|
||||
Map ordinary V1 messages one-to-one by role. Each ordinary V1 user message becomes one V2 `user` row, and each ordinary
|
||||
V1 assistant message becomes one V2 `assistant` row. Fold the source message's ordered V1 parts into that row's V2
|
||||
payload.
|
||||
|
||||
Handle semantic marker parts before applying the ordinary mapping. In particular, a V1 user message containing a
|
||||
`compaction` part and its paired assistant summary represent one compaction operation, not two ordinary messages. Special
|
||||
part mappings must be decided explicitly before implementing the backfill.
|
||||
|
||||
V1 synthetic content is represented by user text parts with `synthetic: true`, not by a separate message role. A V1 user
|
||||
message whose visible text parts are all synthetic should become a V2 `synthetic` message. If a V1 user message mixes
|
||||
ordinary and synthetic content, preserve the ordinary content in the V2 `user` row and emit the synthetic content as an
|
||||
adjacent V2 `synthetic` row. Ignore text parts marked `ignored`, matching V1 model-history behavior.
|
||||
|
||||
Use the V1 compaction user message ID as the ID of the collapsed V2 compaction message. This matches V2's use of the
|
||||
admitted compaction input ID and preserves references to the initiating message.
|
||||
|
||||
For a completed compaction, create one V2 `compaction` row with `status: "completed"`. Set `reason` from the V1
|
||||
compaction part's `auto` flag, join the paired summary assistant's nonempty text parts with blank lines for `summary`, and
|
||||
serialize the retained V1 tail beginning at `tail_start_id` for `recent`. Use an empty `recent` value when no tail was
|
||||
retained, and use the compaction user message creation time. Do not emit the paired summary assistant as a separate V2
|
||||
assistant row.
|
||||
|
||||
After rebuilding `session_message`, seed `event_sequence` with one row per migrated session. Set its watermark to that
|
||||
session's maximum backfilled `session_message.seq`. This prevents new V2 events from reusing sequence numbers or sorting
|
||||
before migrated history. The `event` table remains empty.
|
||||
|
||||
## Drop
|
||||
|
||||
Drop these pre-launch V2 tables without preserving or transforming their rows:
|
||||
|
||||
- `session_input`
|
||||
- `session_context_epoch`
|
||||
|
||||
Do not transfer `session_input` rows into `session_pending`.
|
||||
|
||||
## Create Empty
|
||||
|
||||
Let the generated migration create these tables empty:
|
||||
|
||||
- `instruction_blob`
|
||||
- `instruction_entry`
|
||||
- `instruction_state`
|
||||
- `session_pending`
|
||||
- `kv`
|
||||
|
||||
V1 has no canonical data to backfill into these tables. V2 initializes their state as it runs.
|
||||
|
||||
## Fork Storage
|
||||
|
||||
V1 has no fork-boundary state to backfill. New V2 forks use a required message boundary and persist it in
|
||||
`session.fork_boundary`. The durable fork event contains no parent sequence. Its resolved boundary is one of:
|
||||
|
||||
- `before`: copy messages before the identified message.
|
||||
- `through`: copy messages through the identified message.
|
||||
|
||||
Forking an empty session is not supported. `session.fork_seq` and `session.fork_message_id` are not part of the final V2
|
||||
schema.
|
||||
|
||||
New nullable session columns, including `fork_session_id`, `fork_boundary`, and `time_suspended`, require no explicit
|
||||
backfill. Existing rows naturally receive `NULL` when the generated migration adds the columns.
|
||||
|
||||
## Verification
|
||||
|
||||
The canonical migration test should seed representative V1 sessions, messages, parts, todos, projects, accounts,
|
||||
credentials, permissions, shares, and workspaces. After migration, it should verify:
|
||||
|
||||
- Preserved rows and encoded values remain unchanged.
|
||||
- Todo rows remain available in the unchanged `todo` table.
|
||||
- `event` is empty, and stale pre-launch rows are absent from the rebuilt projections.
|
||||
- Backfilled `session_message` rows represent the canonical V1 `message` and `part` history.
|
||||
- Each migrated session's `event_sequence` watermark matches its maximum backfilled message sequence.
|
||||
- Dropped tables no longer exist.
|
||||
- New tables exist and are empty.
|
||||
- The final schema has no ungenerated changes.
|
||||
@@ -46,7 +46,7 @@ const response = yield * LLMClient.generate(request)
|
||||
|
||||
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
|
||||
|
||||
Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Use `LLMClient.prepare<Body>(request)` to compile a request through the route pipeline without sending it — the optional `Body` type argument narrows `.body` to the route's native shape (e.g. `prepare<OpenAIChatBody>(...)` returns a `PreparedRequestOf<OpenAIChatBody>`). The runtime body is identical; the generic is a type-level assertion.
|
||||
Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`.
|
||||
|
||||
Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g. `events.filter(LLMEvent.is.toolCall)`). The kebab-case `LLMEvent.guards["tool-call"]` form also works but prefer `is.*` in new code.
|
||||
|
||||
@@ -138,13 +138,13 @@ packages/ai/src/
|
||||
ids.ts branded IDs, literal types, ProviderMetadata
|
||||
options.ts Generation/Provider/Http options, Limits, Model, cache policy
|
||||
messages.ts content parts, Message, ToolDefinition, LLMRequest
|
||||
events.ts Usage, individual events, LLMEvent, PreparedRequest, LLMResponse
|
||||
events.ts Usage, individual events, LLMEvent, LLMResponse
|
||||
errors.ts error reasons, LLMError, ToolFailure
|
||||
index.ts barrel
|
||||
llm.ts request constructors and convenience helpers
|
||||
route/
|
||||
index.ts @opencode-ai/ai/route advanced barrel
|
||||
client.ts Route.make + LLMClient.prepare/stream/generate
|
||||
client.ts Route.make + LLMClient.stream/generate
|
||||
executor.ts RequestExecutor service + transport error mapping
|
||||
protocol.ts Protocol type + Protocol.make
|
||||
endpoint.ts Endpoint type + Endpoint.path
|
||||
|
||||
@@ -196,7 +196,6 @@ The hosted result is represented as a provider-executed tool call and tool resul
|
||||
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
|
||||
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
|
||||
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
|
||||
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
|
||||
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
|
||||
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
|
||||
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
|
||||
|
||||
@@ -568,7 +568,7 @@ App boundary = explicit durable-config -> typed-provider call
|
||||
calling `.model(...)`.
|
||||
- [x] Remove request-shaping defaults from `Model`; selected models now carry only
|
||||
id, provider, and configured route while defaults live on routes or requests.
|
||||
- [x] Rework `LLMClient.prepare` / `stream` / `generate` to read
|
||||
- [x] Rework `LLMClient.stream` / `generate` to read
|
||||
`request.model.route` directly instead of calling `registeredRoute(...)`.
|
||||
- [x] Remove `Route.make(...)` global registration from the normal execution
|
||||
path; keep route ids only as diagnostics/provider API labels.
|
||||
|
||||
@@ -50,18 +50,6 @@ const request = LLM.request({
|
||||
},
|
||||
})
|
||||
|
||||
// `http` is intentionally not needed for normal calls. This shows the shape for
|
||||
// newly released provider fields before they deserve a typed provider option.
|
||||
const rawOverlayExample = LLM.request({
|
||||
model,
|
||||
prompt: "Show the final HTTP overlay shape.",
|
||||
http: {
|
||||
body: { metadata: { example: "tutorial" } },
|
||||
headers: { "x-opencode-tutorial": "1" },
|
||||
query: { debug: "1" },
|
||||
},
|
||||
})
|
||||
|
||||
// 3. `generate` sends the request and collects the event stream into one
|
||||
// response object. `response.text` is the collected text output.
|
||||
const generateOnce = Effect.gen(function* () {
|
||||
@@ -222,33 +210,15 @@ const FakeEcho = {
|
||||
}),
|
||||
}
|
||||
|
||||
// `LLMClient.prepare` is the lower-level inspection hook: it compiles through
|
||||
// body conversion, validation, endpoint, auth, and HTTP construction without
|
||||
// sending anything over the network.
|
||||
const inspectFakeProvider = Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: FakeEcho.configure().model("tiny-echo"),
|
||||
prompt: "Show me the provider pipeline.",
|
||||
}),
|
||||
)
|
||||
|
||||
console.log("\n== fake provider prepare ==")
|
||||
console.log("route:", prepared.route)
|
||||
console.log("body:", Formatter.formatJson(prepared.body, { space: 2 }))
|
||||
})
|
||||
|
||||
// Provide the LLM runtime and the HTTP request executor once. Keep one path
|
||||
// enabled at a time so the tutorial can demonstrate generate, prepare, stream,
|
||||
// or tool-loop behavior without spending tokens on every example.
|
||||
// enabled at a time so the tutorial can demonstrate generate, stream, or
|
||||
// tool-loop behavior without spending tokens on every example.
|
||||
const requestExecutorLayer = RequestExecutor.fetchLayer
|
||||
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
// yield* generateOnce
|
||||
// yield* inspectFakeProvider
|
||||
// yield* LLMClient.prepare(rawOverlayExample).pipe(Effect.andThen((prepared) => Effect.sync(() => console.log(prepared.body))))
|
||||
// yield* streamText
|
||||
// yield* generateStructuredObject
|
||||
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
|
||||
|
||||
+16
-11
@@ -9,25 +9,28 @@ import {
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
Message,
|
||||
Model,
|
||||
SystemPart,
|
||||
ToolChoice,
|
||||
ToolDefinition,
|
||||
type ContentPart,
|
||||
type ModelProviderOptions,
|
||||
} from "./schema"
|
||||
import { make as makeTool, toDefinitions, type ToolSchema } from "./tool"
|
||||
|
||||
/** Input accepted by `LLM.request`, normalized into the canonical `LLMRequest` class. */
|
||||
export type RequestInput = Omit<
|
||||
export type RequestInput<SelectedModel extends Model = Model> = Omit<
|
||||
ConstructorParameters<typeof LLMRequest>[0],
|
||||
"system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
|
||||
"model" | "system" | "messages" | "tools" | "toolChoice" | "generation" | "http" | "providerOptions"
|
||||
> & {
|
||||
readonly model: SelectedModel
|
||||
readonly system?: string | SystemPart | ReadonlyArray<SystemPart>
|
||||
readonly prompt?: string | ContentPart | ReadonlyArray<ContentPart>
|
||||
readonly messages?: ReadonlyArray<Message | Message.Input>
|
||||
readonly tools?: ReadonlyArray<ToolDefinition.Input>
|
||||
readonly toolChoice?: ToolChoice.Input
|
||||
readonly generation?: GenerationOptions.Input
|
||||
readonly providerOptions?: ConstructorParameters<typeof LLMRequest>[0]["providerOptions"]
|
||||
readonly providerOptions?: NoInfer<ModelProviderOptions<SelectedModel>>
|
||||
readonly http?: HttpOptions.Input
|
||||
}
|
||||
|
||||
@@ -35,7 +38,7 @@ export const generate = LLMClient.generate
|
||||
|
||||
export const stream = LLMClient.stream
|
||||
|
||||
export const request = (input: RequestInput) => {
|
||||
export const request = <const SelectedModel extends Model>(input: RequestInput<SelectedModel>) => {
|
||||
const {
|
||||
system: requestSystem,
|
||||
prompt,
|
||||
@@ -63,7 +66,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object"
|
||||
|
||||
const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool."
|
||||
|
||||
type GenerateObjectBase = Omit<RequestInput, "tools" | "toolChoice">
|
||||
type GenerateObjectBase<SelectedModel extends Model = Model> = Omit<RequestInput<SelectedModel>, "tools" | "toolChoice">
|
||||
|
||||
export class GenerateObjectResponse<T> {
|
||||
constructor(
|
||||
@@ -80,11 +83,13 @@ export class GenerateObjectResponse<T> {
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenerateObjectOptions<S extends ToolSchema<any>> extends GenerateObjectBase {
|
||||
export interface GenerateObjectOptions<S extends ToolSchema<any>, SelectedModel extends Model = Model>
|
||||
extends GenerateObjectBase<SelectedModel> {
|
||||
readonly schema: S
|
||||
}
|
||||
|
||||
export interface GenerateObjectDynamicOptions extends GenerateObjectBase {
|
||||
export interface GenerateObjectDynamicOptions<SelectedModel extends Model = Model>
|
||||
extends GenerateObjectBase<SelectedModel> {
|
||||
/** Raw JSON Schema object describing the expected output shape. */
|
||||
readonly jsonSchema: JsonSchema.JsonSchema
|
||||
}
|
||||
@@ -137,11 +142,11 @@ const runGenerateObject = Effect.fn("LLM.generateObject")(function* (
|
||||
* 2. `jsonSchema: JsonSchema.JsonSchema` — `.object` is `unknown`. Use when
|
||||
* the schema is only available at runtime (MCP, plugin manifests). Caller validates.
|
||||
*/
|
||||
export function generateObject<S extends ToolSchema<any>>(
|
||||
options: GenerateObjectOptions<S>,
|
||||
export function generateObject<const SelectedModel extends Model, S extends ToolSchema<any>>(
|
||||
options: GenerateObjectOptions<S, SelectedModel>,
|
||||
): Effect.Effect<GenerateObjectResponse<Schema.Schema.Type<S>>, LLMError>
|
||||
export function generateObject(
|
||||
options: GenerateObjectDynamicOptions,
|
||||
export function generateObject<const SelectedModel extends Model>(
|
||||
options: GenerateObjectDynamicOptions<SelectedModel>,
|
||||
): Effect.Effect<GenerateObjectResponse<unknown>, LLMError>
|
||||
export function generateObject(options: GenerateObjectOptions<ToolSchema<any>> | GenerateObjectDynamicOptions) {
|
||||
if ("schema" in options) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Model } from "./schema"
|
||||
import type { Model, ProviderOptions } from "./schema"
|
||||
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
@@ -9,8 +9,11 @@ export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
}
|
||||
}
|
||||
|
||||
export interface Definition<ProviderSettings extends Settings = Settings> {
|
||||
readonly model: (modelID: string, settings: ProviderSettings) => Model
|
||||
export interface Definition<
|
||||
ProviderSettings extends Settings = Settings,
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
> {
|
||||
readonly model: (modelID: string, settings: ProviderSettings) => Model<Options>
|
||||
}
|
||||
|
||||
export * as ProviderPackage from "./provider-package"
|
||||
|
||||
@@ -47,7 +47,7 @@ export const configure = (input: Config) => {
|
||||
})
|
||||
return {
|
||||
id: ProviderID.make(provider),
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
model: (modelID: string | ModelID) => route.model<AnthropicMessages.ProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,10 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
|
||||
@@ -52,7 +52,10 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
|
||||
@@ -99,10 +99,14 @@ export const configure = (input: Config) => {
|
||||
const modelDefaults = defaults(input)
|
||||
|
||||
const responses = (modelID: string | ModelID) =>
|
||||
configuredResponsesRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
|
||||
configuredResponsesRoute
|
||||
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
|
||||
const chat = (modelID: string | ModelID) =>
|
||||
configuredChatRoute.with(withOpenAIOptions(modelID, modelDefaults)).model({ id: modelID })
|
||||
configuredChatRoute
|
||||
.with(withOpenAIOptions(modelID, modelDefaults))
|
||||
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -133,8 +137,12 @@ const config = (settings: Settings): Config => {
|
||||
throw new Error("Azure requires resourceName or baseURL")
|
||||
}
|
||||
|
||||
export const responsesModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
configure(config(settings)).responses(modelID)
|
||||
export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
configure(config(settings)).chat(modelID)
|
||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).responses(modelID)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).chat(modelID)
|
||||
export const model = responsesModel
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Auth } from "../route/auth"
|
||||
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options"
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
||||
|
||||
export const aiGatewayID = ProviderID.make("cloudflare-ai-gateway")
|
||||
export const workersAIID = ProviderID.make("cloudflare-workers-ai")
|
||||
@@ -20,10 +21,11 @@ type GatewayURL = AtLeastOne<{
|
||||
}
|
||||
|
||||
export type AIGatewayOptions = GatewayURL &
|
||||
RouteDefaultsInput &
|
||||
Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
/** Cloudflare AI Gateway authentication token. Sent as `cf-aig-authorization`. */
|
||||
readonly gatewayApiKey?: CloudflareSecret
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
type WorkersAIURL = AtLeastOne<{
|
||||
@@ -31,7 +33,11 @@ type WorkersAIURL = AtLeastOne<{
|
||||
readonly baseURL: string
|
||||
}>
|
||||
|
||||
export type WorkersAIOptions = WorkersAIURL & RouteDefaultsInput & ProviderAuthOption<"optional">
|
||||
export type WorkersAIOptions = WorkersAIURL &
|
||||
Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const aiGatewayBaseURL = (input: GatewayURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
@@ -98,7 +104,7 @@ const configureAIGateway = (options: AIGatewayOptions) => {
|
||||
})
|
||||
return {
|
||||
id: aiGatewayID,
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
configure: configureAIGateway,
|
||||
}
|
||||
}
|
||||
@@ -111,7 +117,7 @@ const configureWorkersAI = (options: WorkersAIOptions) => {
|
||||
})
|
||||
return {
|
||||
id: workersAIID,
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
configure: configureWorkersAI,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,9 +50,11 @@ export const configure = (options: ModelOptions) => {
|
||||
const responsesRoute = configuredResponsesRoute(options)
|
||||
const chatRoute = configuredChatRoute(options)
|
||||
const responses = (modelID: string | ModelID) =>
|
||||
responsesRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
|
||||
responsesRoute
|
||||
.with(withOpenAIOptions(modelID, defaults(options)))
|
||||
.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
const chat = (modelID: string | ModelID) =>
|
||||
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
|
||||
chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat"
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
||||
|
||||
export const id = ProviderID.make("google-vertex")
|
||||
|
||||
@@ -11,6 +12,7 @@ export type Config = RouteDefaultsInput &
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
@@ -19,7 +21,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
const route = OpenAICompatibleChat.route.with({
|
||||
@@ -56,7 +58,7 @@ export const configure = (input: Config = {}) => {
|
||||
const route = configuredRoute(input)
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
model: (modelID: string | ModelID) => route.model<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
@@ -66,7 +68,7 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
|
||||
@@ -91,7 +91,7 @@ export const configure = (input: Config = {}) => {
|
||||
const route = configuredRoute(input)
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
model: (modelID: string | ModelID) => route.model<AnthropicMessages.ProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,10 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { OpenAICompatibleResponses } from "../protocols/openai-compatible-responses"
|
||||
import type { RouteDefaultsInput } from "../route/client"
|
||||
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared"
|
||||
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options"
|
||||
|
||||
export const id = ProviderID.make("google-vertex")
|
||||
|
||||
@@ -11,6 +12,7 @@ export type Config = RouteDefaultsInput &
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
@@ -19,7 +21,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||
}
|
||||
|
||||
const route = OpenAICompatibleResponses.route.with({
|
||||
@@ -58,7 +60,7 @@ export const configure = (input: Config = {}) => {
|
||||
const route = configuredRoute(input)
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
model: (modelID: string | ModelID) => route.model<OpenResponsesProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
@@ -68,7 +70,10 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
|
||||
@@ -77,7 +77,8 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
||||
export const configure = (input: Config = {}) => {
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => configuredRoute(input, modelID).model({ id: modelID }),
|
||||
model: (modelID: string | ModelID) =>
|
||||
configuredRoute(input, modelID).model<Gemini.ProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
@@ -86,7 +87,10 @@ export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
return configure({
|
||||
|
||||
@@ -50,14 +50,14 @@ export const configure = (input: Config = {}) => {
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
model: (modelID: string | ModelID) => route.model<Gemini.ProviderOptionsInput>({ id: modelID }),
|
||||
image,
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -36,7 +36,7 @@ export const configure = (input: Config) => {
|
||||
})
|
||||
return {
|
||||
id: ProviderID.make(provider),
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
model: (modelID: string | ModelID) => route.model<OpenResponsesProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,10 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -4,13 +4,15 @@ import type { RouteDefaultsInput } from "../route/client"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { profiles, type OpenAICompatibleProfile } from "./openai-compatible-profile"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
||||
|
||||
export const id = ProviderID.make("openai-compatible")
|
||||
|
||||
type GenericModelOptions = RouteDefaultsInput &
|
||||
type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly provider?: string
|
||||
readonly baseURL: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
@@ -19,9 +21,10 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly provider?: string
|
||||
}
|
||||
|
||||
export type FamilyModelOptions = RouteDefaultsInput &
|
||||
export type FamilyModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const routes = [OpenAICompatibleChat.route]
|
||||
@@ -37,7 +40,8 @@ export const configure = (input: GenericModelOptions) => {
|
||||
})
|
||||
return {
|
||||
id: ProviderID.make(provider),
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID, provider: ProviderID.make(provider) }),
|
||||
model: (modelID: string | ModelID) =>
|
||||
route.model<OpenAIProviderOptionsInput>({ id: modelID, provider: ProviderID.make(provider) }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
@@ -63,7 +67,7 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -86,10 +86,15 @@ export const configure = (input: Config = {}) => {
|
||||
const chatRoute = configuredRoute(OpenAIChat.route, input)
|
||||
const modelDefaults = defaults(input)
|
||||
const responses = (id: string | ModelID) =>
|
||||
responsesRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
|
||||
responsesRoute
|
||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||
.model<OpenAIProviderOptionsInput>({ id })
|
||||
const responsesWebSocket = (id: string | ModelID) =>
|
||||
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
|
||||
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
|
||||
responsesWebSocketRoute
|
||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||
.model<OpenAIProviderOptionsInput>({ id })
|
||||
const chat = (id: string | ModelID) =>
|
||||
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
|
||||
const image = (modelID: string | ModelID) =>
|
||||
OpenAIImages.model({
|
||||
id: modelID,
|
||||
@@ -132,15 +137,17 @@ const config = (settings: Settings): Config => {
|
||||
}
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) => {
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
const configured = configure(config(settings))
|
||||
if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID)
|
||||
if (settings.transport === "websocket") return configured.responsesWebSocket(modelID)
|
||||
throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`)
|
||||
}
|
||||
|
||||
export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID, settings) =>
|
||||
configure(config(settings)).chat(modelID)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).chat(modelID)
|
||||
export const responses = provider.responses
|
||||
export const responsesWebSocket = provider.responsesWebSocket
|
||||
export const chat = provider.chat
|
||||
|
||||
@@ -107,7 +107,7 @@ export const configure = (input: ModelOptions = {}) => {
|
||||
const route = configuredRoute(input)
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) => route.model({ id: modelID }),
|
||||
model: (modelID: string | ModelID) => route.model<OpenRouterProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
|
||||
import * as OpenAICompatibleChat from "../protocols/openai-compatible-chat"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||
import { XAIImages } from "../protocols/xai-images"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options"
|
||||
|
||||
export const id = ProviderID.make("xai")
|
||||
|
||||
export type ModelOptions = RouteDefaultsInput &
|
||||
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export type { XAIImageOptions } from "../protocols/xai-images"
|
||||
@@ -42,8 +44,8 @@ const configuredChatRoute = (input: ModelOptions) => {
|
||||
export const configure = (input: ModelOptions = {}) => {
|
||||
const responsesRoute = configuredResponsesRoute(input)
|
||||
const chatRoute = configuredChatRoute(input)
|
||||
const responses = (modelID: string | ModelID) => responsesRoute.model({ id: modelID })
|
||||
const chat = (modelID: string | ModelID) => chatRoute.model({ id: modelID })
|
||||
const responses = (modelID: string | ModelID) => responsesRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
const chat = (modelID: string | ModelID) => chatRoute.model<OpenAIProviderOptionsInput>({ id: modelID })
|
||||
const image = (modelID: string | ModelID) =>
|
||||
XAIImages.model({
|
||||
id: modelID,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { WebSocketExecutor } from "./transport"
|
||||
import type { Protocol } from "./protocol"
|
||||
import { applyCachePolicy } from "../cache-policy"
|
||||
import * as ProviderShared from "../protocols/shared"
|
||||
import type { LLMError, PreparedRequestOf, ProtocolID, ProviderOptions } from "../schema"
|
||||
import type { LLMError, ProtocolID, ProviderOptions } from "../schema"
|
||||
import {
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
ModelLimits,
|
||||
LLMError as LLMErrorClass,
|
||||
LLMEvent,
|
||||
PreparedRequest,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
mergeHttpOptions,
|
||||
@@ -46,7 +45,7 @@ export interface Route<Body, Prepared = unknown> {
|
||||
readonly defaults: RouteDefaults
|
||||
readonly body: RouteBody<Body>
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
|
||||
readonly model: (input: RouteMappedModelInput) => Model
|
||||
readonly model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) => Model<Options>
|
||||
readonly prepareTransport: (body: Body, request: LLMRequest) => Effect.Effect<Prepared, LLMError>
|
||||
readonly streamPrepared: (
|
||||
prepared: Prepared,
|
||||
@@ -93,12 +92,12 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
|
||||
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
|
||||
|
||||
const makeRouteModel = (route: AnyRoute, mapped: RouteMappedModelInput) => {
|
||||
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(route: AnyRoute, mapped: RouteMappedModelInput) => {
|
||||
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
||||
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
||||
if (!endpointBaseURL(route.endpoint))
|
||||
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
|
||||
return Model.make({
|
||||
return Model.make<Options>({
|
||||
...mapped,
|
||||
provider,
|
||||
route,
|
||||
@@ -142,17 +141,6 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Compile a request through protocol body construction, validation, and HTTP
|
||||
* preparation without sending it. Returns the prepared request including the
|
||||
* provider-native body.
|
||||
*
|
||||
* Pass a `Body` type argument to statically expose the route's body
|
||||
* shape (e.g. `prepare<OpenAIChatBody>(...)`) — the runtime body is
|
||||
* identical, so this is a type-level assertion the caller makes about which
|
||||
* route the request will resolve to.
|
||||
*/
|
||||
readonly prepare: <Body = unknown>(request: LLMRequest) => Effect.Effect<PreparedRequestOf<Body>, LLMError>
|
||||
readonly stream: StreamMethod
|
||||
readonly generate: GenerateMethod
|
||||
}
|
||||
@@ -296,7 +284,8 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
defaults: mergeRouteDefaults(route.defaults, defaults),
|
||||
})
|
||||
},
|
||||
model: (input) => makeRouteModel(route, input),
|
||||
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedModelInput) =>
|
||||
makeRouteModel<Options>(route, input),
|
||||
prepareTransport: (body, request) =>
|
||||
routeInput.transport.prepare({
|
||||
body,
|
||||
@@ -370,9 +359,6 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
}
|
||||
|
||||
// `compile` is the important boundary: it turns a common `LLMRequest` into a
|
||||
// validated provider body plus transport-private prepared data, but does not
|
||||
// execute transport.
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
|
||||
const resolved = applyCachePolicy(resolveRequestOptions(request))
|
||||
const route = resolved.model.route
|
||||
@@ -390,17 +376,17 @@ const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) {
|
||||
}
|
||||
})
|
||||
|
||||
const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) {
|
||||
/** @internal Test-only projection of the execution compiler; not exported from package barrels. */
|
||||
export const compileRequest = Effect.fn("LLM.compileRequest")(function* (request: LLMRequest) {
|
||||
const compiled = yield* compile(request)
|
||||
|
||||
return new PreparedRequest({
|
||||
return {
|
||||
id: compiled.request.id ?? "request",
|
||||
route: compiled.route.id,
|
||||
protocol: compiled.route.protocol,
|
||||
model: compiled.request.model,
|
||||
body: compiled.body,
|
||||
metadata: { transport: compiled.route.transport.id },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
|
||||
@@ -422,9 +408,6 @@ const generateWith = (stream: Interface["stream"]) =>
|
||||
)
|
||||
})
|
||||
|
||||
export const prepare = <Body = unknown>(request: LLMRequest) =>
|
||||
prepareWith(request) as Effect.Effect<PreparedRequestOf<Body>, LLMError>
|
||||
|
||||
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
@@ -453,7 +436,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
|
||||
http: yield* RequestExecutor.Service,
|
||||
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
|
||||
})
|
||||
return Service.of({ prepare: prepareWith as Interface["prepare"], stream, generate: generateWith(stream) })
|
||||
return Service.of({ stream, generate: generateWith(stream) })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -462,7 +445,6 @@ export const Route = { make } as const
|
||||
export const LLMClient = {
|
||||
Service,
|
||||
layer,
|
||||
prepare,
|
||||
stream,
|
||||
generate,
|
||||
} as const
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids"
|
||||
import { ModelSchema } from "./options"
|
||||
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids"
|
||||
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages"
|
||||
import { ProviderFailureClassification } from "./errors"
|
||||
|
||||
@@ -314,29 +313,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
})
|
||||
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
|
||||
|
||||
export class PreparedRequest extends Schema.Class<PreparedRequest>("LLM.PreparedRequest")({
|
||||
id: Schema.String,
|
||||
route: RouteID,
|
||||
protocol: ProtocolID,
|
||||
model: ModelSchema,
|
||||
body: Schema.Unknown,
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* A `PreparedRequest` whose `body` is typed as `Body`. Use with the generic
|
||||
* on `LLMClient.prepare<Body>(...)` when the caller knows which route their
|
||||
* request will resolve to and wants its native shape statically exposed
|
||||
* (debug UIs, request previews, plan rendering).
|
||||
*
|
||||
* The runtime body is identical — the route still emits `body: unknown` — so
|
||||
* this is a type-level assertion the caller makes about what they expect to
|
||||
* find. The prepare runtime does not validate the assertion.
|
||||
*/
|
||||
export type PreparedRequestOf<Body> = Omit<PreparedRequest, "body"> & {
|
||||
readonly body: Body
|
||||
}
|
||||
|
||||
const responseText = (events: ReadonlyArray<LLMEvent>) =>
|
||||
events
|
||||
.filter(LLMEvent.is.textDelta)
|
||||
|
||||
@@ -178,7 +178,8 @@ export namespace ModelCompatibility {
|
||||
export const make = (input: Input) => (input instanceof ModelCompatibility ? input : new ModelCompatibility(input))
|
||||
}
|
||||
|
||||
export class Model {
|
||||
export class Model<Options extends ProviderOptions = ProviderOptions> {
|
||||
declare protected readonly _ProviderOptions: Options
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: AnyRoute
|
||||
@@ -193,8 +194,8 @@ export class Model {
|
||||
this.compatibility = input.compatibility
|
||||
}
|
||||
|
||||
static make(input: Model.Input) {
|
||||
return new Model({
|
||||
static make<Options extends ProviderOptions = ProviderOptions>(input: Model.Input) {
|
||||
return new Model<Options>({
|
||||
id: ModelID.make(input.id),
|
||||
provider: ProviderID.make(input.provider),
|
||||
route: input.route,
|
||||
@@ -203,7 +204,7 @@ export class Model {
|
||||
})
|
||||
}
|
||||
|
||||
static input(model: Model): Model.ConstructorInput {
|
||||
static input<Options extends ProviderOptions>(model: Model<Options>): Model.ConstructorInput {
|
||||
return {
|
||||
id: model.id,
|
||||
provider: model.provider,
|
||||
@@ -213,9 +214,9 @@ export class Model {
|
||||
}
|
||||
}
|
||||
|
||||
static update(model: Model, patch: Partial<Model.Input>) {
|
||||
static update<Options extends ProviderOptions>(model: Model<Options>, patch: Partial<Model.Input>) {
|
||||
if (Object.keys(patch).length === 0) return model
|
||||
return Model.make({
|
||||
return Model.make<Options>({
|
||||
...Model.input(model),
|
||||
...patch,
|
||||
})
|
||||
@@ -241,6 +242,8 @@ export namespace Model {
|
||||
|
||||
export type ModelInput = Model.Input
|
||||
|
||||
export type ModelProviderOptions<SelectedModel> = SelectedModel extends Model<infer Options> ? Options : never
|
||||
|
||||
export const ModelSchema = Schema.declare((value): value is Model => value instanceof Model, { expected: "LLM.Model" })
|
||||
|
||||
export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
|
||||
|
||||
@@ -99,7 +99,6 @@ export const layer = (options: LayerOptions = {}) =>
|
||||
)
|
||||
}) as LLMClientShape["stream"]
|
||||
const client = LLMClient.Service.of({
|
||||
prepare: () => Effect.die("TestLLM does not prepare provider-native requests"),
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { LLM, LLMRequest, LLMResponse } from "../src"
|
||||
import { Route, Endpoint, LLMClient, Protocol, type FramingDef } from "../src/route"
|
||||
import { compileRequest } from "../src/route/client"
|
||||
import { Model } from "../src/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { dynamicResponse } from "./lib/http"
|
||||
@@ -139,8 +140,7 @@ describe("llm route", () => {
|
||||
|
||||
it.effect("selects routes by model route value", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* LLMClient.Service
|
||||
const prepared = yield* llm.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, { model: updateModel(request.model, { route: configuredGemini }) }),
|
||||
)
|
||||
|
||||
@@ -173,7 +173,7 @@ describe("llm route", () => {
|
||||
framing: fakeFraming,
|
||||
})
|
||||
|
||||
const prepared = yield* (yield* LLMClient.Service).prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, { model: updateModel(request.model, { route: duplicate }) }),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message } from "../src"
|
||||
import { Auth, LLMClient } from "../src/route"
|
||||
import { Auth } from "../src/route"
|
||||
import { compileRequest } from "../src/route/client"
|
||||
import { AmazonBedrock } from "../src/providers"
|
||||
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
|
||||
import * as Gemini from "../src/protocols/gemini"
|
||||
@@ -31,7 +32,7 @@ const geminiModel = Gemini.route
|
||||
describe("applyCachePolicy", () => {
|
||||
it.effect("undefined cache resolves to 'auto' (the recommended default)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "You are concise.",
|
||||
@@ -50,7 +51,7 @@ describe("applyCachePolicy", () => {
|
||||
|
||||
it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: [
|
||||
@@ -87,7 +88,7 @@ describe("applyCachePolicy", () => {
|
||||
|
||||
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: openaiModel,
|
||||
system: "Sys",
|
||||
@@ -106,7 +107,7 @@ describe("applyCachePolicy", () => {
|
||||
|
||||
it.effect("'auto' is a no-op on Gemini (out-of-band caching protocol)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: geminiModel,
|
||||
system: "Sys",
|
||||
@@ -123,7 +124,7 @@ describe("applyCachePolicy", () => {
|
||||
|
||||
it.effect("'auto' on Bedrock emits cachePoint markers in the right places", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: bedrockModel,
|
||||
system: [
|
||||
@@ -157,7 +158,7 @@ describe("applyCachePolicy", () => {
|
||||
|
||||
it.effect("'none' disables auto placement even when manual hints exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "Sys",
|
||||
@@ -176,7 +177,7 @@ describe("applyCachePolicy", () => {
|
||||
|
||||
it.effect("granular object form: tools-only marks just tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "Sys",
|
||||
@@ -195,7 +196,7 @@ describe("applyCachePolicy", () => {
|
||||
|
||||
it.effect("auto policy preserves manual CacheHints on other parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: [
|
||||
@@ -241,7 +242,7 @@ describe("applyCachePolicy", () => {
|
||||
expect("cache" in tail ? tail.cache : undefined).toBeUndefined()
|
||||
expect(applyCachePolicy(applied)).toBe(applied)
|
||||
|
||||
const prepared = yield* LLMClient.prepare(request)
|
||||
const prepared = yield* compileRequest(request)
|
||||
|
||||
const body = prepared.body as {
|
||||
tools: Array<{ cache_control?: unknown }>
|
||||
@@ -261,7 +262,7 @@ describe("applyCachePolicy", () => {
|
||||
|
||||
it.effect("ttlSeconds in the policy flows through to wire markers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "Sys",
|
||||
@@ -278,7 +279,7 @@ describe("applyCachePolicy", () => {
|
||||
|
||||
it.effect("messages: { tail: 2 } marks the last 2 message boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2"), Message.assistant("a2")],
|
||||
@@ -296,7 +297,7 @@ describe("applyCachePolicy", () => {
|
||||
|
||||
it.effect("'latest-assistant' marks the last assistant message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
messages: [Message.user("u1"), Message.assistant("a1"), Message.user("u2")],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, mergeProviderOptions } from "../src"
|
||||
import { AnthropicMessages, OpenAIChat } from "../src/protocols"
|
||||
import { Auth, LLMClient } from "../src/route"
|
||||
import { compileRequest } from "../src/route/client"
|
||||
import { it } from "./lib/effect"
|
||||
import { dynamicResponse } from "./lib/http"
|
||||
import { deltaChunk } from "./lib/openai-chunks"
|
||||
@@ -44,7 +45,7 @@ describe("request option precedence", () => {
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("prepares bodies with route defaults, model defaults, and call options in order", () =>
|
||||
it.effect("compiles bodies with route defaults, model defaults, and call options in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const route = OpenAIChat.route.with({
|
||||
endpoint: { baseURL: "https://api.openai.test/v1/" },
|
||||
@@ -59,7 +60,7 @@ describe("request option precedence", () => {
|
||||
providerOptions: { openai: { reasoningEffort: "medium" } },
|
||||
},
|
||||
})
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Say hello.",
|
||||
@@ -141,7 +142,7 @@ describe("request option precedence", () => {
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Say hello.",
|
||||
@@ -164,10 +165,8 @@ describe("request option precedence", () => {
|
||||
limits: { output: 128 },
|
||||
})
|
||||
const model = route.model({ id: "claude-sonnet-4-5", defaults: { limits: { output: 64 } } })
|
||||
const withoutMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({ model, prompt: "Say hello.", cache: "none" }),
|
||||
)
|
||||
const withMaxTokens = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
const withoutMaxTokens = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
|
||||
const withMaxTokens = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "Say hello.", cache: "none", generation: { maxTokens: 32 } }),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Schema } from "effect"
|
||||
import { LLM, type Model, type ModelProviderOptions, type ProviderOptions } from "../src"
|
||||
import { OpenAIChat } from "../src/protocols"
|
||||
|
||||
interface ExampleOptions {
|
||||
readonly [key: string]: unknown
|
||||
readonly mode?: "fast" | "thorough"
|
||||
}
|
||||
|
||||
type ExampleProviderOptions = ProviderOptions & {
|
||||
readonly example?: ExampleOptions
|
||||
}
|
||||
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://example.com/v1" } })
|
||||
.model<ExampleProviderOptions>({ id: "example" })
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { example: { mode: "fast" } } })
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { future: { option: true } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Known provider options preserve their value types.
|
||||
providerOptions: { example: { mode: "slow" } },
|
||||
})
|
||||
|
||||
LLM.generateObject({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
schema: Schema.Struct({ answer: Schema.String }),
|
||||
providerOptions: { example: { mode: "thorough" } },
|
||||
})
|
||||
|
||||
LLM.generateObject({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
jsonSchema: { type: "object" },
|
||||
// @ts-expect-error Dynamic object generation uses the selected model's provider options.
|
||||
providerOptions: { example: { mode: false } },
|
||||
})
|
||||
|
||||
declare const generic: Model
|
||||
LLM.request({ model: generic, prompt: "Hello", providerOptions: { arbitrary: { option: true } } })
|
||||
|
||||
const options: ModelProviderOptions<typeof model> = { example: { mode: "fast" } }
|
||||
void options
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LLM } from "../../src"
|
||||
import { AnthropicCompatible } from "../../src/providers"
|
||||
|
||||
const model = AnthropicCompatible.configure({ baseURL: "https://example.com" }).model("claude")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { effort: "high" } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Anthropic effort must be a string.
|
||||
providerOptions: { anthropic: { effort: 1 } },
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LLM } from "../../src"
|
||||
import { Anthropic } from "../../src/providers"
|
||||
|
||||
const model = Anthropic.provider.model("claude-sonnet-4-5")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { thinking: { type: "adaptive" } } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Anthropic thinking modes are a fixed union.
|
||||
providerOptions: { anthropic: { thinking: { type: "automatic" } } },
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LLM } from "../../src"
|
||||
import { Azure } from "../../src/providers"
|
||||
|
||||
const model = Azure.configure({ resourceName: "example" }).responses("deployment")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { store: false } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Azure OpenAI store must be boolean.
|
||||
providerOptions: { openai: { store: "false" } },
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LLM } from "../../src"
|
||||
import { CloudflareWorkersAI } from "../../src/providers"
|
||||
|
||||
const model = CloudflareWorkersAI.configure({ accountId: "account", apiKey: "test" }).model("model")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { promptCacheKey: "cache" } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Cloudflare's OpenAI-compatible prompt cache key must be a string.
|
||||
providerOptions: { openai: { promptCacheKey: 1 } },
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LLM } from "../../src"
|
||||
import { GitHubCopilot } from "../../src/providers"
|
||||
|
||||
const model = GitHubCopilot.configure({ baseURL: "https://example.com" }).model("gpt-5")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningSummary: "auto" } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Copilot reasoning summaries use the OpenAI union.
|
||||
providerOptions: { openai: { reasoningSummary: "full" } },
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LLM } from "../../src"
|
||||
import { GoogleVertexChat } from "../../src/providers"
|
||||
|
||||
const model = GoogleVertexChat.configure({ accessToken: "test", project: "project" }).model("gemini")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { serviceTier: "priority" } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Vertex OpenAI-compatible service tiers use the OpenAI union.
|
||||
providerOptions: { openai: { serviceTier: "premium" } },
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LLM } from "../../src"
|
||||
import { GoogleVertexMessages } from "../../src/providers"
|
||||
|
||||
const model = GoogleVertexMessages.configure({ accessToken: "test", project: "project" }).model("claude")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { anthropic: { effort: "medium" } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Vertex Anthropic effort must be a string.
|
||||
providerOptions: { anthropic: { effort: false } },
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LLM } from "../../src"
|
||||
import { GoogleVertexResponses } from "../../src/providers"
|
||||
|
||||
const model = GoogleVertexResponses.configure({ accessToken: "test", project: "project" }).model("gemini")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openresponses: { textVerbosity: "high" } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Vertex Responses verbosity uses the Open Responses union.
|
||||
providerOptions: { openresponses: { textVerbosity: "verbose" } },
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { LLM } from "../../src"
|
||||
import { GoogleVertex } from "../../src/providers"
|
||||
|
||||
const model = GoogleVertex.provider.configure({ apiKey: "test" }).model("gemini-2.5-pro")
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: { gemini: { thinkingConfig: { includeThoughts: true } } },
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Vertex Gemini includeThoughts must be boolean.
|
||||
providerOptions: { gemini: { thinkingConfig: { includeThoughts: "yes" } } },
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import { LLM } from "../../src"
|
||||
import { Google } from "../../src/providers"
|
||||
|
||||
const model = Google.provider.model("gemini-2.5-pro")
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 1024 } } },
|
||||
})
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Gemini thinking budgets must be numeric.
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "large" } } },
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LLM } from "../../src"
|
||||
import { OpenAICompatibleResponses } from "../../src/providers"
|
||||
|
||||
const model = OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("model")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openresponses: { reasoningSummary: "detailed" } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error Open Responses reasoning summaries use a fixed union.
|
||||
providerOptions: { openresponses: { reasoningSummary: "full" } },
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LLM } from "../../src"
|
||||
import { OpenAICompatible } from "../../src/providers"
|
||||
|
||||
const model = OpenAICompatible.deepseek.model("deepseek-chat")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { store: false } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error OpenAI-compatible store must be boolean.
|
||||
providerOptions: { openai: { store: "false" } },
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LLM } from "../../src"
|
||||
import { OpenAI } from "../../src/providers"
|
||||
|
||||
const model = OpenAI.responses("gpt-5")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error OpenAI reasoning effort must be a string.
|
||||
providerOptions: { openai: { reasoningEffort: 1 } },
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LLM } from "../../src"
|
||||
import { OpenRouter } from "../../src/providers"
|
||||
|
||||
const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openrouter: { usage: true } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error OpenRouter usage must be boolean or an option record.
|
||||
providerOptions: { openrouter: { usage: "yes" } },
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LLM } from "../../src"
|
||||
import { XAI } from "../../src/providers"
|
||||
|
||||
const model = XAI.provider.model("grok-4")
|
||||
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error xAI's OpenAI-compatible reasoning effort must be a string.
|
||||
providerOptions: { openai: { reasoningEffort: true } },
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { CacheHint, LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
|
||||
import { Auth, LLMClient } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
||||
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
|
||||
import { it } from "../lib/effect"
|
||||
@@ -44,7 +45,7 @@ const expectToolResult = (body: AnthropicMessages.AnthropicMessagesBody): Anthro
|
||||
describe("Anthropic Messages route", () => {
|
||||
it.effect("prepares Anthropic Messages target", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(request)
|
||||
const prepared = yield* compileRequest(request)
|
||||
|
||||
expect(prepared.body).toEqual({
|
||||
model: "claude-sonnet-4-5",
|
||||
@@ -59,7 +60,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("lowers adaptive thinking settings with effort", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {
|
||||
anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "low" },
|
||||
@@ -76,17 +77,17 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("normalizes enabled and disabled thinking settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const enabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
const enabled = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { anthropic: { thinking: { type: "enabled", budgetTokens: 1_024 } } },
|
||||
}),
|
||||
)
|
||||
const legacy = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
const legacy = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { anthropic: { thinking: { type: "enabled", budget_tokens: 2_048 } } },
|
||||
}),
|
||||
)
|
||||
const disabled = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
const disabled = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { anthropic: { thinking: { type: "disabled" } } },
|
||||
}),
|
||||
@@ -100,7 +101,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("rejects enabled thinking without a budget", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { anthropic: { thinking: { type: "enabled" } } },
|
||||
}),
|
||||
@@ -112,7 +113,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [
|
||||
@@ -137,7 +138,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -164,7 +165,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("rejects non-text chronological system update content before send", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [
|
||||
@@ -181,7 +182,7 @@ describe("Anthropic Messages route", () => {
|
||||
it.effect("falls back for unsupported native chronological system update placement", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
(yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [Message.assistant("Plain."), Message.system("After plain assistant.")],
|
||||
@@ -196,12 +197,11 @@ describe("Anthropic Messages route", () => {
|
||||
},
|
||||
])
|
||||
expect(
|
||||
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" }),
|
||||
)).body.messages,
|
||||
(yield* compileRequest(LLM.request({ model: opus48, messages: [Message.system("First.")], cache: "none" })))
|
||||
.body.messages,
|
||||
).toEqual([{ role: "user", content: [{ type: "text", text: "<system-update>\nFirst.\n</system-update>" }] }])
|
||||
expect(
|
||||
(yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
(yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [Message.user("Before."), Message.system("One."), Message.system("Two.")],
|
||||
@@ -223,7 +223,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("rejects a system update between a local tool call and its result", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [
|
||||
@@ -242,7 +242,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("prepares tool call and tool result messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_result",
|
||||
model,
|
||||
@@ -273,7 +273,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("keeps tools and sends tool_choice none", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_choice_none",
|
||||
model,
|
||||
@@ -303,7 +303,7 @@ describe("Anthropic Messages route", () => {
|
||||
// not JSON-stringified into `tool_result.content`.
|
||||
it.effect("lowers media tool-result content as structured blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_result_image",
|
||||
model,
|
||||
@@ -335,7 +335,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("lowers single-image tool-result content as a structured image block", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_result_image_only",
|
||||
model,
|
||||
@@ -360,7 +360,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_result_unsupported_media",
|
||||
model,
|
||||
@@ -384,7 +384,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("prepares the composed native continuation request", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
continuationRequest({
|
||||
id: "req_native_continuation_anthropic",
|
||||
model,
|
||||
@@ -428,7 +428,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("lowers preserved Anthropic reasoning signature metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -447,7 +447,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("round-trips redacted thinking as redacted_thinking blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -628,9 +628,7 @@ describe("Anthropic Messages route", () => {
|
||||
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
])
|
||||
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({ model, messages: [response.message], cache: "none" }),
|
||||
)
|
||||
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" }))
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] },
|
||||
])
|
||||
@@ -773,7 +771,7 @@ describe("Anthropic Messages route", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -1133,7 +1131,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("round-trips provider-executed assistant content into server tool blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_round_trip",
|
||||
model,
|
||||
@@ -1184,7 +1182,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("rejects round-trip for unknown server tool names", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_unknown_server_tool",
|
||||
model,
|
||||
@@ -1261,7 +1259,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("maps ttlSeconds >= 3600 to cache_control ttl: '1h'", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
system: { type: "text", text: "system", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }) },
|
||||
@@ -1277,7 +1275,7 @@ describe("Anthropic Messages route", () => {
|
||||
|
||||
it.effect("emits cache_control on tool definitions and tool-result blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
tools: [
|
||||
@@ -1318,7 +1316,7 @@ describe("Anthropic Messages route", () => {
|
||||
it.effect("drops cache_control breakpoints past the 4-per-request cap", () =>
|
||||
Effect.gen(function* () {
|
||||
const hint = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
system: [
|
||||
@@ -1344,7 +1342,7 @@ describe("Anthropic Messages route", () => {
|
||||
it.effect("spends breakpoint budget on tools before system before messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const hint = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
tools: [
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ToolDefinition,
|
||||
} from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import { AmazonBedrock } from "../../src/providers"
|
||||
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
|
||||
import { it } from "../lib/effect"
|
||||
@@ -101,7 +102,7 @@ const baseRequest = LLM.request({
|
||||
describe("Bedrock Converse route", () => {
|
||||
it.effect("prepares Converse target with system, inference config, and messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(baseRequest)
|
||||
const prepared = yield* compileRequest(baseRequest)
|
||||
|
||||
expect(prepared.body).toEqual({
|
||||
modelId: "anthropic.claude-3-5-sonnet-20240620-v1:0",
|
||||
@@ -114,7 +115,7 @@ describe("Bedrock Converse route", () => {
|
||||
|
||||
it.effect("passes topK through additionalModelRequestFields as top_k", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(baseRequest, {
|
||||
generation: GenerationOptions.make({ maxTokens: 64, temperature: 0, topK: 40 }),
|
||||
}),
|
||||
@@ -129,14 +130,14 @@ describe("Bedrock Converse route", () => {
|
||||
|
||||
it.effect("omits additionalModelRequestFields when topK is unset", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(baseRequest)
|
||||
const prepared = yield* compileRequest(baseRequest)
|
||||
expect(prepared.body.additionalModelRequestFields).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to wrapped user text in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
|
||||
@@ -153,7 +154,7 @@ describe("Bedrock Converse route", () => {
|
||||
|
||||
it.effect("prepares tool config with toolSpec and toolChoice", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(baseRequest, {
|
||||
tools: [
|
||||
ToolDefinition.make({
|
||||
@@ -187,7 +188,7 @@ describe("Bedrock Converse route", () => {
|
||||
|
||||
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(baseRequest, {
|
||||
tools: [
|
||||
ToolDefinition.make({
|
||||
@@ -217,7 +218,7 @@ describe("Bedrock Converse route", () => {
|
||||
|
||||
it.effect("lowers assistant tool-call + tool-result message history", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_history",
|
||||
model,
|
||||
@@ -256,7 +257,7 @@ describe("Bedrock Converse route", () => {
|
||||
|
||||
it.effect("lowers image content in tool-result messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_image",
|
||||
model,
|
||||
@@ -491,7 +492,7 @@ describe("Bedrock Converse route", () => {
|
||||
providerMetadata: { bedrock: { signature: "sig_1" } },
|
||||
})
|
||||
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -546,9 +547,7 @@ describe("Bedrock Converse route", () => {
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
LLM.request({ model, messages: [response.message], cache: "none" }),
|
||||
)
|
||||
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message], cache: "none" }))
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
@@ -639,7 +638,7 @@ describe("Bedrock Converse route", () => {
|
||||
text: "",
|
||||
providerMetadata: { bedrock: { redactedData } },
|
||||
})
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -754,7 +753,7 @@ describe("Bedrock Converse route", () => {
|
||||
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
},
|
||||
}).model("anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
const prepared = yield* LLMClient.prepare(LLMRequest.update(baseRequest, { model: signed }))
|
||||
const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed }))
|
||||
|
||||
expect(prepared.route).toBe("bedrock-converse")
|
||||
expect(prepared.model).toBe(signed)
|
||||
@@ -764,7 +763,7 @@ describe("Bedrock Converse route", () => {
|
||||
it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_cache",
|
||||
model,
|
||||
@@ -796,7 +795,7 @@ describe("Bedrock Converse route", () => {
|
||||
|
||||
it.effect("does not emit cachePoint when no cache hint is set", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(baseRequest)
|
||||
const prepared = yield* compileRequest(baseRequest)
|
||||
expect(prepared.body).toMatchObject({
|
||||
system: [{ text: "You are concise." }],
|
||||
messages: [{ role: "user", content: [{ text: "Say hello." }] }],
|
||||
@@ -806,7 +805,7 @@ describe("Bedrock Converse route", () => {
|
||||
|
||||
it.effect("lowers image media into Bedrock image blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_image",
|
||||
model,
|
||||
@@ -843,7 +842,7 @@ describe("Bedrock Converse route", () => {
|
||||
|
||||
it.effect("base64-encodes Uint8Array image bytes", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_image_bytes",
|
||||
model,
|
||||
@@ -865,7 +864,7 @@ describe("Bedrock Converse route", () => {
|
||||
|
||||
it.effect("lowers document media into Bedrock document blocks with format and name", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_doc",
|
||||
model,
|
||||
@@ -897,7 +896,7 @@ describe("Bedrock Converse route", () => {
|
||||
|
||||
it.effect("requires names for document media", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
|
||||
@@ -910,7 +909,7 @@ describe("Bedrock Converse route", () => {
|
||||
|
||||
it.effect("passes named document-only messages through for provider validation", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
cache: "none",
|
||||
@@ -936,7 +935,7 @@ describe("Bedrock Converse route", () => {
|
||||
|
||||
it.effect("lowers document media in tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
cache: "none",
|
||||
@@ -988,7 +987,7 @@ describe("Bedrock Converse route", () => {
|
||||
|
||||
it.effect("rejects unsupported image media types", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_bad_image",
|
||||
model,
|
||||
@@ -1002,7 +1001,7 @@ describe("Bedrock Converse route", () => {
|
||||
|
||||
it.effect("rejects unsupported document media types", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_bad_doc",
|
||||
model,
|
||||
@@ -1017,7 +1016,7 @@ describe("Bedrock Converse route", () => {
|
||||
it.effect("maps ttlSeconds >= 3600 to cachePoint ttl: '1h'", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral", ttlSeconds: 3600 })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
system: [{ type: "text", text: "system", cache }],
|
||||
@@ -1034,7 +1033,7 @@ describe("Bedrock Converse route", () => {
|
||||
it.effect("appends cachePoint after marked tool definitions and tool-result blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
tools: [{ name: "lookup", description: "lookup", inputSchema: { type: "object", properties: {} }, cache }],
|
||||
@@ -1066,7 +1065,7 @@ describe("Bedrock Converse route", () => {
|
||||
it.effect("drops cachePoint markers past the 4-per-request cap", () =>
|
||||
Effect.gen(function* () {
|
||||
const cache = new CacheHint({ type: "ephemeral" })
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
system: [
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ConfigProvider, Effect, Schema } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMEvent } from "../../src"
|
||||
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import { it } from "../lib/effect"
|
||||
import { dynamicResponse } from "../lib/http"
|
||||
import { sseEvents } from "../lib/sse"
|
||||
@@ -34,7 +34,7 @@ describe("Cloudflare", () => {
|
||||
})
|
||||
expect(model.route.endpoint.baseURL).toBe("https://gateway.ai.cloudflare.com/v1/test-account/test-gateway/compat")
|
||||
|
||||
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
|
||||
|
||||
expect(prepared.route).toBe("cloudflare-ai-gateway")
|
||||
expect(prepared.body).toMatchObject({
|
||||
@@ -129,7 +129,7 @@ describe("Cloudflare", () => {
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: merged },
|
||||
})
|
||||
|
||||
const replay = yield* LLMClient.prepare(LLM.request({ model, messages: [response.message] }))
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged },
|
||||
])
|
||||
@@ -180,7 +180,7 @@ describe("Cloudflare", () => {
|
||||
|
||||
it.effect("allows a fully configured baseURL override", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: CloudflareAIGateway.configure({
|
||||
baseURL: "https://gateway.proxy.test/v1/custom/compat",
|
||||
@@ -208,7 +208,7 @@ describe("Cloudflare", () => {
|
||||
})
|
||||
expect(model.route.endpoint.baseURL).toBe("https://api.cloudflare.com/client/v4/accounts/test-account/ai/v1")
|
||||
|
||||
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
|
||||
|
||||
expect(prepared.route).toBe("cloudflare-workers-ai")
|
||||
expect(prepared.body).toMatchObject({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage } from "../../src"
|
||||
import { Auth, LLMClient } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import * as Gemini from "../../src/protocols/gemini"
|
||||
import { ProviderShared } from "../../src/protocols/shared"
|
||||
import { it } from "../lib/effect"
|
||||
@@ -26,7 +27,7 @@ const request = LLM.request({
|
||||
describe("Gemini route", () => {
|
||||
it.effect("prepares Gemini target", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(request)
|
||||
const prepared = yield* compileRequest(request)
|
||||
|
||||
expect(prepared.body).toEqual({
|
||||
contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
|
||||
@@ -38,12 +39,12 @@ describe("Gemini route", () => {
|
||||
|
||||
it.effect("normalizes Gemini thinking options", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: 0, includeThoughts: false } } },
|
||||
}),
|
||||
)
|
||||
const filtered = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||
const filtered = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: { gemini: { thinkingConfig: { thinkingBudget: "invalid", includeThoughts: false } } },
|
||||
}),
|
||||
@@ -59,7 +60,7 @@ describe("Gemini route", () => {
|
||||
|
||||
it.effect("lowers chronological system updates to wrapped user text in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
|
||||
@@ -75,7 +76,7 @@ describe("Gemini route", () => {
|
||||
|
||||
it.effect("prepares multimodal user input and tool history", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_result",
|
||||
model,
|
||||
@@ -143,7 +144,7 @@ describe("Gemini route", () => {
|
||||
|
||||
it.effect("continues media tool results as inline model input without base64 text", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -188,7 +189,7 @@ describe("Gemini route", () => {
|
||||
|
||||
it.effect("strips matching data URLs to raw base64 inlineData", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -229,7 +230,7 @@ describe("Gemini route", () => {
|
||||
] as const)
|
||||
it.effect(`rejects ${name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.message).toMatch(/does not support|does not match|valid base64/)
|
||||
@@ -238,7 +239,7 @@ describe("Gemini route", () => {
|
||||
|
||||
it.effect("rejects oversized image input", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -256,7 +257,7 @@ describe("Gemini route", () => {
|
||||
|
||||
it.effect("keeps tools and sends function calling mode NONE", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_choice_none",
|
||||
model,
|
||||
@@ -276,7 +277,7 @@ describe("Gemini route", () => {
|
||||
|
||||
it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_schema_patch",
|
||||
model,
|
||||
@@ -457,7 +458,7 @@ describe("Gemini route", () => {
|
||||
response.events.findIndex((event) => event.type === "tool-call"),
|
||||
)
|
||||
|
||||
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -691,7 +692,7 @@ describe("Gemini route", () => {
|
||||
|
||||
it.effect("rejects unsupported assistant media content", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_media",
|
||||
model,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM } from "../../src"
|
||||
import { GoogleVertex, GoogleVertexChat, GoogleVertexMessages, GoogleVertexResponses } from "../../src/providers"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import { it } from "../lib/effect"
|
||||
import { dynamicResponse } from "../lib/http"
|
||||
import { deltaChunk, finishChunk } from "../lib/openai-chunks"
|
||||
@@ -182,7 +183,7 @@ describe("Google Vertex providers", () => {
|
||||
|
||||
it.effect("protects the Vertex Messages API version from body overlays", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: GoogleVertexMessages.configure({
|
||||
accessToken: "vertex-token",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { OpenAIChat } from "../../src/protocols/openai-chat"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||
import * as OpenRouter from "../../src/providers/openrouter"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios"
|
||||
|
||||
@@ -84,9 +85,7 @@ for (const item of cases) {
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model: item.model, messages: [response.message] }),
|
||||
)
|
||||
const replay = yield* compileRequest(LLM.request({ model: item.model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toMatchObject([
|
||||
{ role: "assistant", content: response.text, reasoning: response.reasoning },
|
||||
])
|
||||
|
||||
@@ -18,6 +18,7 @@ import * as OpenAI from "../../src/providers/openai"
|
||||
import * as OpenAIChat from "../../src/protocols/openai-chat"
|
||||
import { ProviderShared } from "../../src/protocols/shared"
|
||||
import { Auth, LLMClient } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import { it } from "../lib/effect"
|
||||
import { dynamicResponse, fixedResponse, truncatedStream } from "../lib/http"
|
||||
import { deltaChunk, usageChunk } from "../lib/openai-chunks"
|
||||
@@ -42,11 +43,7 @@ const request = LLM.request({
|
||||
describe("OpenAI Chat route", () => {
|
||||
it.effect("prepares OpenAI Chat payload", () =>
|
||||
Effect.gen(function* () {
|
||||
// Pass the OpenAIChat payload type so `prepared.body` is statically
|
||||
// typed to the route's native shape — the assertions below read field
|
||||
// names without `unknown` casts.
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(request)
|
||||
const _typed: { readonly model: string; readonly stream: true } = prepared.body
|
||||
const prepared = yield* compileRequest(request)
|
||||
|
||||
expect(prepared.body).toEqual({
|
||||
model: "gpt-4o-mini",
|
||||
@@ -64,7 +61,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -87,7 +84,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -105,7 +102,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("writes reasoning to a configured custom field on every assistant message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Model.update(model, { compatibility: { reasoningField: "vendor_reasoning" } }),
|
||||
messages: [
|
||||
@@ -131,7 +128,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("rejects reasoning fields that conflict with assistant message fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Model.update(model, { compatibility: { reasoningField: "content" } }),
|
||||
messages: [Message.assistant([{ type: "reasoning", text: "thinking" }])],
|
||||
@@ -144,7 +141,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("maps OpenAI provider options to Chat options", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4o-mini"),
|
||||
prompt: "think",
|
||||
@@ -159,7 +156,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("passes through custom OpenAI-compatible reasoning effort strings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "think",
|
||||
@@ -253,7 +250,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("prepares assistant tool-call and tool-result messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_result",
|
||||
model,
|
||||
@@ -291,7 +288,7 @@ describe("OpenAI Chat route", () => {
|
||||
it.effect("preserves structured tool errors for the model", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = { error: { type: "unknown", message: "Tool execution interrupted" } }
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -311,7 +308,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("continues image tool results as vision input without base64 text", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -355,7 +352,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("orders parallel tool responses before one aggregated vision message", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -405,7 +402,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("aggregates consecutive tool images with a following system update", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -446,7 +443,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("appends system updates without replacing multipart user content", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -474,7 +471,7 @@ describe("OpenAI Chat route", () => {
|
||||
] as const)
|
||||
it.effect(`rejects ${name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.message).toMatch(/does not support|does not match|valid base64/)
|
||||
@@ -483,7 +480,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("rejects oversized image input", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -501,7 +498,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("prepares raw and data URL image media as vision input", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_media",
|
||||
model,
|
||||
@@ -528,7 +525,7 @@ describe("OpenAI Chat route", () => {
|
||||
|
||||
it.effect("lowers reasoning-only assistant history", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_reasoning",
|
||||
model,
|
||||
@@ -619,9 +616,7 @@ describe("OpenAI Chat route", () => {
|
||||
openai: { reasoningField: field },
|
||||
})
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }])
|
||||
}
|
||||
}),
|
||||
@@ -647,9 +642,7 @@ describe("OpenAI Chat route", () => {
|
||||
openai: { reasoningField: "vendor_reasoning" },
|
||||
})
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model: custom, messages: [response.message] }),
|
||||
)
|
||||
const replay = yield* compileRequest(LLM.request({ model: custom, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", vendor_reasoning: "thinking" }])
|
||||
}),
|
||||
)
|
||||
@@ -692,9 +685,7 @@ describe("OpenAI Chat route", () => {
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
@@ -737,9 +728,7 @@ describe("OpenAI Chat route", () => {
|
||||
openai: { reasoningDetails: details },
|
||||
})
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }])
|
||||
}),
|
||||
)
|
||||
@@ -764,9 +753,7 @@ describe("OpenAI Chat route", () => {
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
|
||||
])
|
||||
@@ -839,9 +826,7 @@ describe("OpenAI Chat route", () => {
|
||||
openai: { reasoningDetails: [] },
|
||||
})
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }])
|
||||
}),
|
||||
)
|
||||
@@ -889,9 +874,7 @@ describe("OpenAI Chat route", () => {
|
||||
response.events.findIndex(LLMEvent.is.textStart),
|
||||
)
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged },
|
||||
])
|
||||
@@ -918,9 +901,7 @@ describe("OpenAI Chat route", () => {
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
|
||||
}),
|
||||
)
|
||||
@@ -950,7 +931,7 @@ describe("OpenAI Chat route", () => {
|
||||
Effect.gen(function* () {
|
||||
const first = { type: "reasoning.text", text: "first", signature: "signed-0", index: 0 }
|
||||
const second = { type: "reasoning.text", text: "second", signature: "signed-1", index: 1 }
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const replay = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -979,7 +960,7 @@ describe("OpenAI Chat route", () => {
|
||||
it.effect("retains scalar replay for mixed structured reasoning parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 }
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const replay = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -1004,7 +985,7 @@ describe("OpenAI Chat route", () => {
|
||||
it.effect("replays native scalar reasoning alongside native details", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }]
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const replay = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Effect, Schema } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMRequest, Message, ToolCallPart, ToolChoice, ToolDefinition } from "../../src"
|
||||
import { Auth, LLMClient } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||
import * as OpenAICompatibleChat from "../../src/protocols/openai-compatible-chat"
|
||||
import { it } from "../lib/effect"
|
||||
@@ -52,7 +53,7 @@ const providerFamilies = [
|
||||
describe("OpenAI-compatible Chat route", () => {
|
||||
it.effect("prepares generic Chat target", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
toolChoice: ToolChoice.make({ type: "required" }),
|
||||
@@ -127,7 +128,7 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
|
||||
it.effect("matches AI SDK compatible basic request body fixture", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(request)
|
||||
const prepared = yield* compileRequest(request)
|
||||
|
||||
expect(prepared.body).toEqual({
|
||||
model: "deepseek-chat",
|
||||
@@ -145,7 +146,7 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
|
||||
it.effect("matches AI SDK compatible tool request body fixture", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_parity",
|
||||
model,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { OpenResponses } from "../../src/protocols/open-responses"
|
||||
import { OpenAICompatibleResponses } from "../../src/protocols/openai-compatible-responses"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import { it } from "../lib/effect"
|
||||
import { fixedResponse } from "../lib/http"
|
||||
import { sseEvents } from "../lib/sse"
|
||||
@@ -23,7 +24,7 @@ describe("Open Responses-compatible route", () => {
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
system: "You are concise.",
|
||||
@@ -61,7 +62,7 @@ describe("Open Responses-compatible route", () => {
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "Draw.", tools: [OpenAI.imageGeneration()] }),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
@@ -76,7 +77,7 @@ describe("Open Responses-compatible route", () => {
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -102,7 +103,7 @@ describe("Open Responses-compatible route", () => {
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
|
||||
}).model("example-model")
|
||||
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Think." }))
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." }))
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
reasoning: { effort: "low" },
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Usage,
|
||||
} from "../../src"
|
||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
import * as XAI from "../../src/providers/xai"
|
||||
@@ -56,7 +57,7 @@ const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAITool
|
||||
describe("OpenAI Responses route", () => {
|
||||
it.effect("prepares OpenAI Responses target", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(request)
|
||||
const prepared = yield* compileRequest(request)
|
||||
|
||||
expect(prepared.body).toEqual({
|
||||
model: "gpt-4.1-mini",
|
||||
@@ -74,7 +75,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("lowers the hosted OpenAI image generation tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Show me a rooftop garden.",
|
||||
@@ -92,7 +93,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("rejects invalid hosted image generation options locally", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Show me a rooftop garden.",
|
||||
@@ -109,7 +110,7 @@ describe("OpenAI Responses route", () => {
|
||||
Effect.gen(function* () {
|
||||
const input = LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "priority" } } })
|
||||
expect(input.providerOptions).toEqual({ openai: { serviceTier: "priority" } })
|
||||
const prepared = yield* LLMClient.prepare(input)
|
||||
const prepared = yield* compileRequest(input)
|
||||
|
||||
expect(prepared.body).toMatchObject({ service_tier: "priority" })
|
||||
expect(prepared.body).not.toHaveProperty("serviceTier")
|
||||
@@ -118,7 +119,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("passes through custom OpenAI reasoning effort strings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, { providerOptions: { openai: { reasoningEffort: "experimental" } } }),
|
||||
)
|
||||
|
||||
@@ -128,7 +129,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("omits unsupported semantic service tiers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, { providerOptions: { openai: { serviceTier: "unsupported" } } }),
|
||||
)
|
||||
|
||||
@@ -138,7 +139,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("flattens top-level object unions in function schemas", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
tools: [
|
||||
ToolDefinition.make({
|
||||
@@ -191,7 +192,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -217,7 +218,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("prepares OpenAI Responses WebSocket target", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
model: OpenAIResponses.webSocketRoute
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
@@ -395,7 +396,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("prepares function call and function output input items", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_result",
|
||||
model,
|
||||
@@ -432,7 +433,7 @@ describe("OpenAI Responses route", () => {
|
||||
content: [],
|
||||
structured: {},
|
||||
}
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -453,7 +454,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("keeps primitive tool errors as plain text", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -469,7 +470,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("keeps non-JSON tool errors as plain text", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -487,7 +488,7 @@ describe("OpenAI Responses route", () => {
|
||||
// image data is not JSON-stringified into `function_call_output.output`.
|
||||
it.effect("lowers image tool-result content as structured input_image items", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_result_image",
|
||||
model,
|
||||
@@ -516,7 +517,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("lowers single-image tool-result content as structured input_image array", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_result_image_only",
|
||||
model,
|
||||
@@ -540,7 +541,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("lowers PDF tool-result content as structured input_file array", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_result_pdf",
|
||||
model,
|
||||
@@ -575,7 +576,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("uses xAI inline file encoding for PDF tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: xaiModel,
|
||||
messages: [
|
||||
@@ -610,7 +611,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_result_unsupported_media",
|
||||
model,
|
||||
@@ -633,7 +634,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("prepares the composed native continuation request", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
continuationRequest({
|
||||
id: "req_native_continuation_openai",
|
||||
model,
|
||||
@@ -675,7 +676,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("maps OpenAI provider options to Responses options", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
|
||||
prompt: "think",
|
||||
@@ -700,7 +701,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("accepts the full ResponseIncludable union", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "hi",
|
||||
@@ -722,7 +723,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("filters unknown includable values out of the include array", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "hi",
|
||||
@@ -739,7 +740,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("treats an explicit empty include as no include at all", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }),
|
||||
)
|
||||
|
||||
@@ -749,7 +750,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("treats an all-invalid include as no include at all", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
|
||||
)
|
||||
|
||||
@@ -759,7 +760,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("omits include when no include is set", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }),
|
||||
)
|
||||
|
||||
@@ -773,7 +774,7 @@ describe("OpenAI Responses route", () => {
|
||||
// reasoningSummary: "auto" by default. Without `include`, a follow-up
|
||||
// turn cannot replay reasoning state, so the facade also opts into
|
||||
// `reasoning.encrypted_content` automatically.
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
|
||||
prompt: "hi",
|
||||
@@ -788,7 +789,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("lets callers opt out of the GPT-5 default include", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
|
||||
prompt: "hi",
|
||||
@@ -802,7 +803,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("request OpenAI provider options override route defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({
|
||||
baseURL: "https://api.openai.test/v1/",
|
||||
@@ -934,9 +935,7 @@ describe("OpenAI Responses route", () => {
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
const prepared = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
@@ -1270,7 +1269,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("preserves assistant content order around reasoning items", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_reasoning_order",
|
||||
model,
|
||||
@@ -1308,7 +1307,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("references stored reasoning items by id", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -1330,7 +1329,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("references stored provider-executed hosted tool results by id", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -1367,7 +1366,7 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("continues stateless hosted image generation with the generated image", () =>
|
||||
Effect.gen(function* () {
|
||||
const imageTool = OpenAI.imageGeneration({ action: "edit" })
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
@@ -1408,7 +1407,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_multi_summary_continuation",
|
||||
model,
|
||||
@@ -1445,7 +1444,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_reasoning_without_encrypted_state",
|
||||
model,
|
||||
@@ -1762,7 +1761,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("lowers user image and PDF content", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_media",
|
||||
model,
|
||||
@@ -1793,7 +1792,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("uses xAI inline file encoding for user PDFs", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: xaiModel,
|
||||
messages: [
|
||||
@@ -1825,7 +1824,7 @@ describe("OpenAI Responses route", () => {
|
||||
|
||||
it.effect("rejects unsupported user media content", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_media",
|
||||
model,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { compileRequest } from "../../src/route/client"
|
||||
import * as OpenRouter from "../../src/providers/openrouter"
|
||||
import { it } from "../lib/effect"
|
||||
import { fixedResponse } from "../lib/http"
|
||||
@@ -19,7 +20,7 @@ describe("OpenRouter", () => {
|
||||
})
|
||||
expect(model.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
|
||||
|
||||
const prepared = yield* LLMClient.prepare(LLM.request({ model, prompt: "Say hello." }))
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello." }))
|
||||
|
||||
expect(prepared.route).toBe("openrouter")
|
||||
expect(prepared.body).toMatchObject({
|
||||
@@ -32,7 +33,7 @@ describe("OpenRouter", () => {
|
||||
|
||||
it.effect("applies OpenRouter payload options from the model helper", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({
|
||||
apiKey: "test-key",
|
||||
@@ -100,7 +101,7 @@ describe("OpenRouter", () => {
|
||||
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
|
||||
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
|
||||
]
|
||||
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
messages: [
|
||||
@@ -133,7 +134,7 @@ describe("OpenRouter", () => {
|
||||
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
|
||||
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
|
||||
]
|
||||
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
messages: [
|
||||
@@ -158,7 +159,7 @@ describe("OpenRouter", () => {
|
||||
{ type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" },
|
||||
{ type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" },
|
||||
]
|
||||
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
messages: [
|
||||
@@ -179,7 +180,7 @@ describe("OpenRouter", () => {
|
||||
|
||||
it.effect("omits scalar reasoning without continuation details", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
|
||||
|
||||
@@ -3,7 +3,8 @@ import { Effect } from "effect"
|
||||
import { LLM } from "../src"
|
||||
import { OpenAIChat } from "../src/protocols"
|
||||
import { ToolSchemaProjection } from "../src/protocols/utils/tool-schema"
|
||||
import { Auth, LLMClient } from "../src/route"
|
||||
import { Auth } from "../src/route"
|
||||
import { compileRequest } from "../src/route/client"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
describe("tool schema projections", () => {
|
||||
@@ -79,7 +80,7 @@ describe("tool schema projections", () => {
|
||||
const model = OpenAIChat.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "kimi-k2", compatibility: { toolSchema: "moonshot" } })
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Use the tool.",
|
||||
|
||||
@@ -69,7 +69,7 @@ export const DialogFork: Component = () => {
|
||||
const dir = base64Encode(sdk().directory)
|
||||
|
||||
sdk()
|
||||
.api.session.fork({ sessionID, messageID: item.id })
|
||||
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
|
||||
.then((forked) => {
|
||||
dialog.close()
|
||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||
|
||||
@@ -26,7 +26,6 @@ function setup(
|
||||
return new Response(undefined, { status: 204 })
|
||||
if (request.method === "POST" && request.url.endsWith("/prompt")) {
|
||||
return Response.json({
|
||||
admittedSeq: 1,
|
||||
id: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
timeCreated: 1,
|
||||
|
||||
@@ -229,7 +229,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
],
|
||||
})
|
||||
return {
|
||||
admittedSeq: 0,
|
||||
id: value.id ?? "",
|
||||
sessionID: value.sessionID,
|
||||
timeCreated: Date.now(),
|
||||
@@ -255,7 +254,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
})),
|
||||
})
|
||||
return {
|
||||
admittedSeq: 0,
|
||||
id: value.id ?? "",
|
||||
sessionID: value.sessionID,
|
||||
timeCreated: Date.now(),
|
||||
@@ -280,7 +278,6 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
|
||||
modelID: value.model.modelID,
|
||||
})
|
||||
return {
|
||||
admittedSeq: 0,
|
||||
id: value.id ?? "",
|
||||
sessionID: value.sessionID,
|
||||
timeCreated: Date.now(),
|
||||
|
||||
@@ -244,7 +244,10 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
return {}
|
||||
},
|
||||
forkSession: async (params) => {
|
||||
const forked = await input.client.session.fork({ sessionID: params.sessionId })
|
||||
const forked = await input.client.session.fork({
|
||||
sessionID: params.sessionId,
|
||||
boundary: { type: "through" },
|
||||
})
|
||||
const state = await attach(forked, forked.location.directory, params.mcpServers ?? [])
|
||||
await replay(state)
|
||||
return { sessionId: state.id, configOptions: configOptions(state) }
|
||||
|
||||
@@ -105,7 +105,7 @@ async function selectSession(input: {
|
||||
return {
|
||||
session: input.fork
|
||||
? await input.client.session
|
||||
.fork({ sessionID: explicit.id }, ...requestOptions(input.signal))
|
||||
.fork({ sessionID: explicit.id, boundary: { type: "through" } }, ...requestOptions(input.signal))
|
||||
.catch((error) => {
|
||||
throw new SessionTargetMutationError(error)
|
||||
})
|
||||
@@ -118,9 +118,11 @@ async function selectSession(input: {
|
||||
if (!selected) return { session: undefined, location }
|
||||
return {
|
||||
session: input.fork
|
||||
? await input.client.session.fork({ sessionID: selected.id }, ...requestOptions(input.signal)).catch((error) => {
|
||||
throw new SessionTargetMutationError(error)
|
||||
})
|
||||
? await input.client.session
|
||||
.fork({ sessionID: selected.id, boundary: { type: "through" } }, ...requestOptions(input.signal))
|
||||
.catch((error) => {
|
||||
throw new SessionTargetMutationError(error)
|
||||
})
|
||||
: selected,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ describe("acp service lifecycle", () => {
|
||||
method: "POST",
|
||||
path: "/api/session/ses_loaded/fork",
|
||||
query: {},
|
||||
body: {},
|
||||
body: { boundary: { type: "through" } },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ async function run(input: {
|
||||
values.push(...input.turn(messageID))
|
||||
wake?.()
|
||||
wake = undefined
|
||||
return ok({ admittedSeq: 1, id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never
|
||||
return ok({ id: messageID, sessionID: "ses_1", timeCreated: 1 }) as never
|
||||
})
|
||||
await runNonInteractivePrompt({
|
||||
client: sdk,
|
||||
|
||||
@@ -136,7 +136,7 @@ export type Endpoint5_4Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_4Output = void
|
||||
export type SessionRemoveOperation<E = never> = (input: Endpoint5_4Input) => Effect.Effect<Endpoint5_4Output, E>
|
||||
|
||||
export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly messageID?: SessionMessage.ID | undefined }
|
||||
export type Endpoint5_5Input = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
|
||||
export type Endpoint5_5Output = Session.Info
|
||||
export type SessionForkOperation<E = never> = (input: Endpoint5_5Input) => Effect.Effect<Endpoint5_5Output, E>
|
||||
|
||||
@@ -342,8 +342,10 @@ export type Endpoint5_26Output =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly parentID: Session.ID
|
||||
readonly parentSeq: number
|
||||
readonly from?: SessionMessage.ID | undefined
|
||||
readonly boundary: Session.ForkBoundary
|
||||
readonly instructions?:
|
||||
| { readonly [x: string & Brand.Brand<"Instruction.Key">]: string & Brand.Brand<"Instruction.Hash"> }
|
||||
| undefined
|
||||
}
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -329,7 +329,7 @@ const Endpoint5_4 = (raw: RawClient["server.session"]) => (input: Endpoint5_4Inp
|
||||
|
||||
const Endpoint5_5 = (raw: RawClient["server.session"]) => (input: Endpoint5_5Input) =>
|
||||
preserveEffect<Endpoint5_5Output>()(
|
||||
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"] } }).pipe(
|
||||
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
|
||||
@@ -510,7 +510,7 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/fork`,
|
||||
body: { messageID: input["messageID"] },
|
||||
body: { boundary: input["boundary"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
|
||||
@@ -14,6 +14,8 @@ export type PermissionEffect = "allow" | "deny" | "ask"
|
||||
|
||||
export type PluginInfo = { id: string }
|
||||
|
||||
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
|
||||
|
||||
export type MoneyUSD = number
|
||||
|
||||
export type TokenUsageInfo = {
|
||||
@@ -43,13 +45,7 @@ export type PromptMention = { start: number; end: number; text: string }
|
||||
|
||||
export type SessionPendingSyntheticData = { text: string; description?: string; metadata?: { [x: string]: JsonValue } }
|
||||
|
||||
export type SessionPendingCompaction = {
|
||||
admittedSeq: number
|
||||
id: string
|
||||
sessionID: string
|
||||
timeCreated: number
|
||||
type: "compaction"
|
||||
}
|
||||
export type SessionPendingCompaction = { id: string; sessionID: string; timeCreated: number; type: "compaction" }
|
||||
|
||||
export type SessionMessageAgentSelected = {
|
||||
id: string
|
||||
@@ -628,7 +624,7 @@ export type SessionForked = {
|
||||
type: "session.forked"
|
||||
durable: { aggregateID: string; seq: number; version: 2 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; parentID: string; parentSeq: number; from?: string }
|
||||
data: { sessionID: string; parentID: string; boundary: SessionForkBoundary; instructions?: { [x: string]: string } }
|
||||
}
|
||||
|
||||
export type SessionInputPromoted = {
|
||||
@@ -1210,7 +1206,6 @@ export type PromptFileAttachment = {
|
||||
export type PromptAgentAttachment = { name: string; mention?: PromptMention }
|
||||
|
||||
export type SessionPendingSynthetic = {
|
||||
admittedSeq: number
|
||||
id: string
|
||||
sessionID: string
|
||||
timeCreated: number
|
||||
@@ -1755,7 +1750,7 @@ export type PermissionRuleset = Array<PermissionRule>
|
||||
export type SessionInfo = {
|
||||
id: string
|
||||
parentID?: string
|
||||
fork?: { sessionID: string; messageID?: string }
|
||||
fork?: { sessionID: string; boundary: SessionForkBoundary }
|
||||
projectID: string
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
@@ -1966,7 +1961,6 @@ export type AgentInfo = {
|
||||
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
|
||||
|
||||
export type SessionPendingUser = {
|
||||
admittedSeq: number
|
||||
id: string
|
||||
sessionID: string
|
||||
timeCreated: number
|
||||
@@ -2702,7 +2696,9 @@ export type SessionRemoveOutput = void
|
||||
|
||||
export type SessionForkInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly messageID?: { readonly messageID?: string | undefined }["messageID"]
|
||||
readonly boundary: {
|
||||
readonly boundary: { readonly type: "before"; readonly messageID: string } | { readonly type: "through" }
|
||||
}["boundary"]
|
||||
}
|
||||
|
||||
export type SessionForkOutput = { data: SessionInfo }["data"]
|
||||
|
||||
@@ -268,7 +268,6 @@ const session = {
|
||||
|
||||
const admission = {
|
||||
data: {
|
||||
admittedSeq: 0,
|
||||
id: "msg_test",
|
||||
sessionID: "ses_test",
|
||||
type: "user",
|
||||
@@ -281,7 +280,6 @@ const admission = {
|
||||
const compactionAdmission = {
|
||||
data: {
|
||||
type: "compaction",
|
||||
admittedSeq: 1,
|
||||
id: "msg_compaction",
|
||||
sessionID: "ses_test",
|
||||
timeCreated: 1_717_171_717_000,
|
||||
|
||||
@@ -304,7 +304,6 @@ test("session.pending.list uses the public HTTP contract", async () => {
|
||||
const requests: Array<{ method: string; url: string }> = []
|
||||
const pending = [
|
||||
{
|
||||
admittedSeq: 3,
|
||||
id: "msg_pending",
|
||||
sessionID: "ses_test",
|
||||
timeCreated: 1_717_171_717_000,
|
||||
@@ -547,7 +546,6 @@ const session = {
|
||||
|
||||
const admission = {
|
||||
data: {
|
||||
admittedSeq: 0,
|
||||
id: "msg_test",
|
||||
sessionID: "ses_test",
|
||||
type: "user",
|
||||
@@ -559,7 +557,6 @@ const admission = {
|
||||
|
||||
const syntheticAdmission = {
|
||||
data: {
|
||||
admittedSeq: 1,
|
||||
id: "msg_synthetic",
|
||||
sessionID: "ses_test",
|
||||
type: "synthetic",
|
||||
@@ -572,7 +569,6 @@ const syntheticAdmission = {
|
||||
const compactionAdmission = {
|
||||
data: {
|
||||
type: "compaction",
|
||||
admittedSeq: 1,
|
||||
id: "msg_compaction",
|
||||
sessionID: "ses_test",
|
||||
timeCreated: 1_717_171_717_000,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "a4ba73b4-21bc-41ab-a415-94e2ca38d798",
|
||||
"id": "db37a97f-9b5e-4c87-be8b-4feace35136c",
|
||||
"prevIds": [
|
||||
"5f0a1db8-d4bf-42c3-becb-96b46fe66bed"
|
||||
"a4ba73b4-21bc-41ab-a415-94e2ca38d798"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
@@ -1266,17 +1266,7 @@
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "fork_message_id",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "fork_seq",
|
||||
"name": "fork_boundary",
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ export const Entry = Schema.Struct({
|
||||
path: Schema.String,
|
||||
description: Schema.String,
|
||||
signature: Schema.String,
|
||||
pinned: Schema.optionalKey(Schema.Boolean),
|
||||
})
|
||||
export type Entry = typeof Entry.Type
|
||||
|
||||
@@ -56,26 +57,39 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
|
||||
if (left.path > right.path) return 1
|
||||
return 0
|
||||
})
|
||||
const ranked = rankListings(listings)
|
||||
const pinned = new Set(
|
||||
namespaceEntries
|
||||
.filter((entry) => entry.pinned)
|
||||
.map((entry) => listings.find((listing) => listing.path === entry.path))
|
||||
.filter((listing) => listing !== undefined),
|
||||
)
|
||||
return {
|
||||
name,
|
||||
listings,
|
||||
selectionOrder: rankListings(listings),
|
||||
selectedListings: new Set<typeof Listing.Type>(),
|
||||
selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
|
||||
selectedListings: pinned,
|
||||
selectionIndex: 0,
|
||||
}
|
||||
})
|
||||
|
||||
const active = new Set(namespaces)
|
||||
let remaining = budget
|
||||
let remaining =
|
||||
budget -
|
||||
namespaces
|
||||
.flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing)))
|
||||
.reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0)
|
||||
while (active.size > 0) {
|
||||
for (const namespace of active) {
|
||||
const candidate = namespace.selectionOrder[namespace.selectedListings.size]
|
||||
const candidate = namespace.selectionOrder[namespace.selectionIndex]
|
||||
if (!candidate || candidate.cost > remaining) {
|
||||
active.delete(namespace)
|
||||
continue
|
||||
}
|
||||
namespace.selectedListings.add(candidate.listing)
|
||||
namespace.selectionIndex += 1
|
||||
remaining -= candidate.cost
|
||||
if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace)
|
||||
if (namespace.selectionIndex === namespace.selectionOrder.length) active.delete(namespace)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import { Instructions } from "../instructions/index"
|
||||
import { CodeModeCatalog } from "./catalog"
|
||||
|
||||
// prettier-ignore
|
||||
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.${hasMoreTools ? `
|
||||
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
|
||||
|
||||
${hasMoreTools ? "The Code Mode catalog and `search` results are" : "This catalog is"} the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.${hasMoreTools ? `
|
||||
|
||||
## Search
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ type CollectedFiles = {
|
||||
|
||||
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
|
||||
const description = [
|
||||
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
|
||||
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
|
||||
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
@@ -138,7 +138,14 @@ export const create = (
|
||||
}
|
||||
|
||||
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
|
||||
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
|
||||
const pinned = new Set(
|
||||
Array.from(registrations.values())
|
||||
.filter((registration) => registration.options?.pinned === true)
|
||||
.map(qualifiedName),
|
||||
)
|
||||
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
|
||||
.catalog()
|
||||
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
|
||||
}
|
||||
|
||||
function runtime(
|
||||
@@ -149,11 +156,7 @@ function runtime(
|
||||
const tools: Record<string, Tool.Tool<never>> = {}
|
||||
for (const [name, registration] of registrations) {
|
||||
const child = definition(registration)
|
||||
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
const path =
|
||||
registration.options?.namespace === undefined
|
||||
? normalized
|
||||
: `${registration.options.namespace}.${normalized}`
|
||||
const path = qualifiedName(registration)
|
||||
tools[path] = Tool.make({
|
||||
description: child.description,
|
||||
input: child.inputSchema,
|
||||
@@ -164,6 +167,12 @@ function runtime(
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
}
|
||||
|
||||
function qualifiedName(registration: Info) {
|
||||
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
if (registration.options?.namespace === undefined) return normalized
|
||||
return `${registration.options.namespace}.${normalized}`
|
||||
}
|
||||
|
||||
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
|
||||
function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
|
||||
if (input === null || input === undefined) return
|
||||
|
||||
@@ -393,7 +393,8 @@ export const layer = (options?: Options) => Layer.effect(
|
||||
const key = JSON.stringify(target)
|
||||
if (watched.has(key)) continue
|
||||
watched.add(key)
|
||||
yield* watcher.subscribe(target).pipe(
|
||||
const stream = yield* watcher.subscribe(target)
|
||||
yield* stream.pipe(
|
||||
Stream.runForEach((update) => PubSub.publish(updates, update)),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
|
||||
+1
@@ -57,5 +57,6 @@ export const migrations = (
|
||||
import("./migration/20260716020354_kv"),
|
||||
import("./migration/20260722011141_delete_tool_progress_events"),
|
||||
import("./migration/20260722170000_canonical_tool_results"),
|
||||
import("./migration/20260729022634_session_fork_boundary"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260729022634_session_fork_boundary",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session\` ADD \`fork_boundary\` text;`)
|
||||
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_message_id\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`session\` DROP COLUMN \`fork_seq\`;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -213,8 +213,7 @@ export default {
|
||||
\`workspace_id\` text,
|
||||
\`parent_id\` text,
|
||||
\`fork_session_id\` text,
|
||||
\`fork_message_id\` text,
|
||||
\`fork_seq\` integer,
|
||||
\`fork_boundary\` text,
|
||||
\`slug\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`path\` text,
|
||||
|
||||
@@ -47,13 +47,12 @@ const layer = Layer.effect(
|
||||
const home = path.resolve(location.directory) === path.resolve(os.homedir())
|
||||
|
||||
if (!home && location.vcs) {
|
||||
yield* watcher
|
||||
.subscribe({
|
||||
path: location.directory,
|
||||
type: "directory",
|
||||
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
|
||||
})
|
||||
.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
const updates = yield* watcher.subscribe({
|
||||
path: location.directory,
|
||||
type: "directory",
|
||||
ignore: [...Ignore.PATTERNS, ...config, ...protecteds(location.directory)],
|
||||
})
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
}
|
||||
if (home) {
|
||||
yield* Effect.logInfo("location watcher skipped home directory", { directory: location.directory })
|
||||
@@ -68,9 +67,8 @@ const layer = Layer.effect(
|
||||
const ignore = (yield* fs.readDirectoryEntries(vcs).pipe(Effect.catch(() => Effect.succeed([])))).flatMap(
|
||||
(entry) => (entry.name === "HEAD" ? [] : [entry.name]),
|
||||
)
|
||||
yield* watcher
|
||||
.subscribe({ path: vcs, type: "directory", ignore })
|
||||
.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
const updates = yield* watcher.subscribe({ path: vcs, type: "directory", ignore })
|
||||
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
|
||||
}
|
||||
}
|
||||
}).pipe(
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface NativeInterface {
|
||||
export class Native extends Context.Service<Native, NativeInterface>()("@opencode/Watcher/Native") {}
|
||||
|
||||
export interface Interface {
|
||||
readonly subscribe: (input: WatchInput) => Stream.Stream<Update>
|
||||
readonly subscribe: (input: WatchInput) => Effect.Effect<Stream.Stream<Update>>
|
||||
}
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
@@ -83,7 +83,7 @@ export const layer = (options?: Options) =>
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
if (options?.enabled === false) {
|
||||
return Service.of({ subscribe: () => Stream.empty })
|
||||
return Service.of({ subscribe: () => Effect.succeed(Stream.empty) })
|
||||
}
|
||||
const native = yield* Native
|
||||
|
||||
@@ -131,11 +131,19 @@ export const layer = (options?: Options) =>
|
||||
const subscribe = (input: WatchInput) => {
|
||||
const target = path.resolve(input.path)
|
||||
const ignore = [...new Set(input.type === "directory" ? (input.ignore ?? []) : [])].toSorted()
|
||||
return Stream.unwrap(
|
||||
RcMap.get(watchers, { type: input.type, target, ignore }).pipe(
|
||||
Effect.map((pubsub) => Stream.fromPubSub(pubsub)),
|
||||
),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.logInfo("watcher subscribe", {
|
||||
path: target,
|
||||
type: input.type,
|
||||
ignores: ignore.length,
|
||||
})
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const pubsub = yield* RcMap.get(watchers, { type: input.type, target, ignore })
|
||||
return Stream.fromPubSub(pubsub)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return Service.of({ subscribe })
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as PluginHooks from "./hooks"
|
||||
|
||||
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
||||
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -10,6 +11,7 @@ import { State } from "../state"
|
||||
export interface Domains {
|
||||
readonly aisdk: AISDKHooks
|
||||
readonly session: SessionHooks
|
||||
readonly shell: ShellHooks
|
||||
readonly tool: ToolHooks
|
||||
}
|
||||
|
||||
|
||||
@@ -296,6 +296,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
})
|
||||
}),
|
||||
},
|
||||
shell: {
|
||||
hook: (name, callback) => hooks.register("shell", name, callback),
|
||||
},
|
||||
tool: {
|
||||
transform: (callback) =>
|
||||
tools
|
||||
|
||||
@@ -326,6 +326,10 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })),
|
||||
},
|
||||
shell: {
|
||||
hook: (name, callback) =>
|
||||
register(host.shell.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
}
|
||||
|
||||
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
|
||||
|
||||
@@ -253,7 +253,8 @@ const layer = Layer.effect(
|
||||
// inside), so don't watch what can't trigger anything.
|
||||
if (yield* fs.isDir(operation.target)) continue
|
||||
watched.add(operation.target)
|
||||
yield* watcher.subscribe({ path: operation.target, type: "file" }).pipe(
|
||||
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logError("configured plugin watch failed", { target: operation.target, cause }),
|
||||
|
||||
@@ -28,11 +28,12 @@ import { fromRow } from "./session/info"
|
||||
import { SessionRunner } from "./session/runner/index"
|
||||
import { SessionStore } from "./session/store"
|
||||
import { SessionExecution } from "./session/execution"
|
||||
import { MessageDecodeError, NotFoundError } from "./session/error"
|
||||
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LocationServiceMap } from "./location-service-map"
|
||||
import { SessionEvent } from "./session/event"
|
||||
import { SessionPending } from "./session/pending"
|
||||
import { InstructionState } from "./session/instruction-state"
|
||||
import { SessionGenerate } from "./session/generate"
|
||||
import { Snapshot } from "./snapshot"
|
||||
import { SessionRevert } from "./session/revert"
|
||||
@@ -106,7 +107,7 @@ type CompactInput = {
|
||||
|
||||
type ForkInput = {
|
||||
sessionID: SessionSchema.ID
|
||||
messageID?: SessionMessage.ID
|
||||
boundary: Session.ForkRequestBoundary
|
||||
}
|
||||
|
||||
export class OperationUnavailableError extends Schema.TaggedErrorClass<OperationUnavailableError>()(
|
||||
@@ -181,7 +182,9 @@ export interface Interface {
|
||||
readonly data: SessionSchema.Info[]
|
||||
}>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||
readonly fork: (input: ForkInput) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError>
|
||||
readonly fork: (
|
||||
input: ForkInput,
|
||||
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
|
||||
readonly get: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||
readonly remove: (sessionID: SessionSchema.ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly messages: (input: {
|
||||
@@ -395,25 +398,33 @@ const layer = Layer.effect(
|
||||
}),
|
||||
fork: Effect.fn("Session.fork")(function* (input) {
|
||||
const parent = yield* result.get(input.sessionID)
|
||||
const boundary = input.messageID
|
||||
? yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, input.sessionID), eq(SessionMessageTable.id, input.messageID)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (input.messageID && !boundary)
|
||||
return yield* new MessageNotFoundError({ sessionID: input.sessionID, messageID: input.messageID })
|
||||
const boundary = yield* db
|
||||
.select({ id: SessionMessageTable.id, seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, input.sessionID),
|
||||
input.boundary.type === "before" ? eq(SessionMessageTable.id, input.boundary.messageID) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
.limit(1)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!boundary && input.boundary.type === "before")
|
||||
return yield* new MessageNotFoundError({
|
||||
sessionID: input.sessionID,
|
||||
messageID: input.boundary.messageID,
|
||||
})
|
||||
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
|
||||
const sessionID = SessionSchema.ID.create()
|
||||
const parentSeq = boundary ? boundary.seq - 1 : yield* Bus.latestSequence(db, parent.id)
|
||||
const instructionThrough =
|
||||
input.boundary.type === "before" ? boundary.seq - 1 : yield* Bus.latestSequence(db, parent.id)
|
||||
yield* bus.publish(SessionEvent.Forked, {
|
||||
sessionID,
|
||||
parentID: parent.id,
|
||||
parentSeq,
|
||||
from: input.messageID,
|
||||
boundary: { ...input.boundary, messageID: boundary.id },
|
||||
instructions: yield* InstructionState.valuesAt(db, parent.id, instructionThrough),
|
||||
})
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
}),
|
||||
|
||||
@@ -10,6 +10,14 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {}
|
||||
|
||||
export class ForkEmptyError extends Schema.TaggedErrorClass<ForkEmptyError>()("Session.ForkEmptyError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Cannot fork empty session: ${this.sessionID}`
|
||||
}
|
||||
}
|
||||
|
||||
export class MessageDecodeError extends Schema.TaggedErrorClass<MessageDecodeError>()("Session.MessageDecodeError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
messageID: SessionMessage.ID,
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AbsolutePath, RelativePath } from "../schema"
|
||||
import { Workspace } from "../workspace"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { SessionTable } from "./sql"
|
||||
import { SessionMessage } from "./message"
|
||||
import { PersistedRevert } from "@opencode-ai/schema/session-revert"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
|
||||
@@ -20,12 +19,13 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
||||
projectID: Project.ID.make(row.project_id),
|
||||
title: row.title,
|
||||
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
|
||||
fork: row.fork_session_id
|
||||
? {
|
||||
sessionID: SessionSchema.ID.make(row.fork_session_id),
|
||||
messageID: row.fork_message_id ? SessionMessage.ID.make(row.fork_message_id) : undefined,
|
||||
}
|
||||
: undefined,
|
||||
fork:
|
||||
row.fork_session_id && row.fork_boundary
|
||||
? {
|
||||
sessionID: SessionSchema.ID.make(row.fork_session_id),
|
||||
boundary: row.fork_boundary,
|
||||
}
|
||||
: undefined,
|
||||
agent: row.agent ? Agent.ID.make(row.agent) : undefined,
|
||||
model: row.model
|
||||
? {
|
||||
|
||||
@@ -10,11 +10,12 @@ import { SessionEvent } from "./event"
|
||||
import { SessionMessage } from "./message"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { SessionSchema } from "./schema"
|
||||
import { InstructionBlobTable, InstructionStateTable, SessionTable } from "./sql"
|
||||
import { InstructionBlobTable, InstructionStateTable } from "./sql"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decodeInstructionsUpdated = Schema.decodeUnknownSync(SessionEvent.InstructionsUpdated.data)
|
||||
const decodeForked = Schema.decodeUnknownSync(SessionEvent.Forked.data)
|
||||
|
||||
export interface Observation extends Instructions.Admission {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
@@ -94,6 +95,26 @@ export const apply = Effect.fn("InstructionState.apply")(function* (
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const initialize = Effect.fn("InstructionState.initialize")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
seq: number,
|
||||
values: Instructions.Values,
|
||||
) {
|
||||
yield* db
|
||||
.insert(InstructionStateTable)
|
||||
.values({
|
||||
session_id: sessionID,
|
||||
epoch_start: seq,
|
||||
through_seq: seq,
|
||||
initial_values: values,
|
||||
current_values: values,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
export const advanceEpoch = Effect.fn("InstructionState.advanceEpoch")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
@@ -255,6 +276,14 @@ const stateFromEvents = Effect.fnUntraced(function* (db: DatabaseService, sessio
|
||||
return folded ? foldedState(sessionID, folded) : undefined
|
||||
})
|
||||
|
||||
export const valuesAt = Effect.fn("InstructionState.valuesAt")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
through: number,
|
||||
) {
|
||||
return fold(yield* instructionEvents(db, sessionID, through))?.current
|
||||
})
|
||||
|
||||
const latestRelevantSequence = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return yield* db
|
||||
.select({ seq: EventTable.seq })
|
||||
@@ -324,15 +353,23 @@ const revertedEventType = Bus.versionedType(
|
||||
SessionEvent.RevertEvent.Committed.type,
|
||||
SessionEvent.RevertEvent.Committed.durable.version,
|
||||
)
|
||||
const relevantEventTypes = [instructionEventType, compactionEventType, movedEventType, revertedEventType]
|
||||
const forkedEventType = Bus.versionedType(SessionEvent.Forked.type, SessionEvent.Forked.durable.version)
|
||||
const relevantEventTypes = [
|
||||
forkedEventType,
|
||||
instructionEventType,
|
||||
compactionEventType,
|
||||
movedEventType,
|
||||
revertedEventType,
|
||||
]
|
||||
|
||||
type InstructionEventRow = typeof EventTable.$inferSelect
|
||||
|
||||
const instructionEvents = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
through?: number,
|
||||
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
|
||||
return yield* eventRows(db, sessionID, relevantEventTypes)
|
||||
return yield* eventRows(db, sessionID, relevantEventTypes, undefined, through)
|
||||
})
|
||||
|
||||
const instructionUpdatesAfter = Effect.fnUntraced(function* (
|
||||
@@ -348,48 +385,22 @@ const eventRows = Effect.fnUntraced(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
types: ReadonlyArray<string>,
|
||||
after?: number,
|
||||
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
|
||||
const segments = (yield* lineage(db, sessionID)).filter(
|
||||
(segment) => after === undefined || segment.through === undefined || segment.through > after,
|
||||
)
|
||||
return (yield* Effect.forEach(segments, (segment) =>
|
||||
db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(
|
||||
and(
|
||||
eq(EventTable.aggregate_id, segment.sessionID),
|
||||
inArray(EventTable.type, types),
|
||||
segment.through === undefined ? undefined : lte(EventTable.seq, segment.through),
|
||||
after === undefined ? undefined : gt(EventTable.seq, after),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie),
|
||||
)).flat()
|
||||
})
|
||||
|
||||
const lineage = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
through?: number,
|
||||
): Effect.fn.Return<ReadonlyArray<{ readonly sessionID: SessionSchema.ID; readonly through?: number }>> {
|
||||
const session = yield* db
|
||||
.select({ parentID: SessionTable.fork_session_id, forkSeq: SessionTable.fork_seq })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
): Effect.fn.Return<ReadonlyArray<InstructionEventRow>> {
|
||||
return yield* db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(
|
||||
and(
|
||||
eq(EventTable.aggregate_id, sessionID),
|
||||
inArray(EventTable.type, types),
|
||||
after === undefined ? undefined : gt(EventTable.seq, after),
|
||||
through === undefined ? undefined : lte(EventTable.seq, through),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const inherited =
|
||||
session?.parentID && session.forkSeq !== null
|
||||
? yield* lineage(
|
||||
db,
|
||||
session.parentID,
|
||||
through === undefined ? session.forkSeq : Math.min(session.forkSeq, through),
|
||||
)
|
||||
: []
|
||||
return [...inherited, { sessionID, ...(through === undefined ? {} : { through }) }]
|
||||
})
|
||||
|
||||
function fold(rows: ReadonlyArray<InstructionEventRow>) {
|
||||
@@ -402,6 +413,12 @@ function fold(rows: ReadonlyArray<InstructionEventRow>) {
|
||||
}
|
||||
| undefined
|
||||
>((state, row) => {
|
||||
if (row.type === forkedEventType) {
|
||||
const instructions = decodeForked(row.data).instructions
|
||||
return instructions
|
||||
? { epochStart: row.seq, throughSeq: row.seq, initial: instructions, current: instructions }
|
||||
: undefined
|
||||
}
|
||||
if (row.type === movedEventType || row.type === revertedEventType) return undefined
|
||||
if (row.type === compactionEventType)
|
||||
return state
|
||||
|
||||
@@ -53,7 +53,6 @@ export class LifecycleConflict extends Schema.TaggedErrorClass<LifecycleConflict
|
||||
|
||||
const fromRow = (row: typeof SessionPendingTable.$inferSelect): Info => {
|
||||
const base = {
|
||||
admittedSeq: row.admitted_seq,
|
||||
id: SessionMessage.ID.make(row.id),
|
||||
sessionID: SessionSchema.ID.make(row.session_id),
|
||||
timeCreated: DateTime.makeUnsafe(row.time_created),
|
||||
@@ -134,7 +133,6 @@ const promotedFromHistory = Effect.fn("SessionPending.promotedFromHistory")(func
|
||||
const decoded = decodeAdmittedEvent(row.data)
|
||||
if (decoded._tag !== "Some" || decoded.value.inputID !== id) continue
|
||||
const base = {
|
||||
admittedSeq: row.seq,
|
||||
id,
|
||||
sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(row.created),
|
||||
@@ -172,10 +170,7 @@ export const admit = Effect.fn("SessionPending.admit")(function* (
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap((event) => {
|
||||
if (event.durable === undefined)
|
||||
return Effect.die(new Error("Session input admission event is missing aggregate sequence"))
|
||||
const base = {
|
||||
admittedSeq: event.durable.seq,
|
||||
id: request.id,
|
||||
sessionID: request.sessionID,
|
||||
timeCreated: event.created,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as SessionProjector from "./projector"
|
||||
|
||||
import { and, asc, desc, eq, gt, gte, inArray, lt, sql } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gt, gte, inArray, lt, lte, sql } from "drizzle-orm"
|
||||
import { DateTime, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { Bus } from "../bus"
|
||||
@@ -174,25 +174,28 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!parent) return yield* Effect.die(new Error(`Fork parent session not found: ${event.data.parentID}`))
|
||||
const boundary = event.data.from
|
||||
? yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(eq(SessionMessageTable.session_id, event.data.parentID), eq(SessionMessageTable.id, event.data.from)),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
if (event.data.from && !boundary)
|
||||
return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.from}`))
|
||||
const boundary = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
||||
eq(SessionMessageTable.id, event.data.boundary.messageID),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!boundary)
|
||||
return yield* Effect.die(new Error(`Fork boundary message not found: ${event.data.boundary.messageID}`))
|
||||
const copied = yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, event.data.parentID),
|
||||
boundary === undefined ? undefined : lt(SessionMessageTable.seq, boundary.seq),
|
||||
event.data.boundary.type === "before"
|
||||
? lt(SessionMessageTable.seq, boundary.seq)
|
||||
: lte(SessionMessageTable.seq, boundary.seq),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
@@ -207,8 +210,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
id: event.data.sessionID,
|
||||
parent_id: null,
|
||||
fork_session_id: event.data.parentID,
|
||||
fork_message_id: event.data.from,
|
||||
fork_seq: event.data.parentSeq,
|
||||
fork_boundary: event.data.boundary,
|
||||
project_id: parent.project_id,
|
||||
workspace_id: parent.workspace_id,
|
||||
slug: Slug.create(),
|
||||
@@ -314,8 +316,9 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
|
||||
|
||||
cursor = rows.at(-1)!.seq
|
||||
}
|
||||
yield* Bus.reserveSequence(db, event.data.sessionID, event.data.parentSeq)
|
||||
yield* InstructionState.rebuild(db, event.data.sessionID)
|
||||
if (copiedSeq !== undefined) yield* Bus.reserveSequence(db, event.data.sessionID, copiedSeq)
|
||||
if (event.data.instructions)
|
||||
yield* InstructionState.initialize(db, event.data.sessionID, event.durable.seq, event.data.instructions)
|
||||
})
|
||||
|
||||
function run(db: DatabaseService, event: MessageEvent) {
|
||||
|
||||
@@ -32,8 +32,7 @@ export const SessionTable = sqliteTable(
|
||||
workspace_id: text().$type<Workspace.ID>(),
|
||||
parent_id: text().$type<SessionSchema.ID>(),
|
||||
fork_session_id: text().$type<SessionSchema.ID>(),
|
||||
fork_message_id: text().$type<SessionMessage.ID>(),
|
||||
fork_seq: integer(),
|
||||
fork_boundary: text({ mode: "json" }).$type<Session.ForkBoundary>(),
|
||||
slug: text().notNull(),
|
||||
directory: directoryColumn().notNull(),
|
||||
path: pathColumn(),
|
||||
|
||||
+34
-18
@@ -12,6 +12,8 @@ import { Bus } from "./bus"
|
||||
import { Location } from "./location"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { ShellSelect } from "./shell/select"
|
||||
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
|
||||
import { PluginHooks } from "./plugin/hooks"
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Shell.NotFoundError", {
|
||||
id: Shell.ID,
|
||||
@@ -45,7 +47,10 @@ type Active = {
|
||||
*/
|
||||
export interface Interface {
|
||||
readonly name: () => Effect.Effect<string>
|
||||
readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
|
||||
readonly create: <E = never, R = never>(
|
||||
input: Shell.CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
) => Effect.Effect<Shell.Info, E, R>
|
||||
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
||||
readonly list: () => Effect.Effect<Shell.Info[]>
|
||||
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
|
||||
@@ -68,6 +73,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<string, Active>()
|
||||
@@ -172,24 +178,34 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
|
||||
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
|
||||
input: Shell.CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
) {
|
||||
const invocation: ShellCreateBefore = {
|
||||
command: input.command,
|
||||
cwd: input.cwd ?? location.directory,
|
||||
timeout: input.timeout,
|
||||
shell: yield* resolve(),
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
OPENCODE_TERMINAL: "1",
|
||||
},
|
||||
}
|
||||
yield* hooks.trigger("shell", "create.before", invocation)
|
||||
if (before) yield* before(invocation)
|
||||
|
||||
const id = Shell.ID.ascending()
|
||||
const cwd = input.cwd ?? location.directory
|
||||
const shell = yield* resolve()
|
||||
const args = ShellSelect.args(shell, input.command)
|
||||
const args = ShellSelect.args(invocation.shell, invocation.command)
|
||||
const file = path.join(outputDir, `${id}.out`)
|
||||
const env = {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
OPENCODE_TERMINAL: "1",
|
||||
} as Record<string, string>
|
||||
|
||||
const info: Info = {
|
||||
id,
|
||||
status: "running",
|
||||
command: input.command,
|
||||
cwd,
|
||||
shell,
|
||||
command: invocation.command,
|
||||
cwd: invocation.cwd,
|
||||
shell: invocation.shell,
|
||||
file,
|
||||
metadata: input.metadata ?? {},
|
||||
time: { started: Date.now() },
|
||||
@@ -203,9 +219,9 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* appProcess.spawn(
|
||||
ChildProcess.make(shell, args, {
|
||||
cwd,
|
||||
env,
|
||||
ChildProcess.make(invocation.shell, args, {
|
||||
cwd: invocation.cwd,
|
||||
env: invocation.env,
|
||||
stdin: "ignore",
|
||||
detached: process.platform !== "win32",
|
||||
forceKillAfter: Duration.seconds(3),
|
||||
@@ -297,7 +313,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
yield* session.timeout(input.timeout)
|
||||
yield* session.timeout(invocation.timeout)
|
||||
|
||||
runFork(
|
||||
handle.exitCode.pipe(
|
||||
@@ -327,7 +343,7 @@ export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node],
|
||||
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ export function args(file: string, command: string) {
|
||||
if (n === "nu" || n === "fish") return ["-c", command]
|
||||
if (n === "zsh" || n === "bash") return ["-c", command]
|
||||
if (n === "cmd") return ["/c", command]
|
||||
if (ps(file)) return ["-NoProfile", "-Command", command]
|
||||
if (ps(file)) return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command]
|
||||
return ["-c", command]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
export * as ShellTool from "./shell"
|
||||
|
||||
import path from "path"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
@@ -66,18 +65,14 @@ const Output = Schema.Struct({
|
||||
...StructuredOutput.fields,
|
||||
output: Schema.String,
|
||||
status: Schema.optionalKey(Schema.Literals(["completed", "running"])),
|
||||
warnings: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||
})
|
||||
|
||||
type Output = typeof Output.Type
|
||||
|
||||
const modelOutput = (output: Output): string | undefined => {
|
||||
const warnings = output.warnings?.length
|
||||
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
|
||||
: ""
|
||||
if (output.status === "running") return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
|
||||
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
|
||||
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
|
||||
if (output.status === "running") return BACKGROUND_INSTRUCTION
|
||||
if (output.timeout) return "Command timed out before completion."
|
||||
return `Command exited with code ${output.exit}.`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,31 +81,12 @@ const modelOutput = (output: Output): string | undefined => {
|
||||
*/
|
||||
// TODO: Port tree-sitter bash / PowerShell parser-based approval reduction.
|
||||
// TODO: Port BashArity reusable command-prefix approvals.
|
||||
// TODO: Replace token-based command-argument external-directory advisories with parser-based detection.
|
||||
// TODO: Add plugin shell.env environment augmentation once plugin hooks exist.
|
||||
// TODO: Persist job status and define restart recovery before exposing remote observation.
|
||||
// TODO: Add HTTP job observation only after durable status, restart recovery, and authorization are defined.
|
||||
// TODO: Revisit process-group cleanup and platform coverage with shell-specific tests if current AppProcess semantics do not fully cover it.
|
||||
// TODO: Revisit binary output handling if stdout/stderr decoding is text-only.
|
||||
|
||||
const shellTokens = (command: string) => command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? []
|
||||
const unquote = (value: string) => value.replace(/^(['"])(.*)\1$/, "$2")
|
||||
const externalCommandDirectories = Effect.fn("ShellTool.externalCommandDirectories")(function* (
|
||||
fs: FSUtil.Interface,
|
||||
command: string,
|
||||
cwd: string,
|
||||
) {
|
||||
const directories = new Set<string>()
|
||||
for (const token of shellTokens(command)) {
|
||||
const value = unquote(token).replace(/[;,|&]+$/, "")
|
||||
if (!path.isAbsolute(value)) continue
|
||||
const resolved = yield* fs.resolve(value)
|
||||
if (FSUtil.contains(cwd, resolved)) continue
|
||||
directories.add(yield* fs.resolve(path.dirname(resolved)))
|
||||
}
|
||||
return [...directories]
|
||||
})
|
||||
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.shell",
|
||||
effect: Effect.fn("ShellTool.Plugin")(function* (ctx: PluginContext) {
|
||||
@@ -170,38 +146,40 @@ export const Plugin = {
|
||||
messageID: context.messageID,
|
||||
callID: context.callID,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const warnings = (yield* externalCommandDirectories(fsUtil, input.command, target.canonical)).map(
|
||||
(directory) =>
|
||||
`Command argument references external directory ${path.join(directory, "*").replaceAll("\\", "/")}. Shell runs with host-user filesystem, process, and network authority; this scan is advisory only.`,
|
||||
)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.command],
|
||||
save: [input.command],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
|
||||
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
|
||||
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
|
||||
const info = yield* shell.create({
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
})
|
||||
let finalTimeout = timeout
|
||||
const info = yield* shell.create(
|
||||
{
|
||||
command: input.command,
|
||||
cwd: input.workdir,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
},
|
||||
(invocation) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
invocation.cwd = target.canonical
|
||||
finalTimeout = invocation.timeout
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [invocation.command],
|
||||
save: [invocation.command],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
}),
|
||||
)
|
||||
yield* context.progress({ shellID: info.id })
|
||||
|
||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||
@@ -220,20 +198,20 @@ export const Plugin = {
|
||||
|
||||
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const capture = yield* captureShell()
|
||||
|
||||
// `exit` is optionalKey in the Output schema; a present-but-undefined key
|
||||
// fails output encoding, so omit it when the process has no exit code.
|
||||
if (final.status === "timeout") {
|
||||
return {
|
||||
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
||||
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: false,
|
||||
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: capture.truncated,
|
||||
timeout: true,
|
||||
status: "completed" as const,
|
||||
}
|
||||
}
|
||||
|
||||
const capture = yield* captureShell()
|
||||
return {
|
||||
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
||||
output: capture.output,
|
||||
@@ -251,20 +229,19 @@ export const Plugin = {
|
||||
const job = yield* runtime.job.start({
|
||||
id: context.callID,
|
||||
type: name,
|
||||
title: input.command,
|
||||
title: info.command,
|
||||
metadata: { sessionID: context.sessionID, shellID: info.id },
|
||||
run,
|
||||
})
|
||||
|
||||
if (input.background === true) {
|
||||
yield* runtime.job.background(job.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
|
||||
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,20 +250,19 @@ export const Plugin = {
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* shell.timeout(info.id, 0)
|
||||
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
|
||||
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
truncated: false,
|
||||
status: "running" as const,
|
||||
...(warnings.length ? { warnings } : {}),
|
||||
}
|
||||
}
|
||||
if (result?.info.status === "error")
|
||||
return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
|
||||
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
|
||||
|
||||
return { ...(yield* Deferred.await(settled)), ...(warnings.length ? { warnings } : {}) }
|
||||
return yield* Deferred.await(settled)
|
||||
}).pipe(
|
||||
Effect.map((output) => {
|
||||
const content: Array<Content> = [{ type: "text", text: output.output }]
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { LanguageModelV3, LanguageModelV3CallOptions, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { LLM, LLMError, LLMEvent, Message } from "@opencode-ai/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { compileRequest } from "@opencode-ai/ai/route/client"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -87,9 +88,7 @@ it.effect("projects request settings, headers, and body overlays", () =>
|
||||
headers: { "x-test": "header" },
|
||||
body: { safety_setting: "strict" },
|
||||
})
|
||||
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
|
||||
LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
)
|
||||
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||
|
||||
expect(prepared.body.providerOptions).toEqual({
|
||||
google: { thinkingConfig: { thinkingBudget: 1024 } },
|
||||
@@ -112,9 +111,7 @@ it.effect("maps pro reasoning bodies to AI SDK provider options", () =>
|
||||
...model("@ai-sdk/openai"),
|
||||
body: { reasoning: { mode: "pro" } },
|
||||
})
|
||||
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
|
||||
LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
)
|
||||
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||
|
||||
expect(body).toBeUndefined()
|
||||
expect(prepared.body.providerOptions).toEqual({
|
||||
@@ -139,9 +136,7 @@ it.effect("maps package-specific AI SDK provider option keys", () =>
|
||||
] as const
|
||||
for (const [packageName, key, settings] of cases) {
|
||||
const resolved = yield* aisdk.model(model(packageName, { reasoningEffort: "high" }))
|
||||
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
|
||||
LLM.request({ model: resolved, prompt: "Hello" }),
|
||||
)
|
||||
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||
expect(prepared.body.providerOptions).toEqual({ [key]: settings })
|
||||
}
|
||||
}),
|
||||
@@ -155,17 +150,13 @@ it.effect("forces reasoning and projects both Azure AI SDK namespaces", () =>
|
||||
})
|
||||
|
||||
const openai = yield* aisdk.model(model("@ai-sdk/openai", { reasoningEffort: "high" }))
|
||||
const openaiPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
|
||||
LLM.request({ model: openai, prompt: "Hello" }),
|
||||
)
|
||||
const openaiPrepared = yield* compileRequest(LLM.request({ model: openai, prompt: "Hello" }))
|
||||
expect(openaiPrepared.body.providerOptions).toEqual({
|
||||
openai: { reasoningEffort: "high", forceReasoning: true },
|
||||
})
|
||||
|
||||
const azure = yield* aisdk.model(model("@ai-sdk/azure", { reasoningEffort: "high" }))
|
||||
const azurePrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
|
||||
LLM.request({ model: azure, prompt: "Hello" }),
|
||||
)
|
||||
const azurePrepared = yield* compileRequest(LLM.request({ model: azure, prompt: "Hello" }))
|
||||
expect(azurePrepared.body.providerOptions).toEqual({
|
||||
openai: { reasoningEffort: "high", forceReasoning: true },
|
||||
azure: { reasoningEffort: "high", forceReasoning: true },
|
||||
@@ -187,9 +178,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () =>
|
||||
}),
|
||||
modelID: Model.ID.make("anthropic/claude-sonnet-5"),
|
||||
})
|
||||
const anthropicPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
|
||||
LLM.request({ model: anthropic, prompt: "Hello" }),
|
||||
)
|
||||
const anthropicPrepared = yield* compileRequest(LLM.request({ model: anthropic, prompt: "Hello" }))
|
||||
expect(anthropicPrepared.body.providerOptions).toEqual({
|
||||
gateway: { order: ["anthropic"] },
|
||||
anthropic: { thinking: { type: "adaptive" } },
|
||||
@@ -199,9 +188,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () =>
|
||||
...model("@ai-sdk/gateway", { reasoningConfig: { type: "enabled" } }),
|
||||
modelID: Model.ID.make("amazon/nova-2-lite"),
|
||||
})
|
||||
const bedrockPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
|
||||
LLM.request({ model: bedrock, prompt: "Hello" }),
|
||||
)
|
||||
const bedrockPrepared = yield* compileRequest(LLM.request({ model: bedrock, prompt: "Hello" }))
|
||||
expect(bedrockPrepared.body.providerOptions).toEqual({
|
||||
bedrock: { reasoningConfig: { type: "enabled" } },
|
||||
})
|
||||
@@ -210,9 +197,7 @@ it.effect("routes AI Gateway model options by upstream prefix", () =>
|
||||
...model("@ai-sdk/gateway", { reasoningEffort: "high" }),
|
||||
modelID: Model.ID.make("deepseek/deepseek-v4"),
|
||||
})
|
||||
const fallbackPrepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
|
||||
LLM.request({ model: fallback, prompt: "Hello" }),
|
||||
)
|
||||
const fallbackPrepared = yield* compileRequest(LLM.request({ model: fallback, prompt: "Hello" }))
|
||||
expect(fallbackPrepared.body.providerOptions).toEqual({
|
||||
deepseek: { reasoningEffort: "high" },
|
||||
})
|
||||
@@ -228,7 +213,7 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
||||
|
||||
const resolved = yield* aisdk.model(model("@ai-sdk/anthropic"))
|
||||
expect(resolved.route.providerMetadataKey).toBe("anthropic")
|
||||
const prepared = yield* LLMClient.prepare<LanguageModelV3CallOptions>(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("CodeMode", () => {
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.String,
|
||||
options: { pinned: true },
|
||||
execute: ({ text }) => Effect.succeed({ output: text }),
|
||||
}),
|
||||
)
|
||||
@@ -27,6 +28,7 @@ describe("CodeMode", () => {
|
||||
path: "echo",
|
||||
description: "Echo text",
|
||||
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
|
||||
pinned: true,
|
||||
},
|
||||
])
|
||||
}).pipe(
|
||||
|
||||
@@ -2,10 +2,11 @@ import { describe, expect, test } from "bun:test"
|
||||
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
|
||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||
|
||||
const entry = (path: string, description: string, signature?: string): CodeModeCatalog.Entry => ({
|
||||
const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Entry => ({
|
||||
path,
|
||||
description,
|
||||
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
|
||||
pinned,
|
||||
})
|
||||
|
||||
const lookup = entry(
|
||||
@@ -46,6 +47,30 @@ describe("CodeModeCatalog.summarize", () => {
|
||||
expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
|
||||
})
|
||||
|
||||
test("always retains pinned tools beyond the inline budget", () => {
|
||||
const pinned = [
|
||||
entry("alpha.first", "First", undefined, true),
|
||||
entry("beta.second", "Second", undefined, true),
|
||||
]
|
||||
const catalog = CodeModeCatalog.summarize([...pinned, entry("alpha.unpinned", "Unpinned")], 0)
|
||||
|
||||
expect(catalog.shown).toBe(2)
|
||||
expect(catalog.namespaces.flatMap((namespace) => namespace.entries.map((item) => item.path))).toEqual([
|
||||
"alpha.first",
|
||||
"beta.second",
|
||||
])
|
||||
})
|
||||
|
||||
test("spends the budget remaining after pinned tools on unpinned tools", () => {
|
||||
const pinned = entry("alpha.pinned", "Pinned", undefined, true)
|
||||
const unpinned = entry("beta.unpinned", "Unpinned")
|
||||
const pinCost = Math.round(` - ${pinned.signature} // Pinned`.length / 4)
|
||||
const unpinnedCost = Math.round(` - ${unpinned.signature} // Unpinned`.length / 4)
|
||||
|
||||
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost).shown).toBe(2)
|
||||
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost - 1).shown).toBe(1)
|
||||
})
|
||||
|
||||
test("retains only the rendered portion of inline descriptions", () => {
|
||||
const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)])
|
||||
expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary")
|
||||
@@ -67,6 +92,7 @@ describe("CodeModeInstructions.render", () => {
|
||||
expect(instructions).toContain(` - ${lookup.signature} // Look up an order by ID`)
|
||||
expect(instructions).not.toContain("## Search")
|
||||
expect(instructions).toContain("The Code Mode tool catalog below is complete.")
|
||||
expect(instructions).toContain("This catalog is the complete set of tools available within Code Mode.")
|
||||
expect(instructions).not.toContain("surrounding top-level agent tools")
|
||||
})
|
||||
|
||||
@@ -76,6 +102,9 @@ describe("CodeModeInstructions.render", () => {
|
||||
expect(partial).toContain("- orders (1 tool, none shown)")
|
||||
expect(partial).toContain("## Search")
|
||||
expect(partial).toContain("The Code Mode tool catalog below is partial.")
|
||||
expect(partial).toContain(
|
||||
"The Code Mode catalog and `search` results are the complete set of tools available within Code Mode.",
|
||||
)
|
||||
expect(partial).not.toContain("surrounding top-level agent tools")
|
||||
expect(partial).toContain("- search(input: {")
|
||||
expect(partial).toContain(" limit?: number,\n offset?: number,")
|
||||
|
||||
@@ -44,6 +44,9 @@ describe("CodeModeInstructions", () => {
|
||||
it.effect("renders the initial catalog, semantic deltas, and removal", () =>
|
||||
Effect.gen(function* () {
|
||||
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
|
||||
expect(initialized.text).toContain(
|
||||
"This catalog is the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
|
||||
)
|
||||
expect(initialized.text).toContain("## Available tools")
|
||||
expect(initialized.text).not.toContain("## Search")
|
||||
expect(initialized.text).toContain(` - ${echo.signature} // Echo text`)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user