mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-26 05:05:16 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d927f6fa8d |
@@ -155,8 +155,11 @@ export interface Interface {
|
||||
readonly prepare: <Body = unknown>(request: LLMRequest) => Effect.Effect<PreparedRequestOf<Body>, LLMError>
|
||||
readonly stream: StreamMethod
|
||||
readonly generate: GenerateMethod
|
||||
readonly withOptionsTransform: (transform: OptionsTransform) => Interface
|
||||
}
|
||||
|
||||
export type OptionsTransform = (request: LLMRequest) => Effect.Effect<LLMRequest, LLMError>
|
||||
|
||||
export interface StreamMethod {
|
||||
(request: LLMRequest): Stream.Stream<LLMEvent, LLMError>
|
||||
}
|
||||
@@ -373,43 +376,59 @@ 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
|
||||
const compileWith = (transform?: OptionsTransform) =>
|
||||
Effect.fn("LLM.compile")(function* (request: LLMRequest) {
|
||||
const options = resolveRequestOptions(request)
|
||||
const resolved = applyCachePolicy(transform ? yield* transform(options) : options)
|
||||
const route = resolved.model.route
|
||||
|
||||
const body = yield* route.body
|
||||
.from(resolved)
|
||||
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
|
||||
const prepared = yield* route.prepareTransport(body, resolved)
|
||||
const body = yield* route.body
|
||||
.from(resolved)
|
||||
.pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema))))
|
||||
const prepared = yield* route.prepareTransport(body, resolved)
|
||||
|
||||
return {
|
||||
request: resolved,
|
||||
route,
|
||||
body,
|
||||
prepared,
|
||||
}
|
||||
})
|
||||
|
||||
const prepareWith = Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) {
|
||||
const compiled = yield* compile(request)
|
||||
|
||||
return new PreparedRequest({
|
||||
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 },
|
||||
return {
|
||||
request: resolved,
|
||||
route,
|
||||
body,
|
||||
prepared,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const compiled = yield* compile(request)
|
||||
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
||||
}),
|
||||
)
|
||||
const prepareWith = (compile: ReturnType<typeof compileWith>) =>
|
||||
Effect.fn("LLMClient.prepare")(function* (request: LLMRequest) {
|
||||
const compiled = yield* compile(request)
|
||||
|
||||
return new PreparedRequest({
|
||||
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, compile: ReturnType<typeof compileWith>) => (request: LLMRequest) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const compiled = yield* compile(request)
|
||||
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
||||
}),
|
||||
)
|
||||
|
||||
const makeClient = (runtime: TransportRuntime, transform?: OptionsTransform): Interface => {
|
||||
const compile = compileWith(transform)
|
||||
const stream = streamRequestWith(runtime, compile)
|
||||
return {
|
||||
prepare: prepareWith(compile) as Interface["prepare"],
|
||||
stream,
|
||||
generate: generateWith(stream),
|
||||
withOptionsTransform: (next) =>
|
||||
makeClient(runtime, transform ? (request) => transform(request).pipe(Effect.flatMap(next)) : next),
|
||||
}
|
||||
}
|
||||
|
||||
const generateWith = (stream: Interface["stream"]) =>
|
||||
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
|
||||
@@ -423,7 +442,7 @@ const generateWith = (stream: Interface["stream"]) =>
|
||||
})
|
||||
|
||||
export const prepare = <Body = unknown>(request: LLMRequest) =>
|
||||
prepareWith(request) as Effect.Effect<PreparedRequestOf<Body>, LLMError>
|
||||
prepareWith(compileWith())(request) as Effect.Effect<PreparedRequestOf<Body>, LLMError>
|
||||
|
||||
export function stream(request: LLMRequest): Stream.Stream<LLMEvent, LLMError> {
|
||||
return Stream.unwrap(
|
||||
@@ -449,11 +468,12 @@ export const streamRequest = (request: LLMRequest) =>
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const stream = streamRequestWith({
|
||||
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(
|
||||
makeClient({
|
||||
http: yield* RequestExecutor.Service,
|
||||
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ export type {
|
||||
RouteDefaultsInput,
|
||||
AnyRoute,
|
||||
Interface as LLMClientShape,
|
||||
OptionsTransform,
|
||||
Service as LLMClientService,
|
||||
} from "./client"
|
||||
export * from "./executor"
|
||||
|
||||
@@ -694,4 +694,47 @@ describe("OpenAI Chat route", () => {
|
||||
expect(events.map((event) => event.type)).toEqual(["step-start"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("transforms resolved options before provider lowering", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* LLMClient.Service
|
||||
const resolvedModel = OpenAIChat.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://api.openai.test/v1/" },
|
||||
auth: Auth.bearer("test"),
|
||||
generation: { topP: 0.8 },
|
||||
})
|
||||
.model({ id: "gpt-4o-mini", defaults: { generation: { topK: 4 } } })
|
||||
let seen: typeof request.generation
|
||||
const transformed = LLM.request({
|
||||
model: resolvedModel,
|
||||
prompt: "Say hello.",
|
||||
generation: { maxTokens: 20, temperature: 0.1 },
|
||||
})
|
||||
|
||||
yield* llm
|
||||
.withOptionsTransform((request) =>
|
||||
Effect.sync(() => {
|
||||
seen = request.generation
|
||||
const generation = { ...request.generation, temperature: 0.7 }
|
||||
delete generation.topP
|
||||
return LLM.updateRequest(request, { generation })
|
||||
}),
|
||||
)
|
||||
.stream(transformed)
|
||||
.pipe(Stream.runDrain)
|
||||
|
||||
expect(seen).toMatchObject({ maxTokens: 20, temperature: 0.1, topP: 0.8, topK: 4 })
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) => {
|
||||
expect(JSON.parse(input.text)).toMatchObject({ max_tokens: 20, temperature: 0.7 })
|
||||
expect(JSON.parse(input.text)).not.toHaveProperty("top_p")
|
||||
return Effect.succeed(
|
||||
input.respond(sseEvents(deltaChunk({}, "stop")), { headers: { "content-type": "text/event-stream" } }),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -163,12 +163,7 @@ export type SessionMessageProviderState7 = { [x: string]: any }
|
||||
|
||||
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
|
||||
|
||||
export type ModelInterleavedField =
|
||||
| "reasoning"
|
||||
| "reasoning_content"
|
||||
| "reasoning_text"
|
||||
| "reasoning_details"
|
||||
| (string & {})
|
||||
export type ModelCapabilities = { tools: boolean; input: Array<string>; output: Array<string> }
|
||||
|
||||
export type ModelVariant = {
|
||||
id: string
|
||||
@@ -1099,7 +1094,7 @@ export type TuiCommandExecute = {
|
||||
| "prompt.clear"
|
||||
| "prompt.submit"
|
||||
| "agent.cycle"
|
||||
| (string & {})
|
||||
| string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1406,8 +1401,6 @@ export type SessionToolFailed = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ModelInterleaved = true | { field: ModelInterleavedField }
|
||||
|
||||
export type ModelCost = {
|
||||
tier?: { type: "context"; size: number }
|
||||
input: MoneyUSDPerMillionTokens
|
||||
@@ -1892,11 +1885,23 @@ export type SessionMessageCompaction =
|
||||
| SessionMessageCompactionCompleted
|
||||
| SessionMessageCompactionFailed
|
||||
|
||||
export type ModelCapabilities = {
|
||||
tools: boolean
|
||||
input: Array<string>
|
||||
output: Array<string>
|
||||
interleaved?: ModelInterleaved
|
||||
export type ModelInfo = {
|
||||
id: string
|
||||
modelID: string
|
||||
providerID: string
|
||||
family?: string
|
||||
name: string
|
||||
package?: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
capabilities: ModelCapabilities
|
||||
variants: Array<ModelVariant>
|
||||
time: { released: number }
|
||||
cost: Array<ModelCost>
|
||||
status: "alpha" | "beta" | "deprecated" | "active"
|
||||
enabled: boolean
|
||||
limit: { context: number; input?: number; output: number }
|
||||
}
|
||||
|
||||
export type IntegrationOAuthMethod = {
|
||||
@@ -2026,25 +2031,6 @@ export type SessionMessageAssistantTool = {
|
||||
time: { created: number; ran?: number; completed?: number }
|
||||
}
|
||||
|
||||
export type ModelInfo = {
|
||||
id: string
|
||||
modelID: string
|
||||
providerID: string
|
||||
family?: string
|
||||
name: string
|
||||
package?: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
capabilities: ModelCapabilities
|
||||
variants: Array<ModelVariant>
|
||||
time: { released: number }
|
||||
cost: Array<ModelCost>
|
||||
status: "alpha" | "beta" | "deprecated" | "active"
|
||||
enabled: boolean
|
||||
limit: { context: number; input?: number; output: number }
|
||||
}
|
||||
|
||||
export type IntegrationMethod =
|
||||
| IntegrationOAuthMethod
|
||||
| IntegrationCommandMethod
|
||||
|
||||
@@ -69,9 +69,6 @@ export const Plugin = define({
|
||||
tools: config.capabilities.tools,
|
||||
input: [...config.capabilities.input],
|
||||
output: [...config.capabilities.output],
|
||||
...(config.capabilities.interleaved !== undefined
|
||||
? { interleaved: config.capabilities.interleaved }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
if (config.variants !== undefined) {
|
||||
|
||||
@@ -12,12 +12,6 @@ export type VariantID = typeof VariantID.Type
|
||||
export const Family = Model.Family
|
||||
export type Family = Model.Family
|
||||
|
||||
export const InterleavedField = Model.InterleavedField
|
||||
export type InterleavedField = Model.InterleavedField
|
||||
|
||||
export const Interleaved = Model.Interleaved
|
||||
export type Interleaved = Model.Interleaved
|
||||
|
||||
export const Capabilities = Model.Capabilities
|
||||
export type Capabilities = Model.Capabilities
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ type SourceModel = {
|
||||
readonly reasoning_options?: readonly ReasoningOption[]
|
||||
readonly temperature?: boolean
|
||||
readonly tool_call: boolean
|
||||
readonly interleaved?: ModelV2.Interleaved
|
||||
readonly interleaved?: true | { readonly field: "reasoning" | "reasoning_content" | "reasoning_details" }
|
||||
readonly cost?: Cost
|
||||
readonly limit: { readonly context: number; readonly input?: number; readonly output: number }
|
||||
readonly modalities?: { readonly input: readonly Modality[]; readonly output: readonly Modality[] }
|
||||
@@ -505,7 +505,6 @@ function modelInfo(
|
||||
tools: model.tool_call,
|
||||
input: [...(model.modalities?.input ?? [])],
|
||||
output: [...(model.modalities?.output ?? [])],
|
||||
...(model.interleaved !== undefined ? { interleaved: model.interleaved } : {}),
|
||||
},
|
||||
variants: [...(input.variants ?? [])],
|
||||
time: { released: released(model.release_date) },
|
||||
|
||||
@@ -16,6 +16,10 @@ export interface Domains {
|
||||
type Callback<Event> = (event: Event) => Effect.Effect<void>
|
||||
|
||||
export interface Interface {
|
||||
readonly has: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
) => boolean
|
||||
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
@@ -60,7 +64,7 @@ const layer = Layer.effect(
|
||||
return event
|
||||
})
|
||||
|
||||
return Service.of({ register, trigger })
|
||||
return Service.of({ has: (domain, name) => callbacks.has(key(domain, name)), register, trigger })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SessionContext } from "./context"
|
||||
import { SessionGenerate } from "./generate"
|
||||
import { SessionHistory } from "./history"
|
||||
import { SessionModelHeaders } from "./model-headers"
|
||||
import { SessionOptionsHook } from "./options-hook"
|
||||
import { SessionRunnerModel } from "./runner/model"
|
||||
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
|
||||
import { toLLMMessages } from "./runner/to-llm-message"
|
||||
@@ -46,7 +47,11 @@ const layer = Layer.effect(
|
||||
],
|
||||
tools: {},
|
||||
})
|
||||
return (yield* llm.generate(
|
||||
return (yield* SessionOptionsHook.client(llm, hooks, {
|
||||
sessionID: selection.session.id,
|
||||
agent: selection.agent.id,
|
||||
model: model.ref,
|
||||
}).generate(
|
||||
LLM.request({
|
||||
model: model.model,
|
||||
http: { headers: SessionModelHeaders.make(selection.session) },
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export * as SessionOptionsHook from "./options-hook"
|
||||
|
||||
import { LLM } from "@opencode-ai/ai"
|
||||
import type { LLMClientShape } from "@opencode-ai/ai/route"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/v2/effect/session"
|
||||
import { Effect } from "effect"
|
||||
import { PluginHooks } from "../plugin/hooks"
|
||||
|
||||
type Identity = Pick<SessionHooks["options"], "sessionID" | "agent" | "model">
|
||||
|
||||
export const client = (llm: LLMClientShape, hooks: PluginHooks.Interface, identity: Identity) =>
|
||||
hooks.has("session", "options")
|
||||
? llm.withOptionsTransform((request) =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* hooks.trigger("session", "options", {
|
||||
...identity,
|
||||
generation: { ...request.generation },
|
||||
providerOptions: structuredClone(request.providerOptions ?? {}),
|
||||
})
|
||||
return LLM.updateRequest(request, {
|
||||
generation: event.generation,
|
||||
providerOptions: event.providerOptions,
|
||||
})
|
||||
}),
|
||||
)
|
||||
: llm
|
||||
@@ -6,6 +6,7 @@ import { Cause, Effect, Exit, Fiber, FiberSet, Layer, Option, Semaphore, Stream
|
||||
import { Database } from "../../database/database"
|
||||
import { EventV2 } from "../../event"
|
||||
import { PermissionV2 } from "../../permission"
|
||||
import { PluginHooks } from "../../plugin/hooks"
|
||||
import { QuestionTool } from "../../tool/question"
|
||||
import { ToolOutputStore } from "../../tool-output-store"
|
||||
import { InstructionState } from "../instruction-state"
|
||||
@@ -13,6 +14,7 @@ import { SessionCompaction } from "../compaction"
|
||||
import { SessionContext } from "../context"
|
||||
import { SessionEvent } from "../event"
|
||||
import { SessionPending } from "../pending"
|
||||
import { SessionOptionsHook } from "../options-hook"
|
||||
import { SessionModelRequest } from "../model-request"
|
||||
import { SessionMessage } from "../message"
|
||||
import { SessionSchema } from "../schema"
|
||||
@@ -40,6 +42,7 @@ const layer = Layer.effect(
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const title = yield* SessionTitle.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
// Title generation is a side effect of the first step; it must not delay step continuation.
|
||||
// Tracked per process so repeated wakes before the second user message arrives don't
|
||||
// re-fire a redundant LLM call; `SessionTitle` itself is idempotent based on durable history.
|
||||
@@ -132,7 +135,12 @@ const layer = Layer.effect(
|
||||
const serialized = <A, E, R>(effect: Effect.Effect<A, E, R>) => publication.withPermit(effect)
|
||||
const publish = (event: LLMEvent, error?: SessionError.Error) => serialized(publisher.publish(event, error))
|
||||
let overflowFailure: ProviderErrorEvent | undefined
|
||||
const providerStream = llm.stream(prepared.request).pipe(
|
||||
const sessionLLM = SessionOptionsHook.client(llm, hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
})
|
||||
const providerStream = sessionLLM.stream(prepared.request).pipe(
|
||||
Stream.runForEach((event) =>
|
||||
Effect.gen(function* () {
|
||||
if (overflowFailure || publisher.hasProviderError()) return
|
||||
@@ -482,5 +490,6 @@ export const node = makeLocationNode({
|
||||
SessionTitle.node,
|
||||
Snapshot.node,
|
||||
Database.node,
|
||||
PluginHooks.node,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -271,16 +271,8 @@ function migrateModel(info: typeof ConfigProviderV1.Model.Type) {
|
||||
: []),
|
||||
]
|
||||
const capabilities =
|
||||
info.tool_call !== undefined ||
|
||||
info.modalities?.input !== undefined ||
|
||||
info.modalities?.output !== undefined ||
|
||||
info.interleaved !== undefined
|
||||
? {
|
||||
tools: info.tool_call ?? false,
|
||||
input: info.modalities?.input ?? [],
|
||||
output: info.modalities?.output ?? [],
|
||||
...(info.interleaved !== undefined ? { interleaved: info.interleaved } : {}),
|
||||
}
|
||||
info.tool_call !== undefined || info.modalities?.input !== undefined || info.modalities?.output !== undefined
|
||||
? { tools: info.tool_call ?? false, input: info.modalities?.input ?? [], output: info.modalities?.output ?? [] }
|
||||
: undefined
|
||||
return {
|
||||
modelID: info.id,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as ConfigProviderV1 from "./provider"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Interleaved } from "@opencode-ai/schema/model"
|
||||
import { PositiveInt } from "../../schema"
|
||||
|
||||
export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"])
|
||||
@@ -15,7 +14,14 @@ export const Model = Schema.Struct({
|
||||
reasoning: Schema.optional(Schema.Boolean),
|
||||
temperature: Schema.optional(Schema.Boolean),
|
||||
tool_call: Schema.optional(Schema.Boolean),
|
||||
interleaved: Schema.optional(Interleaved),
|
||||
interleaved: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Literal(true),
|
||||
Schema.Struct({
|
||||
field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
cost: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
|
||||
@@ -289,24 +289,6 @@ describe("Config", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("migrates provider-specific interleaved reasoning fields", () =>
|
||||
Effect.sync(() => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
custom: {
|
||||
models: {
|
||||
chat: { interleaved: { field: "vendor_reasoning" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.custom?.models?.chat?.capabilities?.interleaved).toEqual({
|
||||
field: "vendor_reasoning",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("migrates v1 provider lists to policies", () =>
|
||||
Effect.sync(() => {
|
||||
expect(
|
||||
|
||||
@@ -170,12 +170,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
models: {
|
||||
chat: {
|
||||
name: "First",
|
||||
capabilities: {
|
||||
tools: true,
|
||||
input: ["text"],
|
||||
output: ["text"],
|
||||
interleaved: { field: "vendor_reasoning" },
|
||||
},
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
disabled: true,
|
||||
limit: { context: 100, output: 50 },
|
||||
cost: { input: 1, output: 2 },
|
||||
@@ -256,12 +251,7 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
expect(model.id).toBe(modelID)
|
||||
expect(model.modelID).toBe(ModelV2.ID.make("api-chat"))
|
||||
expect(model.name).toBe("Last")
|
||||
expect(model.capabilities).toEqual({
|
||||
tools: true,
|
||||
input: ["text"],
|
||||
output: ["text"],
|
||||
interleaved: { field: "vendor_reasoning" },
|
||||
})
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text"], output: ["text"] })
|
||||
expect(model.enabled).toBe(false)
|
||||
expect(model.limit).toEqual({ context: 100, output: 75 })
|
||||
expect(model.cost).toEqual([
|
||||
|
||||
@@ -47,7 +47,6 @@ const fixture = {
|
||||
reasoning: false,
|
||||
temperature: true,
|
||||
tool_call: true,
|
||||
interleaved: { field: "vendor_reasoning" },
|
||||
limit: { context: 128000, output: 8192 },
|
||||
},
|
||||
},
|
||||
@@ -70,7 +69,7 @@ const fixtureSnapshot = [
|
||||
family: undefined,
|
||||
package: undefined,
|
||||
settings: undefined,
|
||||
capabilities: { tools: true, input: [], output: [], interleaved: { field: "vendor_reasoning" } },
|
||||
capabilities: { tools: true, input: [], output: [] },
|
||||
variants: [],
|
||||
time: { released: Date.parse("2026-01-01") },
|
||||
cost: [
|
||||
|
||||
@@ -218,6 +218,38 @@ describe("fromPromise", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards session option hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* PluginPromise.fromPromise(
|
||||
Plugin.define({
|
||||
id: "promise-session-options",
|
||||
setup: async (ctx) => {
|
||||
await ctx.session.hook("options", (event) => {
|
||||
event.generation.temperature = 0.3
|
||||
event.providerOptions.openai ??= {}
|
||||
event.providerOptions.openai.reasoningEffort = "high"
|
||||
})
|
||||
},
|
||||
}),
|
||||
).effect(host)
|
||||
const event: SessionHooks["options"] = {
|
||||
sessionID: SessionV2.ID.make("ses_promise_session_options"),
|
||||
agent: AgentV2.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
|
||||
generation: { temperature: 0.7 },
|
||||
providerOptions: {},
|
||||
}
|
||||
|
||||
yield* hooks.trigger("session", "options", event)
|
||||
|
||||
expect(event.generation.temperature).toBe(0.3)
|
||||
expect(event.providerOptions).toEqual({ openai: { reasoningEffort: "high" } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disposes a hook registration on request", () =>
|
||||
Effect.gen(function* () {
|
||||
const agents = yield* AgentV2.Service
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import type { LLMClientShape } from "@opencode-ai/ai/route"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -40,14 +41,16 @@ const projects = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
let requests: LLMRequest[] = []
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
const clientValue: LLMClientShape = {
|
||||
prepare: () => Effect.die("unused"),
|
||||
stream: (request: LLMRequest) => {
|
||||
requests.push(request)
|
||||
return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual session summary" }))
|
||||
},
|
||||
generate: () => Effect.die("unused"),
|
||||
})
|
||||
withOptionsTransform: () => clientValue,
|
||||
}
|
||||
const client = Layer.mock(LLMClient.Service)(clientValue)
|
||||
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
|
||||
const locations = Layer.effect(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import type { LLMClientShape } from "@opencode-ai/ai/route"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -42,7 +43,7 @@ const cost = [
|
||||
},
|
||||
},
|
||||
]
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
const clientValue: LLMClientShape = {
|
||||
prepare: () => Effect.die("unused"),
|
||||
stream: (request: LLMRequest) => {
|
||||
requests.push(request)
|
||||
@@ -66,7 +67,9 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
)
|
||||
},
|
||||
generate: () => Effect.die("unused"),
|
||||
})
|
||||
withOptionsTransform: () => clientValue,
|
||||
}
|
||||
const client = Layer.mock(LLMClient.Service)(clientValue)
|
||||
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LLMResponse, Model, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import type { LLMClientShape } from "@opencode-ai/ai/route"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -47,7 +48,7 @@ let instruction: string | Instructions.Unavailable = "Initial context"
|
||||
const sessionID = SessionSchema.ID.make("ses_generate_test")
|
||||
|
||||
const model = Model.make({ id: "generate-model", provider: "test", route: OpenAIChat.route })
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
const clientValue: LLMClientShape = {
|
||||
prepare: () => Effect.die(new Error("unused")),
|
||||
stream: () => Stream.die(new Error("unused")),
|
||||
generate: (request) =>
|
||||
@@ -64,7 +65,9 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
if (!response) throw new Error("Incomplete generate response")
|
||||
return response
|
||||
}),
|
||||
})
|
||||
withOptionsTransform: () => clientValue,
|
||||
}
|
||||
const client = Layer.mock(LLMClient.Service)(clientValue)
|
||||
const models = SessionRunnerModel.layerWith(() => Effect.succeed(SessionRunnerModel.resolved(model)))
|
||||
const builtins = Layer.mock(InstructionBuiltIns.Service, {
|
||||
load: () =>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LLM, LLMClient, Model as LLMModel } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import type { LLMClientShape, OptionsTransform } from "@opencode-ai/ai/route"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { PluginHooks } from "../src/plugin/hooks"
|
||||
import { SessionOptionsHook } from "../src/session/options-hook"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const layer = PluginHooks.node.implementation as Layer.Layer<PluginHooks.Service>
|
||||
const it = testEffect(layer)
|
||||
|
||||
it.effect("applies session option hooks to resolved model options", () =>
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const sessionID = Session.ID.make("ses_options")
|
||||
const agent = Agent.ID.make("build")
|
||||
const model = Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") })
|
||||
yield* hooks.register("session", "options", (event) =>
|
||||
Effect.sync(() => {
|
||||
expect(event).toMatchObject({ sessionID, agent, model })
|
||||
event.generation.temperature = 0.2
|
||||
delete event.generation.topP
|
||||
event.providerOptions.openai ??= {}
|
||||
event.providerOptions.openai.reasoningEffort = "high"
|
||||
}),
|
||||
)
|
||||
let transform: OptionsTransform | undefined
|
||||
const llm: LLMClientShape = {
|
||||
prepare: () => Effect.die("unused"),
|
||||
stream: () => Stream.die("unused"),
|
||||
generate: () => Effect.die("unused"),
|
||||
withOptionsTransform: (next) => {
|
||||
transform = next
|
||||
return llm
|
||||
},
|
||||
}
|
||||
|
||||
SessionOptionsHook.client(llm, hooks, { sessionID, agent, model })
|
||||
if (!transform) return yield* Effect.die("options transform was not installed")
|
||||
const result = yield* transform(
|
||||
LLM.request({
|
||||
model: LLMModel.make({ id: "model", provider: "test", route: OpenAIChat.route }),
|
||||
generation: { temperature: 0.7, topP: 0.9 },
|
||||
providerOptions: { openai: { promptCacheKey: "cache" } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.generation).toMatchObject({ temperature: 0.2 })
|
||||
expect(result.generation?.topP).toBeUndefined()
|
||||
expect(result.providerOptions).toEqual({ openai: { promptCacheKey: "cache", reasoningEffort: "high" } })
|
||||
}),
|
||||
)
|
||||
@@ -90,9 +90,7 @@ let toolExecutionsStarted: Deferred.Deferred<void> | undefined
|
||||
let toolExecutionsReady = 5
|
||||
let activeToolExecutions = 0
|
||||
let maxActiveToolExecutions = 0
|
||||
const client = Layer.succeed(
|
||||
LLMClient.Service,
|
||||
LLMClient.Service.of({
|
||||
const clientValue: LLMClientShape = LLMClient.Service.of({
|
||||
prepare: () => Effect.die("unused"),
|
||||
stream: ((request: LLMRequest) => {
|
||||
requests.push(request)
|
||||
@@ -114,8 +112,9 @@ const client = Layer.succeed(
|
||||
)
|
||||
}) as unknown as LLMClientShape["stream"],
|
||||
generate: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
withOptionsTransform: () => clientValue,
|
||||
})
|
||||
const client = Layer.succeed(LLMClient.Service, clientValue)
|
||||
const reply = {
|
||||
stop: () => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import type { LLMClientShape } from "@opencode-ai/ai/route"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
@@ -40,7 +41,7 @@ const cost = [
|
||||
},
|
||||
},
|
||||
]
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
const clientValue: LLMClientShape = {
|
||||
prepare: () => Effect.die("unused"),
|
||||
stream: (request: LLMRequest) => {
|
||||
requests.push(request)
|
||||
@@ -64,7 +65,9 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
)
|
||||
},
|
||||
generate: () => Effect.die("unused"),
|
||||
})
|
||||
withOptionsTransform: () => clientValue,
|
||||
}
|
||||
const client = Layer.mock(LLMClient.Service)(clientValue)
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: () => Effect.succeed(SessionRunnerModel.resolved(model, undefined, cost)),
|
||||
})
|
||||
|
||||
@@ -830,7 +830,7 @@ function structuralTypes(schemas: ReadonlyArray<Schema.Top>, mutable: boolean, r
|
||||
.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
|
||||
.replaceAll("Schema.Json", "JsonValue")
|
||||
.replaceAll(/(?<!["'])\bunknown\b(?!["'])/g, "any")
|
||||
return mutable ? mutableType(preserveStringSuggestions(output)) : preserveStringSuggestions(output)
|
||||
return mutable ? mutableType(output) : output
|
||||
}
|
||||
return {
|
||||
types: document.codes.map((code) => render(code.Type)),
|
||||
@@ -870,15 +870,9 @@ function structuralType(schema: Schema.Top) {
|
||||
}
|
||||
return type
|
||||
}
|
||||
return preserveStringSuggestions(
|
||||
expand(document.codes[0].Type)
|
||||
.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
|
||||
.replaceAll("Schema.Json", "JsonValue"),
|
||||
)
|
||||
}
|
||||
|
||||
function preserveStringSuggestions(type: string) {
|
||||
return type.replaceAll(/((?:"(?:\\.|[^"\\])*"\s*\|\s*)+)string\b/g, "$1(string & {})")
|
||||
return expand(document.codes[0].Type)
|
||||
.replaceAll(/ & Brand\.Brand<"[^"]+">/g, "")
|
||||
.replaceAll("Schema.Json", "JsonValue")
|
||||
}
|
||||
|
||||
function normalizePromiseClientContent(content: string, groups: ReadonlyArray<Group>) {
|
||||
|
||||
@@ -503,19 +503,6 @@ describe("HttpApiCodegen.generate", () => {
|
||||
expect(types).not.toContain("Brand")
|
||||
})
|
||||
|
||||
test("preserves suggestions for open string unions in Promise wire types", () => {
|
||||
const Field = Schema.Union([Schema.Literals(["reasoning", "reasoning_content"]), Schema.String]).annotate({
|
||||
identifier: "Field",
|
||||
})
|
||||
const output = emitPromise(
|
||||
compileContract(api(HttpApiEndpoint.get("get", "/model", { success: Schema.Struct({ field: Field }) }))),
|
||||
)
|
||||
|
||||
expect(output.files.find((file) => file.path === "types.ts")?.content).toContain(
|
||||
'export type Field = "reasoning" | "reasoning_content" | (string & {})',
|
||||
)
|
||||
})
|
||||
|
||||
test("retains non-recursive references in Promise wire types", () => {
|
||||
const Referenced = Schema.Struct({ value: Schema.String }).annotate({ identifier: "Referenced" })
|
||||
const output = emitPromise(
|
||||
|
||||
@@ -92,6 +92,20 @@ yield *
|
||||
)
|
||||
```
|
||||
|
||||
Resolved generation and provider options are mutable before provider lowering:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
ctx.session.hook("options", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.generation.temperature = 0.2
|
||||
event.generation.topP = 0.9
|
||||
event.providerOptions.openai ??= {}
|
||||
event.providerOptions.openai.reasoningEffort = "high"
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
## Reloading A Domain
|
||||
|
||||
When data captured by a transform changes, reload the affected domain:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/effect/api"
|
||||
import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { GenerationOptionsFields, Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
@@ -15,8 +15,23 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export type SessionGenerationOptions = {
|
||||
-readonly [Key in keyof GenerationOptionsFields]: GenerationOptionsFields[Key]
|
||||
}
|
||||
|
||||
export type SessionProviderOptions = Record<string, Record<string, unknown>>
|
||||
|
||||
export interface SessionOptions {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
generation: SessionGenerationOptions
|
||||
providerOptions: SessionProviderOptions
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly options: SessionOptions
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -94,6 +94,17 @@ await ctx.session.hook("context", (event) => {
|
||||
})
|
||||
```
|
||||
|
||||
Resolved generation and provider options are mutable before provider lowering:
|
||||
|
||||
```ts
|
||||
await ctx.session.hook("options", (event) => {
|
||||
event.generation.temperature = 0.2
|
||||
event.generation.topP = 0.9
|
||||
event.providerOptions.openai ??= {}
|
||||
event.providerOptions.openai.reasoningEffort = "high"
|
||||
})
|
||||
```
|
||||
|
||||
Promise tools use plain object declarations with async executors:
|
||||
|
||||
```ts
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SessionApi } from "@opencode-ai/client/promise/api"
|
||||
import type { Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { GenerationOptionsFields, Message, SystemPart } from "@opencode-ai/ai"
|
||||
import type { Agent } from "@opencode-ai/schema/agent"
|
||||
import type { Model } from "@opencode-ai/schema/model"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
@@ -15,8 +15,23 @@ export interface SessionContext {
|
||||
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
|
||||
}
|
||||
|
||||
export type SessionGenerationOptions = {
|
||||
-readonly [Key in keyof GenerationOptionsFields]: GenerationOptionsFields[Key]
|
||||
}
|
||||
|
||||
export type SessionProviderOptions = Record<string, Record<string, unknown>>
|
||||
|
||||
export interface SessionOptions {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model: Model.Ref
|
||||
generation: SessionGenerationOptions
|
||||
providerOptions: SessionProviderOptions
|
||||
}
|
||||
|
||||
export interface SessionHooks {
|
||||
readonly context: SessionContext
|
||||
readonly options: SessionOptions
|
||||
}
|
||||
|
||||
export type SessionDomain = Pick<
|
||||
|
||||
@@ -41,29 +41,11 @@ export interface Ref extends Schema.Schema.Type<typeof Ref> {}
|
||||
export const Family = Schema.String.pipe(Schema.brand("Model.Family"))
|
||||
export type Family = typeof Family.Type
|
||||
|
||||
export type InterleavedField =
|
||||
| "reasoning"
|
||||
| "reasoning_content"
|
||||
| "reasoning_text"
|
||||
| "reasoning_details"
|
||||
| (string & {})
|
||||
export const InterleavedField: Schema.Codec<InterleavedField> = Schema.Union([
|
||||
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text", "reasoning_details"]),
|
||||
Schema.String,
|
||||
]).annotate({ identifier: "Model.InterleavedField" })
|
||||
|
||||
export type Interleaved = true | { readonly field: InterleavedField }
|
||||
export const Interleaved: Schema.Codec<Interleaved> = Schema.Union([
|
||||
Schema.Literal(true),
|
||||
Schema.Struct({ field: InterleavedField }),
|
||||
]).annotate({ identifier: "Model.Interleaved" })
|
||||
|
||||
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
|
||||
export const Capabilities = Schema.Struct({
|
||||
tools: Schema.Boolean,
|
||||
input: Schema.Array(Schema.String),
|
||||
output: Schema.Array(Schema.String),
|
||||
interleaved: Interleaved.pipe(optional),
|
||||
}).annotate({ identifier: "Model.Capabilities" })
|
||||
|
||||
export interface Cost extends Schema.Schema.Type<typeof Cost> {}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { Model } from "../src/model.js"
|
||||
|
||||
describe("Model.Ref", () => {
|
||||
@@ -21,13 +20,3 @@ describe("Model.Ref", () => {
|
||||
expect(() => Model.Ref.parse("openai/gpt-5#high#extra")).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Model.Interleaved", () => {
|
||||
test("accepts known and provider-specific fields", () => {
|
||||
const decode = Schema.decodeUnknownSync(Model.Interleaved)
|
||||
const fields = ["reasoning", "reasoning_content", "reasoning_text", "reasoning_details", "vendor_reasoning"]
|
||||
|
||||
for (const field of fields) expect(decode({ field })).toEqual({ field })
|
||||
expect(decode(true)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user