Compare commits

...
42 changed files with 983 additions and 549 deletions
+10 -9
View File
@@ -436,21 +436,22 @@ const mapFinishReason = (reason: string): FinishReason => {
return "unknown"
}
// AWS Bedrock Converse reports `inputTokens` (inclusive total) with
// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass
// the total through and derive the non-cached breakdown. Bedrock does
// not break reasoning out of `outputTokens` for any current model.
// AWS reports inputTokens separately from cache reads and writes.
// Bedrock does not break reasoning out of outputTokens for current models.
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
if (!usage) return undefined
const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0)
const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal)
const inputTokens = ProviderShared.sumTokens(
usage.inputTokens,
usage.cacheReadInputTokens,
usage.cacheWriteInputTokens,
)
return new Usage({
inputTokens: usage.inputTokens,
inputTokens,
outputTokens: usage.outputTokens,
nonCachedInputTokens: nonCached,
nonCachedInputTokens: usage.inputTokens,
cacheReadInputTokens: usage.cacheReadInputTokens,
cacheWriteInputTokens: usage.cacheWriteInputTokens,
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
providerMetadata: { bedrock: usage },
})
}
@@ -253,7 +253,7 @@ const OpenAIResponsesEvent = Schema.Struct({
Schema.Struct({
id: Schema.optional(Schema.String),
service_tier: optionalNull(Schema.String),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
usage: optionalNull(OpenAIResponsesUsage),
error: optionalNull(OpenAIResponsesErrorPayload),
}),
@@ -602,7 +602,8 @@ const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => {
const mapFinishReason = (event: OpenAIResponsesEvent, hasFunctionCall: boolean): FinishReason => {
const reason = event.response?.incomplete_details?.reason
if (reason === undefined || reason === null) return hasFunctionCall ? "tool-calls" : "stop"
if (reason === undefined || reason === null)
return hasFunctionCall ? "tool-calls" : event.type === "response.incomplete" ? "unknown" : "stop"
if (reason === "max_output_tokens") return "length"
if (reason === "content_filter") return "content-filter"
return hasFunctionCall ? "tool-calls" : "unknown"
+4 -3
View File
@@ -34,11 +34,12 @@ import { ProviderFailureClassification } from "./errors"
*
* **Semantics by provider**:
*
* - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive
* - OpenAI Chat / Responses / Gemini: provider reports inclusive
* `inputTokens` and an inclusive `outputTokens`; mapper subtracts to
* derive the breakdown.
* - Anthropic: provider reports the breakdown natively (`input_tokens` is
* non-cached only); mapper sums to derive the inclusive `inputTokens`.
* - Anthropic and Bedrock report the input breakdown natively: Anthropic's
* `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their
* mappers sum the breakdown to derive the inclusive `inputTokens`.
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
* `reasoningTokens` is `undefined` and `outputTokens` carries the
* combined total — a documented limitation of the Anthropic API.
File diff suppressed because one or more lines are too long
@@ -13,12 +13,8 @@ const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
// call wouldn't deterministically prove cache mapping works. Override with
// BEDROCK_CACHE_MODEL_ID if your account has access elsewhere.
const model = AmazonBedrock.configure({
credentials: {
region: RECORDING_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture",
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture",
sessionToken: process.env.AWS_SESSION_TOKEN,
},
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
region: RECORDING_REGION,
}).model(process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0")
const cacheRequest = LLM.request({
@@ -36,7 +32,7 @@ const recorded = recordedTests({
prefix: "bedrock-converse-cache",
provider: "amazon-bedrock",
protocol: "bedrock-converse",
requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
// Two identical requests in one cassette — replay walks the cassette in
// recording order so the second call replays the cached-hit interaction.
})
@@ -45,10 +41,20 @@ describe("Bedrock Converse cache recorded", () => {
recorded.effect.with("writes then reads cachePoint on identical second call", { tags: ["cache"] }, () =>
Effect.gen(function* () {
const first = yield* LLMClient.generate(cacheRequest)
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
expect(first.usage?.cacheWriteInputTokens ?? 0).toBeGreaterThan(0)
expect(first.usage?.inputTokens).toBe(
(first.usage?.nonCachedInputTokens ?? 0) +
(first.usage?.cacheReadInputTokens ?? 0) +
(first.usage?.cacheWriteInputTokens ?? 0),
)
const second = yield* LLMClient.generate(cacheRequest)
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
expect(second.usage?.inputTokens).toBe(
(second.usage?.nonCachedInputTokens ?? 0) +
(second.usage?.cacheReadInputTokens ?? 0) +
(second.usage?.cacheWriteInputTokens ?? 0),
)
}),
)
})
@@ -269,6 +269,39 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("adds cache reads and writes to Bedrock input usage", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
["contentBlockStop", { contentBlockIndex: 0 }],
["messageStop", { stopReason: "end_turn" }],
[
"metadata",
{
usage: {
inputTokens: 5,
outputTokens: 2,
totalTokens: 12,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
},
},
],
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.usage).toMatchObject({
inputTokens: 10,
nonCachedInputTokens: 5,
cacheReadInputTokens: 3,
cacheWriteInputTokens: 2,
outputTokens: 2,
totalTokens: 12,
})
}),
)
it.effect("assembles streamed tool call input", () =>
Effect.gen(function* () {
const body = eventStreamBody(
@@ -870,6 +870,32 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("maps incomplete response reasons", () =>
Effect.gen(function* () {
const generate = (incompleteDetails: object) =>
LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents({
type: "response.incomplete",
response: { id: "resp_incomplete", incomplete_details: incompleteDetails },
}),
),
),
)
const length = yield* generate({ reason: "max_output_tokens" })
const contentFilter = yield* generate({ reason: "content_filter" })
const unknown = yield* generate({})
expect([length.finishReason, contentFilter.finishReason, unknown.finishReason]).toEqual([
"length",
"content-filter",
"unknown",
])
}),
)
// OpenAI's documented stream orders output text within one message item; no
// provider-valid same-kind overlap is evidenced, so done boundaries close it.
it.effect("closes sequential output messages before starting the next", () =>
+16 -6
View File
@@ -1030,13 +1030,22 @@ export interface VcsApi<E = never> {
readonly diff: VcsDiffOperation<E>
}
export type Endpoint26_0Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location"]>>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
type Endpoint26_0Request = Parameters<RawClient["server.path"]["path.get"]>[0]
export type Endpoint26_0Input = { readonly location?: Endpoint26_0Request["query"]["location"] }
export type Endpoint26_0Output = EffectValue<ReturnType<RawClient["server.path"]["path.get"]>>
export type PathGetOperation<E = never> = (input?: Endpoint26_0Input) => Effect.Effect<Endpoint26_0Output, E>
type Endpoint26_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
export type Endpoint26_1Input = { readonly location?: Endpoint26_1Request["query"]["location"] }
export type Endpoint26_1Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location.evict"]>>
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
export interface PathApi<E = never> {
readonly get: PathGetOperation<E>
}
export type Endpoint27_0Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location"]>>
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
type Endpoint27_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
export type Endpoint27_1Input = { readonly location?: Endpoint27_1Request["query"]["location"] }
export type Endpoint27_1Output = EffectValue<ReturnType<RawClient["server.debug"]["debug.location.evict"]>>
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
export interface DebugApi<E = never> {
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
@@ -1069,5 +1078,6 @@ export interface AppApi<E = never> {
readonly reference: ReferenceApi<E>
readonly projectCopy: ProjectCopyApi<E>
readonly vcs: VcsApi<E>
readonly path: PathApi<E>
readonly debug: DebugApi<E>
}
+15 -7
View File
@@ -1232,16 +1232,23 @@ const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_1Input
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({ status: Endpoint25_0(raw), diff: Endpoint25_1(raw) })
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
type Endpoint26_0Request = Parameters<RawClient["server.path"]["path.get"]>[0]
type Endpoint26_0Input = { readonly location?: Endpoint26_0Request["query"]["location"] }
const Endpoint26_0 = (raw: RawClient["server.path"]) => (input?: Endpoint26_0Input) =>
raw["path.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup26 = (raw: RawClient["server.path"]) => ({ get: Endpoint26_0(raw) })
const Endpoint27_0 = (raw: RawClient["server.debug"]) => () =>
raw["debug.location"]({}).pipe(Effect.mapError(mapClientError))
type Endpoint26_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
type Endpoint26_1Input = { readonly location?: Endpoint26_1Request["query"]["location"] }
const Endpoint26_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint26_1Input) =>
type Endpoint27_1Request = Parameters<RawClient["server.debug"]["debug.location.evict"]>[0]
type Endpoint27_1Input = { readonly location?: Endpoint27_1Request["query"]["location"] }
const Endpoint27_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint27_1Input) =>
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
const adaptGroup27 = (raw: RawClient["server.debug"]) => ({
location: { list: Endpoint27_0(raw), evict: Endpoint27_1(raw) },
})
const adaptClient = (raw: RawClient) => ({
@@ -1271,7 +1278,8 @@ const adaptClient = (raw: RawClient) => ({
reference: adaptGroup23(raw["server.reference"]),
projectCopy: adaptGroup24(raw["server.projectCopy"]),
vcs: adaptGroup25(raw["server.vcs"]),
debug: adaptGroup26(raw["server.debug"]),
path: adaptGroup26(raw["server.path"]),
debug: adaptGroup27(raw["server.debug"]),
})
export const make = (options?: { readonly baseUrl?: URL | string }) =>
+9
View File
@@ -3,13 +3,22 @@ type Client = ReturnType<typeof import("./generated/client.js").make>
export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
export type EventApi = Client["event"]
export type FileApi = Client["file"]
export type IntegrationApi = Client["integration"]
export type MessageApi = Client["message"]
export type McpApi = Client["mcp"]
export type ModelApi = Client["model"]
export type PluginApi = Client["plugin"]
export type PermissionApi = Client["permission"]
export type PathApi = Client["path"]
export type ProjectApi = Client["project"]
export type ProviderApi = Client["provider"]
export type QuestionApi = Client["question"]
export type ReferenceApi = Client["reference"]
export type SessionApi = Client["session"]
export type SkillApi = Client["skill"]
export type PtyApi = Client["pty"]
export type VcsApi = Client["vcs"]
export interface CatalogApi {
readonly provider: ProviderApi
@@ -204,6 +204,8 @@ import type {
VcsStatusOutput,
VcsDiffInput,
VcsDiffOutput,
PathGetInput,
PathGetOutput,
DebugLocationListOutput,
DebugLocationEvictInput,
DebugLocationEvictOutput,
@@ -1708,6 +1710,20 @@ export function make(options: ClientOptions) {
requestOptions,
),
},
path: {
get: (input?: PathGetInput, requestOptions?: RequestOptions) =>
request<PathGetOutput>(
{
method: "GET",
path: `/api/path`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
debug: {
location: {
list: (requestOptions?: RequestOptions) =>
@@ -533,6 +533,8 @@ export type VcsFileStatus = {
status: "added" | "deleted" | "modified"
}
export type PathInfo = { home: string; state: string; config: string; worktree: string; directory: string }
export type SessionMessageModelSelected = {
id: string
metadata?: { [x: string]: JsonValue }
@@ -549,6 +551,7 @@ export type CommandInfo = {
agent?: string
model?: ModelRef
subtask?: boolean
source?: "command" | "mcp"
}
export type ProviderRequest = {
@@ -4952,6 +4955,14 @@ export type VcsDiffOutput = {
data: Array<FileDiffInfo>
}
export type PathGetInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type PathGetOutput = PathInfo
export type DebugLocationListOutput = Array<LocationRef>
export type DebugLocationEvictInput = {
+9
View File
@@ -4,13 +4,22 @@ export type {
CatalogApi,
CommandApi,
EventApi,
FileApi,
IntegrationApi,
MessageApi,
McpApi,
ModelApi,
PermissionApi,
PathApi,
PluginApi,
ProjectApi,
ProviderApi,
QuestionApi,
ReferenceApi,
SessionApi,
SkillApi,
PtyApi,
VcsApi,
} from "./api.js"
export type { EventSubscribeOutput as OpenCodeEvent } from "./generated/types"
export type OpenCodeClient = ReturnType<typeof import("./generated/client").make>
+1
View File
@@ -31,6 +31,7 @@ test("exposes every standard HTTP API group", () => {
"reference",
"projectCopy",
"vcs",
"path",
"debug",
])
expect(Object.keys(client.debug)).toEqual(["location"])
+5 -1
View File
@@ -85,6 +85,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
name: mcpCommandName(prompt.server, prompt.name),
template: "",
description: prompt.description,
source: "mcp",
}),
)
})
@@ -98,7 +99,10 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
return (yield* mcpCommands()).find((command) => command.name === name)
}),
list: Effect.fn("CommandV2.list")(function* () {
const commands = Array.from(state.get().commands.values()) as Info[]
const commands = Array.from(state.get().commands.values()).map((command) => ({
...command,
source: command.source ?? "command" as const,
}))
const names = new Set(commands.map((command) => command.name))
return [
...commands,
+30 -50
View File
@@ -2,12 +2,10 @@ export * as Generate from "./generate"
import { LLM, LLMClient, LLMError } from "@opencode-ai/ai"
import { Context, Effect, Layer, Schema } from "effect"
import { Catalog } from "./catalog"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { llmClient } from "./effect/app-node-platform"
import { Integration } from "./integration"
import { ModelResolver } from "./model-resolver"
import { ModelV2 } from "./model"
import { SessionRunnerModel } from "./session/runner/model"
export interface TextInput {
readonly prompt: string
@@ -19,10 +17,10 @@ export class ModelSelectionError extends Schema.TaggedErrorClass<ModelSelectionE
{ message: Schema.String },
) {}
export class UnavailableError extends Schema.TaggedErrorClass<UnavailableError>()(
"Generate.UnavailableError",
{ message: Schema.String, service: Schema.optional(Schema.String) },
) {}
export class UnavailableError extends Schema.TaggedErrorClass<UnavailableError>()("Generate.UnavailableError", {
message: Schema.String,
service: Schema.optional(Schema.String),
}) {}
export type Error = ModelSelectionError | UnavailableError
@@ -35,56 +33,34 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const llm = yield* LLMClient.Service
const selectModel = Effect.fn("Generate.selectModel")(function* (requested?: ModelV2.Ref) {
const selected = requested
? yield* catalog.model.get(requested.providerID, requested.id)
: yield* catalog.model.default().pipe(
Effect.flatMap((model) =>
model && SessionRunnerModel.supported(model)
? Effect.succeed(model)
: Effect.map(catalog.model.available(), (models) => models.find(SessionRunnerModel.supported)),
),
)
if (!selected)
return yield* new ModelSelectionError({
message: requested
? `Model unavailable: ${requested.providerID}/${requested.id}`
: "No model specified and no supported model is available",
})
return yield* SessionRunnerModel.withVariant(selected, requested?.variant).pipe(
Effect.mapError(
() =>
new ModelSelectionError({
message: `Variant unavailable for ${selected.providerID}/${selected.id}: ${requested?.variant}`,
}),
),
)
})
const resolver = yield* ModelResolver.Service
const runText = Effect.fn("Generate.text")(function* (input: TextInput) {
const selected = yield* selectModel(input.model)
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
const resolved = yield* resolver.resolve(input.model).pipe(
Effect.catchTags({
"SessionRunnerModel.VariantUnavailableError": (error) =>
input.model
? new ModelSelectionError({ message: error.message })
: new UnavailableError({ message: error.message, service: error.providerID }),
"SessionRunnerModel.UnsupportedPackageError": (error) =>
input.model
? new ModelSelectionError({ message: error.message })
: new UnavailableError({ message: error.message, service: error.providerID }),
}),
)
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
const model = yield* SessionRunnerModel.fromCatalogModel(selected, credential).pipe(
Effect.mapError((error) =>
input.model
? new ModelSelectionError({ message: error.message })
: new UnavailableError({ message: error.message, service: selected.providerID }),
),
)
const response = yield* llm.generate(LLM.request({ model, prompt: input.prompt })).pipe(
if (!resolved)
return yield* new ModelSelectionError({
message: input.model
? `Model unavailable: ${input.model.providerID}/${input.model.id}`
: "No model specified and no supported model is available",
})
const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe(
Effect.mapError(
(error: LLMError) =>
new UnavailableError({
message: error.message,
service: selected.providerID,
service: resolved.ref.providerID,
}),
),
)
@@ -106,4 +82,8 @@ export const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, Integration.node, llmClient] })
export const node = makeLocationNode({
service: Service,
layer,
deps: [ModelResolver.node, llmClient],
})
+2
View File
@@ -20,6 +20,7 @@ import { Integration } from "./integration"
import { Location } from "./location"
import { LocationMutation } from "./location-mutation"
import { LocationServiceMap } from "./location-service-map"
import { ModelResolver } from "./model-resolver"
import { MCP } from "./mcp/index"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
@@ -58,6 +59,7 @@ const locationServiceNodes = [
Reference.node,
Integration.node,
Catalog.node,
ModelResolver.node,
AISDK.node,
PluginV2.node,
PluginSupervisor.node,
+344
View File
@@ -0,0 +1,344 @@
export * as ModelResolver from "./model-resolver"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Model } from "@opencode-ai/ai"
// ast-grep-ignore: no-star-import
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
// ast-grep-ignore: no-star-import
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
// ast-grep-ignore: no-star-import
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
import { AISDK } from "./aisdk"
import { Catalog } from "./catalog"
import { Credential } from "./credential"
import { Integration } from "./integration"
import { ModelV2 } from "./model"
import { Npm } from "@opencode-ai/util/npm"
import { OpenAICodex } from "./plugin/provider/openai-codex"
import { ProviderV2 } from "./provider"
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
"SessionRunnerModel.VariantUnavailableError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
variant: ModelV2.VariantID,
},
) {
override get message() {
return `Variant unavailable for ${this.providerID}/${this.modelID}: ${this.variant}`
}
}
export class UnsupportedPackageError extends Schema.TaggedErrorClass<UnsupportedPackageError>()(
"SessionRunnerModel.UnsupportedPackageError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
package: Schema.String,
},
) {
override get message() {
return `Unsupported package for ${this.providerID}/${this.modelID}: ${this.package}`
}
}
export type Error = VariantUnavailableError | UnsupportedPackageError | Integration.AuthorizationError
export interface Resolved {
/** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */
readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref
/** Catalog capabilities used to shape requests before provider lowering. */
readonly capabilities: ModelV2.Capabilities
/** Catalog pricing in dollars per million tokens. */
readonly cost: ModelV2.Info["cost"]
}
export interface Interface {
readonly resolve: (requested?: ModelV2.Ref) => Effect.Effect<Resolved | undefined, Error>
readonly resolveModel: (model: ModelV2.Info, variant?: ModelV2.VariantID) => Effect.Effect<Resolved, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ModelResolver") {}
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
if (credential?.type === "key") return Auth.value(credential.key)
if (credential?.type === "oauth") return Auth.value(credential.access)
const value = model.settings?.apiKey
if (typeof value === "string") return Auth.value(value)
return undefined
}
const withDefaults = (model: ModelV2.Info, route: AnyRoute) =>
route.with({
provider: model.providerID,
endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined,
headers: providerHeaders(model),
providerOptions: providerOptions(model),
http: model.body === undefined ? undefined : { body: model.body },
limits: { context: model.limit.context, output: model.limit.output },
})
const providerHeaders = (model: ModelV2.Info) => {
const packageName = ProviderV2.packageName(model.package)
const generated = new Map<string, string>()
if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string")
generated.set("OpenAI-Organization", model.settings.organization)
if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string")
generated.set("OpenAI-Project", model.settings.project)
if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string")
generated.set("Authorization", `Bearer ${model.settings.authToken}`)
return ProviderV2.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
}
const providerOptions = (
model: ModelV2.Info,
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
if (!ProviderV2.isAISDK(model.package) || model.settings === undefined) return undefined
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
if (Object.keys(settings).length === 0) return undefined
const packageName = ProviderV2.packageName(model.package)
if (packageName === "@ai-sdk/openai") return { openai: settings }
if (packageName === "@ai-sdk/anthropic") return { anthropic: settings }
if (packageName === "@ai-sdk/openai-compatible") return { openai: settings }
return undefined
}
export const withVariant = (
model: ModelV2.Info,
variantID: ModelV2.VariantID | undefined,
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
const id = variantID === "default" ? undefined : variantID
const variant = model.variants?.find((item) => item.id === id)
if (!variant && variantID !== undefined && variantID !== "default")
return Effect.fail(
new VariantUnavailableError({
providerID: model.providerID,
modelID: model.id,
variant: variantID,
}),
)
return Effect.succeed(
variant
? produce(model, (draft) => {
draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings)
draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers)
draft.body = ProviderV2.mergeOverlay(draft.body, variant.body)
})
: model,
)
}
export interface Dependencies {
readonly loadPackage?: (specifier: string) => Effect.Effect<ProviderV2.ProviderPackage, ProviderV2.LoadError>
readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect<Model, AISDK.InitError>
}
export const fromCatalogModel = (
model: ModelV2.Info,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<Model, UnsupportedPackageError> => {
const resolved = produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata)
})
const packageName = ProviderV2.packageName(resolved.package)
const key = apiKey(resolved, credential)
if (OpenAICodex.isChatGPT(credential) && !ProviderV2.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
return Effect.succeed(codexModel(resolved, credential, key))
}
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
return Effect.succeed(
withDefaults(resolved, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (
ProviderV2.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
return Effect.succeed(
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (ProviderV2.isAISDK(resolved.package)) {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = ProviderV2.mergeOverlay(draft.settings, {
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
...credential?.metadata,
})
})
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!resolved.package) return Effect.fail(unsupported(resolved))
const specifier = resolved.package
return Effect.gen(function* () {
const module = yield* (dependencies?.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const configured = { ...resolved.settings, ...credential?.metadata }
const settings = {
...(credential ? withoutNativeAuthSettings(configured) : configured),
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
limits: { context: resolved.limit.context, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return Model.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
})
})
}
const isNativeOpenAI = (packageName: string | undefined) =>
packageName === "@opencode-ai/ai/providers/openai" ||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
if (!credential) return {}
if (credential.type === "key") return { apiKey: credential.key }
if (
specifier === "@opencode-ai/ai/providers/anthropic" ||
specifier === "@opencode-ai/ai/providers/anthropic-compatible"
)
return { authToken: credential.access }
if (
specifier === "@opencode-ai/ai/providers/google-vertex" ||
specifier.startsWith("@opencode-ai/ai/providers/google-vertex/")
)
return { accessToken: credential.access }
return { apiKey: credential.access }
}
const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
return rest
}
const codexModel = (
model: ModelV2.Info,
credential: Credential.Value | undefined,
key: ReturnType<typeof Auth.value> | undefined,
) => {
const account = OpenAICodex.accountID(credential)
return withDefaults(model, OpenAIResponses.route)
.with({
endpoint: { baseURL: OpenAICodex.baseURL },
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
),
})
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
}
const unsupported = (model: ModelV2.Info) =>
new UnsupportedPackageError({
providerID: model.providerID,
modelID: model.id,
package: model.package ?? "unknown",
})
export const resolveModel = (
model: ModelV2.Info,
variant: ModelV2.VariantID | undefined,
credential?: Credential.Value,
dependencies?: Dependencies,
) => withVariant(model, variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)))
export const supported = (model: ModelV2.Info) => Boolean(model.package)
/** Resolves catalog selections into runtime models for the current Location. */
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const npm = yield* Npm.Service
const aisdk = yield* AISDK.Service
const load = Effect.fn("ModelResolver.resolveModel")(function* (
selected: ModelV2.Info,
variant?: ModelV2.VariantID,
) {
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const model = yield* resolveModel(
selected,
variant,
connection ? yield* integrations.connection.resolve(connection) : undefined,
{
loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
},
)
return {
model,
ref: ModelV2.Ref.make({
id: selected.id,
providerID: selected.providerID,
...(variant === undefined ? {} : { variant }),
}),
capabilities: selected.capabilities,
cost: selected.cost,
}
})
return Service.of({
resolve: Effect.fn("ModelResolver.resolve")(function* (requested) {
const selected = requested
? yield* catalog.model.get(requested.providerID, requested.id)
: yield* catalog.model
.default()
.pipe(
Effect.flatMap((model) =>
model && supported(model)
? Effect.succeed(model)
: Effect.map(catalog.model.available(), (models) => models.find(supported)),
),
)
if (!selected) return undefined
return yield* load(selected, requested?.variant)
}),
resolveModel: load,
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
})
@@ -1,7 +1,7 @@
export * as OpenAICodex from "./openai-codex"
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
// codex routing lives in SessionRunnerModel.fromCatalogModel and catalog filtering
// Codex routing lives in ModelResolver and catalog filtering.
// in OpenAIPlugin, sharing this module. Once the native provider packages land
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
+21 -323
View File
@@ -2,30 +2,16 @@ export * as SessionRunnerModel from "./model"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Model } from "@opencode-ai/ai"
// ast-grep-ignore: no-star-import
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
// ast-grep-ignore: no-star-import
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
// ast-grep-ignore: no-star-import
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
import { Context, Effect, Layer, Schema } from "effect"
import { produce } from "immer"
import { AISDK } from "../../aisdk"
import { Catalog } from "../../catalog"
import { Credential } from "../../credential"
import { Integration } from "../../integration"
import { ModelResolver } from "../../model-resolver"
import { ModelV2 } from "../../model"
import { Npm } from "@opencode-ai/util/npm"
import { OpenAICodex } from "../../plugin/provider/openai-codex"
import { ProviderV2 } from "../../provider"
import { SessionSchema } from "../schema"
export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelectedError>()(
"SessionRunnerModel.ModelNotSelectedError",
{
sessionID: SessionSchema.ID,
},
{ sessionID: SessionSchema.ID },
) {
override get message() {
return `No model is available for session ${this.sessionID}`
@@ -34,59 +20,19 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelec
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
"SessionRunnerModel.ModelUnavailableError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
},
{ providerID: ProviderV2.ID, modelID: ModelV2.ID },
) {
override get message() {
return `Model unavailable: ${this.providerID}/${this.modelID}`
}
}
export const VariantUnavailableError = ModelResolver.VariantUnavailableError
export type VariantUnavailableError = ModelResolver.VariantUnavailableError
export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
"SessionRunnerModel.VariantUnavailableError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
variant: ModelV2.VariantID,
},
) {
override get message() {
return `Variant unavailable for ${this.providerID}/${this.modelID}: ${this.variant}`
}
}
export class UnsupportedPackageError extends Schema.TaggedErrorClass<UnsupportedPackageError>()(
"SessionRunnerModel.UnsupportedPackageError",
{
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
package: Schema.String,
},
) {
override get message() {
return `Unsupported package for ${this.providerID}/${this.modelID}: ${this.package}`
}
}
export type Error =
| ModelNotSelectedError
| ModelUnavailableError
| VariantUnavailableError
| UnsupportedPackageError
| Integration.AuthorizationError
export interface Resolved {
/** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */
readonly model: Model
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
readonly ref: ModelV2.Ref
/** Catalog capabilities used to shape requests before provider lowering. */
readonly capabilities: ModelV2.Capabilities
/** Catalog pricing in dollars per million tokens. */
readonly cost: ModelV2.Info["cost"]
}
export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error
export type Resolved = ModelResolver.Resolved
export interface Interface {
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, Error>
@@ -94,9 +40,6 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunnerModel") {}
/** Test or embedding seam for supplying a model resolver directly. */
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
export const resolved = (
model: Model,
@@ -116,276 +59,31 @@ export const resolved = (
cost: options.cost,
})
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
if (credential?.type === "key") return Auth.value(credential.key)
if (credential?.type === "oauth") return Auth.value(credential.access)
const value = model.settings?.apiKey
if (typeof value === "string") return Auth.value(value)
}
const withDefaults = (model: ModelV2.Info, route: AnyRoute) =>
route.with({
provider: model.providerID,
endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined,
headers: providerHeaders(model),
providerOptions: providerOptions(model),
http: model.body === undefined ? undefined : { body: model.body },
limits: { context: model.limit.context, output: model.limit.output },
})
const providerHeaders = (model: ModelV2.Info) => {
const packageName = ProviderV2.packageName(model.package)
const generated = new Map<string, string>()
if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string")
generated.set("OpenAI-Organization", model.settings.organization)
if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string")
generated.set("OpenAI-Project", model.settings.project)
if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string")
generated.set("Authorization", `Bearer ${model.settings.authToken}`)
return ProviderV2.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
}
const providerOptions = (
model: ModelV2.Info,
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
if (!ProviderV2.isAISDK(model.package) || model.settings === undefined) return undefined
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
if (Object.keys(settings).length === 0) return undefined
const packageName = ProviderV2.packageName(model.package)
if (packageName === "@ai-sdk/openai") return { openai: settings }
if (packageName === "@ai-sdk/anthropic") return { anthropic: settings }
if (packageName === "@ai-sdk/openai-compatible") return { openai: settings }
}
export const withVariant = (
model: ModelV2.Info,
variantID: ModelV2.VariantID | undefined,
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
const id = variantID === "default" ? undefined : variantID
const variant = model.variants?.find((item) => item.id === id)
if (!variant && variantID !== undefined && variantID !== "default")
return Effect.fail(
new VariantUnavailableError({
providerID: model.providerID,
modelID: model.id,
variant: variantID,
}),
)
return Effect.succeed(
variant
? produce(model, (draft) => {
draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings)
draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers)
draft.body = ProviderV2.mergeOverlay(draft.body, variant.body)
})
: model,
)
}
export interface Dependencies {
readonly loadPackage?: (specifier: string) => Effect.Effect<ProviderV2.ProviderPackage, ProviderV2.LoadError>
readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect<Model, AISDK.InitError>
}
export const fromCatalogModel = (
model: ModelV2.Info,
credential?: Credential.Value,
dependencies: Dependencies = {},
): Effect.Effect<Model, UnsupportedPackageError> => {
const resolved = produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata)
})
const packageName = ProviderV2.packageName(resolved.package)
const key = apiKey(resolved, credential)
if (OpenAICodex.isChatGPT(credential) && !ProviderV2.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
return Effect.succeed(codexModel(resolved, credential, key))
}
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
return Effect.succeed(
withDefaults(resolved, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (
ProviderV2.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
return Effect.succeed(
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (ProviderV2.isAISDK(resolved.package)) {
if (!dependencies.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = ProviderV2.mergeOverlay(draft.settings, {
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
...credential?.metadata,
})
})
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!resolved.package) return Effect.fail(unsupported(resolved))
const specifier = resolved.package
return Effect.gen(function* () {
const module = yield* (dependencies.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const configured = { ...resolved.settings, ...credential?.metadata }
const settings = {
...(credential ? withoutNativeAuthSettings(configured) : configured),
...nativeCredentialSettings(specifier, credential),
headers: resolved.headers,
body: resolved.body,
limits: { context: resolved.limit.context, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return Model.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? { ...runtime.compatibility, ...resolved.compatibility }
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
})
})
}
const isNativeOpenAI = (packageName: string | undefined) =>
packageName === "@opencode-ai/ai/providers/openai" ||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
if (!credential) return {}
if (credential.type === "key") return { apiKey: credential.key }
if (
specifier === "@opencode-ai/ai/providers/anthropic" ||
specifier === "@opencode-ai/ai/providers/anthropic-compatible"
)
return { authToken: credential.access }
if (
specifier === "@opencode-ai/ai/providers/google-vertex" ||
specifier.startsWith("@opencode-ai/ai/providers/google-vertex/")
)
return { accessToken: credential.access }
return { apiKey: credential.access }
}
const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
return rest
}
const codexModel = (
model: ModelV2.Info,
credential: Credential.Value | undefined,
key: ReturnType<typeof Auth.value> | undefined,
) => {
const account = OpenAICodex.accountID(credential)
return withDefaults(model, OpenAIResponses.route)
.with({
endpoint: { baseURL: OpenAICodex.baseURL },
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
),
})
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
}
const unsupported = (model: ModelV2.Info) =>
new UnsupportedPackageError({
providerID: model.providerID,
modelID: model.id,
package: model.package ?? "unknown",
})
export const resolve = (
session: SessionSchema.Info,
model: ModelV2.Info,
credential?: Credential.Value,
dependencies?: Dependencies,
) =>
withVariant(model, session.model?.variant).pipe(
Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)),
)
export const supported = (model: ModelV2.Info) => Boolean(model.package)
/** Resolves models from the catalog belonging to the current Location runtime. */
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const integrations = yield* Integration.Service
const npm = yield* Npm.Service
const aisdk = yield* AISDK.Service
const resolver = yield* ModelResolver.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
// Location plugins populate and filter the catalog asynchronously during layer startup.
const defaultModel = session.model ? undefined : yield* catalog.model.default()
const selected = session.model
? (yield* catalog.model.available()).find(
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
)
: defaultModel && supported(defaultModel)
? defaultModel
: (yield* catalog.model.available()).find(supported)
if (!selected && session.model)
if (!session.model) {
const resolved = yield* resolver.resolve()
if (resolved) return resolved
return yield* new ModelNotSelectedError({ sessionID: session.id })
}
const selected = (yield* catalog.model.available()).find(
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
)
if (!selected)
return yield* new ModelUnavailableError({
providerID: session.model.providerID,
modelID: session.model.id,
})
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
)
const model = yield* resolve(
session,
selected,
connection ? yield* integrations.connection.resolve(connection) : undefined,
{
loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm),
loadAISDK: (model) => aisdk.model(model),
},
)
return {
model,
ref: ModelV2.Ref.make({
id: selected.id,
providerID: selected.providerID,
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
}),
capabilities: selected.capabilities,
cost: selected.cost,
}
return yield* resolver.resolveModel(selected, session.model.variant)
}),
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
})
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, ModelResolver.node] })
+5 -2
View File
@@ -4,7 +4,10 @@ import { Schema, SchemaGetter } from "effect"
import { PositiveInt } from "../../schema"
import { ConfigPermissionV1 } from "./permission"
const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
const Color = Schema.Union([
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
const AgentSchema = Schema.StructWithRest(
Schema.Struct({
@@ -26,7 +29,7 @@ const AgentSchema = Schema.StructWithRest(
}),
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
color: Schema.optional(Color).annotate({
description: "Hex color code (e.g., #FF5733)",
description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
}),
steps: Schema.optional(PositiveInt).annotate({
description: "Maximum number of agentic iterations before forcing text-only response",
+1 -1
View File
@@ -161,7 +161,7 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
description: info.description,
mode: info.mode,
hidden: info.hidden,
color: info.color,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(info.permission),
+1
View File
@@ -59,6 +59,7 @@ describe("CommandV2", () => {
providerID: ProviderV2.ID.make("anthropic"),
variant: ModelV2.VariantID.make("high"),
},
source: "command",
}),
])
}),
+3 -2
View File
@@ -95,9 +95,10 @@ Review files`,
variant: ModelV2.VariantID.make("high"),
},
subtask: true,
source: "command",
}),
CommandV2.Info.make({ name: "empty", template: "" }),
CommandV2.Info.make({ name: "nested/docs", template: "Write docs" }),
CommandV2.Info.make({ name: "empty", template: "", source: "command" }),
CommandV2.Info.make({ name: "nested/docs", template: "Write docs", source: "command" }),
])
yield* Effect.promise(() => fs.writeFile(path.join(tmp.path, "commands", "review.md"), "Review again"))
+111
View File
@@ -0,0 +1,111 @@
import { expect } from "bun:test"
import { LLMClient, LLMEvent, LLMResponse, Model } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { AISDK } from "@opencode-ai/core/aisdk"
import { Catalog } from "@opencode-ai/core/catalog"
import { Generate } from "@opencode-ai/core/generate"
import { Integration } from "@opencode-ai/core/integration"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Npm } from "@opencode-ai/util/npm"
import { Effect, Layer, Stream } from "effect"
import { testEffect } from "./lib/effect"
const selected = ModelV2.Info.make({
...ModelV2.Info.empty(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("gemini")),
package: ProviderV2.aisdk("@ai-sdk/google"),
})
const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
const catalog = Layer.mock(Catalog.Service, {
provider: {
get: () => Effect.succeed(undefined),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
},
model: {
get: () => Effect.succeed(selected),
all: () => Effect.die("unused"),
available: () => Effect.die("unused"),
default: () => Effect.die("unused"),
small: () => Effect.die("unused"),
},
})
const integrations = Layer.mock(Integration.Service, {
connection: {
active: () => Effect.succeed(undefined),
resolve: () => Effect.die("unused"),
key: () => Effect.die("unused"),
update: () => Effect.die("unused"),
remove: () => Effect.die("unused"),
},
oauth: {
connect: () => Effect.die("unused"),
status: () => Effect.die("unused"),
complete: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
},
command: {
connect: () => Effect.die("unused"),
status: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
},
})
const npm = Layer.mock(Npm.Service, {
add: () => Effect.die("unused"),
install: () => Effect.die("unused"),
which: () => Effect.die("unused"),
})
const aisdk = Layer.mock(AISDK.Service, {
hook: {
sdk: () => Effect.die("unused"),
language: () => Effect.die("unused"),
},
model: () => Effect.succeed(runtime),
})
const client = Layer.mock(LLMClient.Service)({
prepare: () => Effect.die("unused"),
stream: () => Stream.die("unused"),
generate: () =>
Effect.sync(() => {
const response = LLMResponse.fromEvents([
LLMEvent.textStart({ id: "generate" }),
LLMEvent.textDelta({ id: "generate", text: "OK" }),
LLMEvent.textEnd({ id: "generate" }),
LLMEvent.finish({ reason: "stop" }),
])
if (!response) throw new Error("Incomplete generate response")
return response
}),
})
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client))))
const resolverIt = testEffect(resolver)
it.effect("loads dynamic AI SDK models", () =>
Effect.gen(function* () {
const generate = yield* Generate.Service
const result = yield* generate.text({
prompt: "Return exactly OK",
model: ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }),
})
expect(result).toBe("OK")
}),
)
resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
const result = yield* resolver.resolve(ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }))
expect(result).toEqual({
model: runtime,
ref: ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }),
capabilities: selected.capabilities,
cost: selected.cost,
})
}),
)
@@ -1,17 +1,13 @@
import { describe, expect } from "bun:test"
import { LLM, Model } from "@opencode-ai/ai"
import { LLMClient } from "@opencode-ai/ai/route"
import { DateTime, Effect } from "effect"
import { Money } from "@opencode-ai/schema/money"
import { Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Credential } from "@opencode-ai/core/credential"
import { Integration } from "@opencode-ai/core/integration"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionV2 } from "@opencode-ai/core/session"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ModelResolver } from "@opencode-ai/core/model-resolver"
import { it } from "./lib/effect"
interface ModelOptions {
@@ -43,13 +39,13 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
limit: { context: 100, output: 20 },
})
describe("SessionRunnerModel", () => {
describe("ModelResolver", () => {
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
})
const resolved = yield* SessionRunnerModel.fromCatalogModel(catalog)
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
expect(catalog.id).toBe(ModelV2.ID.make("test-model"))
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
@@ -68,7 +64,7 @@ describe("SessionRunnerModel", () => {
it.effect("keeps catalog apiKey credentials out of provider JSON", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "secret", baseURL: "https://openai.example/v1" },
}),
@@ -82,7 +78,7 @@ describe("SessionRunnerModel", () => {
it.effect("treats an empty configured API key as omitted", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
}),
@@ -101,7 +97,7 @@ describe("SessionRunnerModel", () => {
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
compatibility: { reasoningField: "vendor_reasoning" },
settings: {
@@ -130,7 +126,7 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("overlays selected OpenAI Session variant settings and bodies", () =>
it.effect("overlays selected OpenAI variant settings and bodies", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
@@ -147,22 +143,7 @@ describe("SessionRunnerModel", () => {
},
],
})
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_model_variant"),
projectID: ProjectV2.ID.global,
title: "test",
model: {
id: catalog.id,
providerID: catalog.providerID,
variant: ModelV2.VariantID.make("high"),
},
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high"))
expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
expect(resolved.route.defaults.http?.body).toEqual({
@@ -177,7 +158,7 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("overlays selected OpenAI-compatible Session variant bodies", () =>
it.effect("overlays selected OpenAI-compatible variant bodies", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
settings: { baseURL: "https://compatible.example/v1" },
@@ -190,18 +171,7 @@ describe("SessionRunnerModel", () => {
},
],
})
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_compatible_variant"),
projectID: ProjectV2.ID.global,
title: "test",
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high"))
expect(resolved.route.defaults.http?.body).toEqual({
custom_extension: { enabled: true },
@@ -211,27 +181,12 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("rejects an explicit unavailable Session variant during model resolution", () =>
it.effect("rejects an explicit unavailable variant during model resolution", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
})
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_model_variant_unavailable"),
projectID: ProjectV2.ID.global,
title: "test",
model: {
id: catalog.id,
providerID: catalog.providerID,
variant: ModelV2.VariantID.make("unknown"),
},
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const failure = yield* SessionRunnerModel.resolve(session, catalog).pipe(Effect.flip)
const failure = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("unknown")).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.VariantUnavailableError",
@@ -243,7 +198,7 @@ describe("SessionRunnerModel", () => {
}),
)
it.effect("overlays selected Anthropic Session variant settings", () =>
it.effect("overlays selected Anthropic variant settings", () =>
Effect.gen(function* () {
const catalog = model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
settings: { baseURL: "https://anthropic.example/v1" },
@@ -256,18 +211,7 @@ describe("SessionRunnerModel", () => {
},
],
})
const session = SessionV2.Info.make({
id: SessionV2.ID.make("ses_anthropic_variant"),
projectID: ProjectV2.ID.global,
title: "test",
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: { directory: AbsolutePath.make("/project") },
})
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high"))
expect(resolved.route.defaults.http?.body).toEqual({
custom_extension: { enabled: true },
@@ -280,7 +224,7 @@ describe("SessionRunnerModel", () => {
it.effect("maps catalog Anthropic AI SDK models into native routes", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
settings: { baseURL: "https://anthropic.example/v1" },
}),
@@ -296,7 +240,7 @@ describe("SessionRunnerModel", () => {
it.effect("uses resolved credentials for bearer auth", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
@@ -320,7 +264,7 @@ describe("SessionRunnerModel", () => {
it.effect("prefers stored credentials over configured auth", () =>
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { apiKey: "configured-secret", baseURL: "https://openai.example/v1" },
headers: {},
@@ -343,7 +287,7 @@ describe("SessionRunnerModel", () => {
it.effect("does not project OAuth account metadata into the request body", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
@@ -365,7 +309,7 @@ describe("SessionRunnerModel", () => {
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
@@ -400,7 +344,7 @@ describe("SessionRunnerModel", () => {
it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/openai", {
settings: { baseURL: "https://openai.example/v1" },
}),
@@ -429,7 +373,7 @@ describe("SessionRunnerModel", () => {
it.effect("does not route native OpenAI-compatible packages to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/openai-compatible", {
settings: { baseURL: "https://compatible.example/v1" },
}),
@@ -450,7 +394,7 @@ describe("SessionRunnerModel", () => {
it.effect("maps legacy OpenAI organization and project settings to headers", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { organization: "org_123", project: "proj_123" },
}),
@@ -465,7 +409,7 @@ describe("SessionRunnerModel", () => {
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
@@ -496,7 +440,7 @@ describe("SessionRunnerModel", () => {
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
Effect.gen(function* () {
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
headers: {},
@@ -528,12 +472,12 @@ describe("SessionRunnerModel", () => {
it.effect("loads dynamic native provider packages through the injected package loader", () =>
Effect.gen(function* () {
const native = yield* SessionRunnerModel.fromCatalogModel(
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model("@opencode-ai/ai/providers/custom", {
settings: { region: "test" },
headers: { "x-package": "header" },
@@ -565,7 +509,7 @@ describe("SessionRunnerModel", () => {
it.effect("maps OAuth credentials to native provider auth settings", () =>
Effect.gen(function* () {
const native = yield* SessionRunnerModel.fromCatalogModel(
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
@@ -588,7 +532,7 @@ describe("SessionRunnerModel", () => {
] as const
yield* Effect.forEach(packages, ([specifier, key]) =>
SessionRunnerModel.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, {
ModelResolver.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, {
loadPackage: () =>
Effect.succeed({
model: (modelID, settings) => {
@@ -604,12 +548,12 @@ describe("SessionRunnerModel", () => {
it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
Effect.gen(function* () {
const native = yield* SessionRunnerModel.fromCatalogModel(
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
const resolved = yield* SessionRunnerModel.fromCatalogModel(
const resolved = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/google"), {
modelID: "gemini-api-model",
settings: { project: "test" },
@@ -644,7 +588,7 @@ describe("SessionRunnerModel", () => {
it.effect("rejects AISDK packages without an available loader", () =>
Effect.gen(function* () {
const failure = yield* SessionRunnerModel.fromCatalogModel(
const failure = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://google.example/v1" },
}),
@@ -662,12 +606,12 @@ describe("SessionRunnerModel", () => {
it.effect("drops an empty API key before loading an AISDK package", () =>
Effect.gen(function* () {
const native = yield* SessionRunnerModel.fromCatalogModel(
const native = yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
}),
)
yield* SessionRunnerModel.fromCatalogModel(
yield* ModelResolver.fromCatalogModel(
model(ProviderV2.aisdk("@ai-sdk/google"), {
settings: { apiKey: "", baseURL: "https://google.example/v1" },
}),
@@ -685,9 +629,9 @@ describe("SessionRunnerModel", () => {
it.effect("reports whether a catalog model declares a provider package", () =>
Effect.sync(() => {
expect(SessionRunnerModel.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true)
expect(SessionRunnerModel.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true)
expect(SessionRunnerModel.supported(model(undefined))).toBe(false)
expect(ModelResolver.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true)
expect(ModelResolver.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true)
expect(ModelResolver.supported(model(undefined))).toBe(false)
}),
)
})
+9 -8
View File
@@ -49,14 +49,15 @@ const client = Layer.mock(LLMClient.Service)({
generate: () => Effect.die("unused"),
})
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
})
const locations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
+9 -8
View File
@@ -66,14 +66,15 @@ const client = Layer.mock(LLMClient.Service)({
return response
}),
})
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
})
const builtins = Layer.mock(InstructionBuiltIns.Service, {
load: () =>
Effect.succeed(
@@ -73,14 +73,15 @@ const model = OpenAIChat.route
generation: { maxTokens: 20, temperature: 0 },
})
.model({ id: "gpt-4o-mini" })
const models = SessionRunnerModel.layerWith(() =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
)
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: () =>
Effect.succeed(
SessionRunnerModel.resolved(model, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
}),
),
})
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
+11 -10
View File
@@ -280,17 +280,18 @@ const echo = Layer.effectDiscard(
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
let modelResolveHook = Effect.void
let currentModel = model
const models = SessionRunnerModel.layerWith((session) =>
modelResolveHook.pipe(
Effect.as(
SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
variant: session.model?.variant,
}),
const models = Layer.mock(SessionRunnerModel.Service)({
resolve: (session) =>
modelResolveHook.pipe(
Effect.as(
SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
cost: [],
variant: session.model?.variant,
}),
),
),
),
)
})
const systemContextKey = Instructions.Key.make("test/context")
let systemBaseline = "Initial context"
let systemRemoved = false
+2 -2
View File
@@ -88,8 +88,8 @@ describe("search tools", () => {
expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
expect(glob.output?.content).toEqual([{ type: "text", text: glob.result.value }])
expect(grep.output?.content).toEqual([{ type: "text", text: grep.result.value }])
expect(glob.output?.content).toEqual([{ type: "text", text: String(glob.result.value) }])
expect(grep.output?.content).toEqual([{ type: "text", text: String(grep.result.value) }])
expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
}),
+3
View File
@@ -30,6 +30,7 @@ import { CredentialGroup } from "./groups/credential.js"
import { ProjectGroup } from "./groups/project.js"
import { ProjectCopyGroup } from "./groups/project-copy.js"
import { VcsGroup } from "./groups/vcs.js"
import { PathGroup } from "./groups/path.js"
type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof LocationGroup, LocationId>
@@ -50,6 +51,7 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ProjectCopyGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof PathGroup, LocationId>
type SessionGroups<SessionLocationId extends HttpApiMiddleware.AnyId, SessionLocationService> =
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService>>
@@ -168,6 +170,7 @@ const makeApiFromGroup = <
.add(ReferenceGroup.middleware(locationMiddleware))
.add(ProjectCopyGroup.middleware(locationMiddleware))
.add(VcsGroup.middleware(locationMiddleware))
.add(PathGroup.middleware(locationMiddleware))
.add(DebugGroup)
.annotateMerge(
OpenApi.annotations({
+1
View File
@@ -60,6 +60,7 @@ export const groupNames = {
"server.project": "project",
"server.projectCopy": "projectCopy",
"server.vcs": "vcs",
"server.path": "path",
} as const
export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"])
+20
View File
@@ -0,0 +1,20 @@
import { Path } from "@opencode-ai/schema/path"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const PathGroup = HttpApiGroup.make("server.path")
.add(
HttpApiEndpoint.get("path.get", "/api/path", {
query: LocationQuery,
success: Path.Info,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "v2.path.get",
summary: "Get paths",
description: "Get process and location paths.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "path", description: "Location and process paths." }))
+1
View File
@@ -16,6 +16,7 @@ export const Info = Schema.Struct({
agent: Agent.ID.pipe(optional),
model: Model.Ref.pipe(optional),
subtask: Schema.Boolean.pipe(optional),
source: Schema.Literals(["command", "mcp"]).pipe(optional),
}).annotate({ identifier: "Command.Info" })
export const Event = {
+1
View File
@@ -14,6 +14,7 @@ export { Model } from "./model.js"
export { Money } from "./money.js"
export { Permission } from "./permission.js"
export { PermissionSaved } from "./permission-saved.js"
export { Path } from "./path.js"
export { Project } from "./project.js"
export { ProjectCopy } from "./project-copy.js"
export { Provider } from "./provider.js"
+13
View File
@@ -0,0 +1,13 @@
export * as Path from "./path.js"
import { Schema } from "effect"
import { AbsolutePath } from "./schema.js"
export const Info = Schema.Struct({
home: AbsolutePath,
state: AbsolutePath,
config: AbsolutePath,
worktree: AbsolutePath,
directory: AbsolutePath,
}).annotate({ identifier: "Path.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
+2
View File
@@ -26,6 +26,7 @@ import { CredentialHandler } from "./handlers/credential"
import { ProjectHandler } from "./handlers/project"
import { ProjectCopyHandler } from "./handlers/project-copy"
import { VcsHandler } from "./handlers/vcs"
import { PathHandler } from "./handlers/path"
import { EventFeed } from "./event-feed"
export const handlers = Layer.mergeAll(
@@ -56,4 +57,5 @@ export const handlers = Layer.mergeAll(
ReferenceHandler,
ProjectCopyHandler,
VcsHandler,
PathHandler,
)
+22
View File
@@ -0,0 +1,22 @@
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
export const PathHandler = HttpApiBuilder.group(Api, "server.path", (handlers) =>
handlers.handle("path.get", () =>
Effect.gen(function* () {
const global = yield* Global.Service
const location = yield* Location.Service
return {
home: AbsolutePath.make(global.home),
state: AbsolutePath.make(global.state),
config: AbsolutePath.make(global.config),
worktree: location.project.directory,
directory: location.directory,
}
}),
),
)
+4 -1
View File
@@ -60,6 +60,7 @@ const applicationServices = LayerNode.group([
WellKnown.node,
PtyEnvironment.node,
LocationServiceMap.node,
Global.node,
SessionRestart.node,
])
@@ -134,7 +135,9 @@ function makeRoutes<AuthError, AuthServices>(
Layer.flatMap((context) => {
const services = Layer.succeedContext(context)
const requestServices = Layer.merge(
Layer.succeedContext(Context.pick(PermissionSaved.Service, Project.Service, WellKnown.Service)(context)),
Layer.succeedContext(
Context.pick(Global.Service, PermissionSaved.Service, Project.Service, WellKnown.Service)(context),
),
ServerInfo.layer(serviceURLs, options.app),
)
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
+2 -4
View File
@@ -4,10 +4,8 @@ import {
DEFAULT_THEMES,
addTheme,
allThemes,
generateSyntax,
hasTheme,
isTheme,
resolveTheme,
selectedForeground,
setCustomThemes,
setSystemTheme,
@@ -16,6 +14,7 @@ import {
type Theme,
type ThemeJson,
} from "../theme"
import { generateSyntax } from "../theme/v2/syntax"
import { generateSystem, terminalMode } from "../theme/system"
import { discoverThemes, themeDirectories } from "../theme/discovery"
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
@@ -279,7 +278,6 @@ const themeContext = createSimpleContext({
if (supported.includes(store.mode)) return store.mode
return supported[0] ?? store.mode
}
const legacySyntaxTheme = createMemo(() => resolveTheme(source(), mode()))
const valuesV2 = createMemo(() => resolveThemeFile(file(), mode(), sourceName()))
valuesV2()
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
@@ -299,7 +297,7 @@ const themeContext = createSimpleContext({
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
const syntax = createSyntaxStyleMemo(() => generateSyntax(legacySyntaxTheme()))
const syntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
function contextual(context: ContextName) {
return contextualServices[context]
}
+93
View File
@@ -0,0 +1,93 @@
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
import type { Mode, ResolvedThemeView } from "./index"
export function generateSyntax(theme: ResolvedThemeView, mode: Mode) {
const step = mode === "light" ? 800 : 200
const syntax = theme.syntax
const markdown = theme.markdown
const feedback = theme.text.feedback
return SyntaxStyle.fromTheme([
rule(["default"], theme.text.default),
rule(["prompt"], theme.hue.accent[step]),
rule(["extmark.file"], feedback.warning.default, { bold: true }),
rule(["extmark.agent"], theme.categorical[0][step], { bold: true }),
// V1 migration preserves its selected/inverse foreground in this action state.
rule(["extmark.paste"], theme.text.action.primary.focused, {
background: feedback.warning.default,
bold: true,
}),
rule(["comment", "comment.documentation"], syntax.comment, { italic: true }),
rule(["string", "symbol", "character.special", "character"], syntax.string),
rule(["number", "boolean", "constant", "float"], syntax.number),
rule(["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], syntax.keyword, {
italic: true,
}),
rule(["keyword.type"], syntax.type, { bold: true, italic: true }),
rule(["keyword.function", "function.method"], syntax.function),
rule(["keyword"], syntax.keyword, { italic: true }),
rule(["keyword.import", "string.escape", "string.regexp", "tag.attribute", "keyword.export"], syntax.keyword),
rule(["operator", "keyword.operator", "punctuation.delimiter", "keyword.conditional.ternary"], syntax.operator),
rule(
["variable", "variable.parameter", "function.method.call", "function.call", "property", "parameter", "field"],
syntax.variable,
),
rule(["variable.member", "function", "constructor"], syntax.function),
rule(["type", "module", "class", "namespace"], syntax.type),
rule(["type.definition"], syntax.type, { bold: true }),
rule(["punctuation", "punctuation.bracket"], syntax.punctuation),
rule(
["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin", "variable.super"],
feedback.error.default,
),
rule(["keyword.directive", "keyword.modifier", "keyword.exception"], syntax.keyword, { italic: true }),
rule(["punctuation.special", "tag.delimiter"], syntax.operator),
rule(
[
"markup.heading",
"markup.heading.2",
"markup.heading.3",
"markup.heading.4",
"markup.heading.5",
"markup.heading.6",
],
markdown.heading,
{ bold: true },
),
rule(["markup.heading.1"], markdown.heading, { bold: true, underline: true }),
rule(["markup.bold", "markup.strong"], markdown.strong, { bold: true }),
rule(["markup.italic"], markdown.emphasis, { italic: true }),
rule(["markup.list"], markdown.listItem),
rule(["markup.quote"], markdown.blockQuote, { italic: true }),
rule(["markup.raw", "markup.raw.block"], markdown.code),
rule(["markup.raw.inline"], markdown.code, { background: theme.background.default }),
rule(["markup.link", "markup.link.url", "string.special", "string.special.url"], markdown.link, {
underline: true,
}),
rule(["markup.link.label"], markdown.linkText, { underline: true }),
rule(["label"], markdown.linkText),
rule(["spell", "nospell"], theme.text.default),
rule(["markup.underline"], theme.text.default, { underline: true }),
rule(["comment.error"], feedback.error.default, { italic: true, bold: true }),
rule(["comment.warning"], feedback.warning.default, { italic: true, bold: true }),
rule(["comment.todo", "comment.note"], feedback.info.default, { italic: true, bold: true }),
rule(["attribute", "annotation"], feedback.warning.default),
rule(["tag"], feedback.error.default),
rule(["markup.strikethrough", "markup.list.unchecked", "debug"], theme.text.subdued),
rule(["markup.list.checked"], feedback.success.default),
rule(["diff.plus"], theme.diff.text.added, { background: theme.diff.background.added }),
rule(["diff.minus"], theme.diff.text.removed, { background: theme.diff.background.removed }),
rule(["diff.delta"], theme.diff.text.context, { background: theme.diff.background.context }),
rule(["error"], feedback.error.default, { bold: true }),
rule(["warning"], feedback.warning.default, { bold: true }),
rule(["info"], feedback.info.default),
])
}
function rule(
scope: string[],
foreground: RGBA,
style: Omit<ThemeTokenStyle["style"], "foreground"> = {},
): ThemeTokenStyle {
return { scope, style: { foreground, ...style } }
}