diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b486b68a93..1ae28ea874 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -97,6 +97,11 @@ jobs: working-directory: packages/client run: bun run check:generated + - name: Check generated documentation + if: runner.os == 'Linux' + working-directory: packages/docs + run: bun run check:generated + e2e: name: e2e (${{ matrix.settings.name }}) if: github.ref_name != 'v2' && github.head_ref != 'v2' diff --git a/bun.lock b/bun.lock index 3ea6f31f5d..24e3c97c4e 100644 --- a/bun.lock +++ b/bun.lock @@ -124,7 +124,7 @@ "opencode2": "./bin/opencode2.cjs", }, "dependencies": { - "@agentclientprotocol/sdk": "0.21.0", + "@agentclientprotocol/sdk": "1.2.1", "@effect/platform-node": "catalog:", "@opencode-ai/client": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -362,7 +362,7 @@ "@aws-sdk/credential-providers": "3.1057.0", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", - "@ff-labs/fff-bun": "0.9.4", + "@ff-labs/fff-bun": "0.10.1", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", "@opencode-ai/ai": "workspace:*", @@ -1173,7 +1173,7 @@ "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], - "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="], + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.2.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA=="], "@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="], @@ -1641,23 +1641,25 @@ "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], - "@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xyivu2xB++O5xXDx5Qm50JsU2aXt8YgXlGVhH/HE7UMYDrE6L6f1RYdYs8Y0bn0D3D0+bFBrN5ELPszK9E4Wbw=="], + "@ff-labs/fff-bin-android-arm64": ["@ff-labs/fff-bin-android-arm64@0.10.1", "", { "os": "android", "cpu": "arm64" }, "sha512-6Bsaa6yKEd2HV1M2WtqSYhoZucKYffIUms6GYoPN48QHP8hZgO8GXJ85/JDxKlkCJsN2hw1ROiwQK0+xUHYhFQ=="], - "@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xLooAhCnTDCipPSMMZz7kGF3lhRHx6aP5fb6DJ0Ipyw/w/UWJb+xITJFszUl/QnIBoJ/qjDc93/FZMo1dk6gVA=="], + "@ff-labs/fff-bin-darwin-arm64": ["@ff-labs/fff-bin-darwin-arm64@0.10.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7yUP+56sG3UTrLg7eepOD16yM2dgiD7g6Ase2XWQB7oXwLV7mBMylPKIli2pP3kHzfw9K+BS3WAwHKHJ2QhxYw=="], - "@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-m5+8vA+1veaUUWonwva1WsU6m1HRm8CpYUzr06KDB65mewlmPbqz7+Fh7hjEfiD8C4mHVHe6RysULvAH1yhsdw=="], + "@ff-labs/fff-bin-darwin-x64": ["@ff-labs/fff-bin-darwin-x64@0.10.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-9zb+P1xtyqu/jVklm5RKF2zm9doRRsBNbkF/a8S4aSqSJoNlQR8ZF7C129fzOLfffVAjjgcO2l8oJgxOzHYiwQ=="], - "@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-EMeWm7CSTVkizy4ZEzUkLDP024tVcbCUthduuIhekFQRDsiaAze0YboIylWb9HBHJCZlCCoZrWAl4nnJbsX7AA=="], + "@ff-labs/fff-bin-linux-arm64-gnu": ["@ff-labs/fff-bin-linux-arm64-gnu@0.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-KTwr9CUfCJv0vtG2xG+nMxDae/2NJY2/oVmtgSvTnH9baI6JprqsFGphbukx7mdW/QMPwBiYtoO8ZGoC7i92jA=="], - "@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-pglE0uLkhnlE6bStXqfgUjYTSj+2sVwXaPfoA0QksidAsQor6NRt8004mygzC9DPubgHq5B9QezPfEwigKaP9Q=="], + "@ff-labs/fff-bin-linux-arm64-musl": ["@ff-labs/fff-bin-linux-arm64-musl@0.10.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-oznmSpV+zjiAPiwqbA4y+SAsMaYvDhGllHiT9L4ebdQnSOfcQzlUwvo45OhBPwHBt47tIois4bkF/MITlDk54A=="], - "@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-VNKxgl8qs3aTfXViX7lqRK1aLu311h8dtBFqG4Scv+9Oi7WprybUp5L7IZ8sxKERaDAaiJMXHodXa1c90QdK8w=="], + "@ff-labs/fff-bin-linux-x64-gnu": ["@ff-labs/fff-bin-linux-x64-gnu@0.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-KDpl8lwSEOauP/6FSJIvnARzE+ILm2rVIRQAio9dc5nn56EvlsryBWry0c5V0Tbw1PV0lNrZ+bzcIiPuTacvzw=="], - "@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-uFEt0aNL54vQxq1ivjxRuo+thnhS4wLqa4INl4VXnXJUmwB42XXxD+gsj7vzhBLLx4cFf0aWgy/+TVDR8yjZtQ=="], + "@ff-labs/fff-bin-linux-x64-musl": ["@ff-labs/fff-bin-linux-x64-musl@0.10.1", "", { "os": "linux", "cpu": "x64" }, "sha512-mZCpojVtGNDr/wdCUEAHdfrl6qORvKHv3Pw35TaOdshnG4wocoA5bBKTGj/zmAU11o0GMK4V+wUhdNrgqoE10w=="], - "@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Yd2Eyxj+slWv+0QDW9/xBpu9FXq+hwD0rXQD5184/88d+xwWCLKhEP2w8I6OO9XCg+kLT79UJb+k0WwXUtBtMw=="], + "@ff-labs/fff-bin-win32-arm64": ["@ff-labs/fff-bin-win32-arm64@0.10.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-SbT76ETXC5AgV9J8sVDozKG8wzrrxsOn8lbBprlOtx+O5V5EYmS1W7BlsatTHy6ddQ3uU5oOYO04PWZBuP6wXg=="], - "@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.9.4", "", { "optionalDependencies": { "@ff-labs/fff-bin-darwin-arm64": "0.9.4", "@ff-labs/fff-bin-darwin-x64": "0.9.4", "@ff-labs/fff-bin-linux-arm64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-arm64-musl": "0.9.4", "@ff-labs/fff-bin-linux-x64-gnu": "0.9.4", "@ff-labs/fff-bin-linux-x64-musl": "0.9.4", "@ff-labs/fff-bin-win32-arm64": "0.9.4", "@ff-labs/fff-bin-win32-x64": "0.9.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ] }, "sha512-7HUraaK/g5dStAnuKAuzsXVOQvqqX0ylo5G+DxYwsCjCDc42bjoEAAHqz/3Sn3raUNw97KMoz87XR9QyrLEfVw=="], + "@ff-labs/fff-bin-win32-x64": ["@ff-labs/fff-bin-win32-x64@0.10.1", "", { "os": "win32", "cpu": "x64" }, "sha512-dcpHUCBEZoXKQCdKa3bADfgcNyeyfM8tatXrILqIFYi9GL8E4GnzKx1rGkgP5ukVtuj0WuoJyyqSvGjpJPIT8w=="], + + "@ff-labs/fff-bun": ["@ff-labs/fff-bun@0.10.1", "", { "optionalDependencies": { "@ff-labs/fff-bin-android-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-arm64": "0.10.1", "@ff-labs/fff-bin-darwin-x64": "0.10.1", "@ff-labs/fff-bin-linux-arm64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-arm64-musl": "0.10.1", "@ff-labs/fff-bin-linux-x64-gnu": "0.10.1", "@ff-labs/fff-bin-linux-x64-musl": "0.10.1", "@ff-labs/fff-bin-win32-arm64": "0.10.1", "@ff-labs/fff-bin-win32-x64": "0.10.1" }, "os": [ "!aix", "!sunos", "!freebsd", "!openbsd", ], "cpu": [ "x64", "arm64", ] }, "sha512-9oUCxypGbf2q3vNfKZ31wdzt5KqjhA9S6TwQaFol/j1lkSHVGvtIa3RvdGHPs1UUuvR/MO7b6pHj054BR9bXPQ=="], "@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="], diff --git a/package.json b/package.json index 7bb37f946f..2110c4d9af 100644 --- a/package.json +++ b/package.json @@ -155,7 +155,6 @@ "@types/node": "catalog:" }, "patchedDependencies": { - "@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", diff --git a/packages/ai/src/llm.ts b/packages/ai/src/llm.ts index e4781d8608..ecdf30ae47 100644 --- a/packages/ai/src/llm.ts +++ b/packages/ai/src/llm.ts @@ -81,7 +81,7 @@ const GENERATE_OBJECT_TOOL_NAME = "generate_object" const GENERATE_OBJECT_TOOL_DESCRIPTION = "Return the structured result by calling this tool." -type GenerateObjectBase = Omit +type GenerateObjectBase = Omit export class GenerateObjectResponse { constructor( diff --git a/packages/ai/src/protocols/anthropic-messages.ts b/packages/ai/src/protocols/anthropic-messages.ts index f5b9ea67d9..cc03dfa65b 100644 --- a/packages/ai/src/protocols/anthropic-messages.ts +++ b/packages/ai/src/protocols/anthropic-messages.ts @@ -159,7 +159,7 @@ const AnthropicTool = Schema.Struct({ type AnthropicTool = Schema.Schema.Type const AnthropicToolChoice = Schema.Union([ - Schema.Struct({ type: Schema.Literals(["auto", "any"]) }), + Schema.Struct({ type: Schema.Literals(["auto", "any", "none"]) }), Schema.Struct({ type: Schema.tag("tool"), name: Schema.String }), ]) @@ -297,7 +297,7 @@ const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSc const lowerToolChoice = (toolChoice: NonNullable) => ProviderShared.matchToolChoice("Anthropic Messages", toolChoice, { auto: () => ({ type: "auto" as const }), - none: () => undefined, + none: () => ({ type: "none" as const }), required: () => ({ type: "any" as const }), tool: (name) => ({ type: "tool" as const, name }), }) @@ -542,7 +542,6 @@ const outputConfig = (request: LLMRequest) => { } const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) { - const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema const outputLimit = request.model.defaults?.limits?.output ?? request.model.route.defaults.limits?.output ?? 4096 @@ -551,7 +550,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques // over-mark we keep their tool hints and shed the message-tail ones first. const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP) const tools = - request.tools.length === 0 || request.toolChoice?.type === "none" + request.tools.length === 0 ? undefined : request.tools.map((tool) => lowerTool( @@ -560,6 +559,9 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility), ), ) + // Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present. + const toolChoice = + tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice) const system = request.system.length === 0 ? undefined diff --git a/packages/ai/src/protocols/bedrock-converse.ts b/packages/ai/src/protocols/bedrock-converse.ts index 801fb8a98a..16052648a9 100644 --- a/packages/ai/src/protocols/bedrock-converse.ts +++ b/packages/ai/src/protocols/bedrock-converse.ts @@ -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 }, }) } diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 6cba2bc7f9..57ad066037 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -313,7 +313,7 @@ const thinkingConfig = (request: LLMRequest) => { } const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMRequest) { - const toolsEnabled = request.tools.length > 0 && request.toolChoice?.type !== "none" + const hasTools = request.tools.length > 0 const generation = request.generation const toolSchemaCompatibility = request.model.compatibility?.toolSchema const generationConfig = { @@ -329,7 +329,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque contents: yield* lowerMessages(request), systemInstruction: request.system.length === 0 ? undefined : { parts: [{ text: ProviderShared.joinText(request.system) }] }, - tools: toolsEnabled + tools: hasTools ? [ { functionDeclarations: request.tools.map((tool) => @@ -338,7 +338,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque }, ] : undefined, - toolConfig: toolsEnabled && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined, + toolConfig: hasTools && request.toolChoice ? yield* lowerToolConfig(request.toolChoice) : undefined, generationConfig: Object.values(generationConfig).some((value) => value !== undefined) ? generationConfig : undefined, diff --git a/packages/ai/src/protocols/openai-responses.ts b/packages/ai/src/protocols/openai-responses.ts index 87ca8c75e1..2973acf000 100644 --- a/packages/ai/src/protocols/openai-responses.ts +++ b/packages/ai/src/protocols/openai-responses.ts @@ -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" diff --git a/packages/ai/src/schema/events.ts b/packages/ai/src/schema/events.ts index 5be1c4cf90..18454b8470 100644 --- a/packages/ai/src/schema/events.ts +++ b/packages/ai/src/schema/events.ts @@ -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. diff --git a/packages/ai/src/schema/messages.ts b/packages/ai/src/schema/messages.ts index 4a9de3a735..e6617ddc9e 100644 --- a/packages/ai/src/schema/messages.ts +++ b/packages/ai/src/schema/messages.ts @@ -261,13 +261,6 @@ export namespace ToolChoice { } } -export const ResponseFormat = Schema.Union([ - Schema.Struct({ type: Schema.Literal("text") }), - Schema.Struct({ type: Schema.Literal("json"), schema: JsonSchema }), - Schema.Struct({ type: Schema.Literal("tool"), tool: ToolDefinition }), -]).pipe(Schema.toTaggedUnion("type")) -export type ResponseFormat = Schema.Schema.Type - export class LLMRequest extends Schema.Class("LLM.Request")({ id: Schema.optional(Schema.String), model: ModelSchema, @@ -278,7 +271,6 @@ export class LLMRequest extends Schema.Class("LLM.Request")({ generation: Schema.optional(GenerationOptions), providerOptions: Schema.optional(ProviderOptions), http: Schema.optional(HttpOptions), - responseFormat: Schema.optional(ResponseFormat), cache: Schema.optional(CachePolicy), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) {} @@ -296,7 +288,6 @@ export namespace LLMRequest { generation: request.generation, providerOptions: request.providerOptions, http: request.http, - responseFormat: request.responseFormat, cache: request.cache, metadata: request.metadata, }) diff --git a/packages/ai/test/fixtures/recordings/bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call.json b/packages/ai/test/fixtures/recordings/bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call.json new file mode 100644 index 0000000000..8fd307e220 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:bedrock-converse-cache", + "provider:amazon-bedrock", + "protocol:bedrock-converse", + "cache" + ], + "name": "bedrock-converse-cache/writes-then-reads-cachepoint-on-identical-second-call", + "recordedAt": "2026-07-23T02:29:10.955Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Say hi.\"}]}],\"system\":[{\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"},{\"cachePoint\":{\"type\":\"default\"}}],\"inferenceConfig\":{\"maxTokens\":16,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAAiwAAAFImcW4yCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1uIiwicm9sZSI6ImFzc2lzdGFudCJ9uwonDAAAANUAAABX0TjrFws6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJIaS4ifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNCJ9BToCUgAAAJMAAABWcYx2aAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbG1ubyJ9uOXHGAAAALAAAABRaYm2Hws6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVIiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn0SuCAcAAABgQAAAE6znPl5CzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6MTE5OH0sInAiOiJhYmNkZWYiLCJ1c2FnZSI6eyJjYWNoZURldGFpbHMiOlt7ImlucHV0VG9rZW5zIjo1NzUyLCJ0dGwiOiI1bSJ9XSwiY2FjaGVSZWFkSW5wdXRUb2tlbkNvdW50IjowLCJjYWNoZVJlYWRJbnB1dFRva2VucyI6MCwiY2FjaGVXcml0ZUlucHV0VG9rZW5Db3VudCI6NTc1MiwiY2FjaGVXcml0ZUlucHV0VG9rZW5zIjo1NzUyLCJpbnB1dFRva2VucyI6OSwib3V0cHV0VG9rZW5zIjoyLCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6NTc2M319YVPHOQ==", + "bodyEncoding": "base64" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream", + "headers": { + "content-type": "application/json" + }, + "body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"Say hi.\"}]}],\"system\":[{\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. \"},{\"cachePoint\":{\"type\":\"default\"}}],\"inferenceConfig\":{\"maxTokens\":16,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/vnd.amazon.eventstream" + }, + "body": "AAAApgAAAFIfIIWHCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PIiwicm9sZSI6ImFzc2lzdGFudCJ9AcVkFwAAAKkAAABX7Rrm2Qs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiJIaS4ifSwicCI6ImFiY2RlZmdoaWprbG0iffxI0NkAAACiAAAAVu3N514LOmV2ZW50LXR5cGUHABBjb250ZW50QmxvY2tTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0QifWQnKBAAAACFAAAAUQBIgekLOmV2ZW50LXR5cGUHAAttZXNzYWdlU3RvcA06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7InAiOiJhYmNkIiwic3RvcFJlYXNvbiI6ImVuZF90dXJuIn0c+t0FAAABTQAAAE6fefyjCzpldmVudC10eXBlBwAIbWV0YWRhdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJtZXRyaWNzIjp7ImxhdGVuY3lNcyI6OTcwfSwicCI6ImFiY2QiLCJ1c2FnZSI6eyJjYWNoZVJlYWRJbnB1dFRva2VuQ291bnQiOjU3NTIsImNhY2hlUmVhZElucHV0VG9rZW5zIjo1NzUyLCJjYWNoZVdyaXRlSW5wdXRUb2tlbkNvdW50IjowLCJjYWNoZVdyaXRlSW5wdXRUb2tlbnMiOjAsImlucHV0VG9rZW5zIjo5LCJvdXRwdXRUb2tlbnMiOjIsInNlcnZlclRvb2xVc2FnZSI6e30sInRvdGFsVG9rZW5zIjo1NzYzfX0J7IoM", + "bodyEncoding": "base64" + } + } + ] +} diff --git a/packages/ai/test/provider/anthropic-messages.test.ts b/packages/ai/test/provider/anthropic-messages.test.ts index 30b3c1b6b8..da8bb34287 100644 --- a/packages/ai/test/provider/anthropic-messages.test.ts +++ b/packages/ai/test/provider/anthropic-messages.test.ts @@ -235,6 +235,34 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("keeps tools and sends tool_choice none", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + id: "req_tool_choice_none", + model, + tools: [{ name: "lookup", description: "Look things up", inputSchema: { type: "object", properties: {} } }], + messages: [ + Message.user("What is the weather?"), + Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]), + Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }), + ], + toolChoice: "none", + cache: "none", + }), + ) + + expect(prepared.body.tools).toEqual([ + { + name: "lookup", + description: "Look things up", + input_schema: { type: "object", properties: {} }, + }, + ]) + expect(prepared.body.tool_choice).toEqual({ type: "none" }) + }), + ) + // Regression: read tool results must stay structured so base64 media data is // not JSON-stringified into `tool_result.content`. it.effect("lowers media tool-result content as structured blocks", () => diff --git a/packages/ai/test/provider/bedrock-converse-cache.recorded.test.ts b/packages/ai/test/provider/bedrock-converse-cache.recorded.test.ts index 8702e4eb40..8209ab1121 100644 --- a/packages/ai/test/provider/bedrock-converse-cache.recorded.test.ts +++ b/packages/ai/test/provider/bedrock-converse-cache.recorded.test.ts @@ -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), + ) }), ) }) diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts index 59ca67333b..776032966c 100644 --- a/packages/ai/test/provider/bedrock-converse.test.ts +++ b/packages/ai/test/provider/bedrock-converse.test.ts @@ -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( diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 5195d372c9..50b5dbb1d2 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -233,11 +233,11 @@ describe("Gemini route", () => { }), ) - it.effect("omits tools when tool choice is none", () => + it.effect("keeps tools and sends function calling mode NONE", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( LLM.request({ - id: "req_no_tools", + id: "req_tool_choice_none", model, prompt: "Say hello.", tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], @@ -245,8 +245,10 @@ describe("Gemini route", () => { }), ) - expect(prepared.body).toEqual({ + expect(prepared.body).toMatchObject({ contents: [{ role: "user", parts: [{ text: "Say hello." }] }], + tools: [{ functionDeclarations: [{ name: "lookup", description: "Lookup data" }] }], + toolConfig: { functionCallingConfig: { mode: "NONE" } }, }) }), ) diff --git a/packages/ai/test/provider/openai-responses.test.ts b/packages/ai/test/provider/openai-responses.test.ts index 11466a6896..ffbd329434 100644 --- a/packages/ai/test/provider/openai-responses.test.ts +++ b/packages/ai/test/provider/openai-responses.test.ts @@ -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", () => diff --git a/packages/cli/package.json b/packages/cli/package.json index acf8ec75dd..f848b56279 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -22,7 +22,7 @@ "typecheck": "tsgo --noEmit" }, "dependencies": { - "@agentclientprotocol/sdk": "0.21.0", + "@agentclientprotocol/sdk": "1.2.1", "@effect/platform-node": "catalog:", "@opencode-ai/client": "workspace:*", "@opencode-ai/plugin": "workspace:*", diff --git a/packages/cli/src/acp/agent.ts b/packages/cli/src/acp/agent.ts index cf8eb693f7..c01ec94018 100644 --- a/packages/cli/src/acp/agent.ts +++ b/packages/cli/src/acp/agent.ts @@ -5,6 +5,7 @@ import { type AuthenticateRequest, type CancelNotification, type CloseSessionRequest, + type DeleteSessionRequest, type ForkSessionRequest, type InitializeRequest, type ListSessionsRequest, @@ -13,7 +14,6 @@ import { type PromptRequest, type ResumeSessionRequest, type SetSessionConfigOptionRequest, - type SetSessionModelRequest, type SetSessionModeRequest, } from "@agentclientprotocol/sdk" import type { OpenCodeClient } from "@opencode-ai/client/promise" @@ -28,12 +28,12 @@ export function create(client: OpenCodeClient, connection: AgentSideConnection) newSession: (params: NewSessionRequest) => run(service.newSession(params)), loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)), listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)), + deleteSession: (params: DeleteSessionRequest) => run(service.deleteSession(params)), resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)), closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)), unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)), setSessionConfigOption: (params: SetSessionConfigOptionRequest) => run(service.setSessionConfigOption(params)), setSessionMode: (params: SetSessionModeRequest) => run(service.setSessionMode(params)), - unstable_setSessionModel: (params: SetSessionModelRequest) => run(service.setSessionModel(params)), prompt: (params: PromptRequest) => run(service.prompt(params)), cancel: (params: CancelNotification) => run(service.cancel(params)), } satisfies Agent diff --git a/packages/cli/src/acp/event.ts b/packages/cli/src/acp/event.ts index da8bfe168b..1551353830 100644 --- a/packages/cli/src/acp/event.ts +++ b/packages/cli/src/acp/event.ts @@ -47,7 +47,7 @@ export async function streamTurn(input: { readonly sessionID: string readonly cwd: string readonly start: TurnStart - readonly userMessageID?: string | null + readonly writeTextFile: boolean readonly submit: (signal: AbortSignal) => Promise readonly control: TurnControl }): Promise { @@ -170,6 +170,7 @@ export async function streamTurn(input: { tools.delete(event.data.callID) await syncEditedFiles({ connection: input.connection, + writeTextFile: input.writeTextFile, sessionID: input.sessionID, cwd: input.cwd, toolName: current.name, @@ -231,7 +232,7 @@ export async function streamTurn(input: { if (!started) { streamController.abort() await completed.catch(() => {}) - return response(undefined, undefined, "interrupted", true, undefined, input.userMessageID) + return response(undefined, undefined, "interrupted", true, undefined) } } const terminal = await completed @@ -246,7 +247,6 @@ export async function streamTurn(input: { terminal, control.cancelled, finish, - input.userMessageID, ) } catch (error) { streamController.abort() @@ -400,7 +400,6 @@ function response( terminal: "succeeded" | "failed" | "interrupted", cancelled: boolean, finish: SessionMessageAssistant["finish"], - messageID: string | null | undefined, ): PromptResponse { const error = assistant?.error ?? executionError if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError() @@ -423,7 +422,7 @@ function response( } : undefined const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type }) - return { stopReason, ...(usage ? { usage } : {}), ...(messageID ? { userMessageId: messageID } : {}), _meta: {} } + return { stopReason, ...(usage ? { usage } : {}), _meta: {} } } function resolveStopReason(input: { diff --git a/packages/cli/src/acp/permission.ts b/packages/cli/src/acp/permission.ts index 71086bf909..3c1d92957e 100644 --- a/packages/cli/src/acp/permission.ts +++ b/packages/cli/src/acp/permission.ts @@ -53,13 +53,14 @@ export async function replyPermission(input: { export async function syncEditedFiles(input: { readonly connection: Partial> + readonly writeTextFile: boolean readonly sessionID: string readonly cwd: string readonly toolName: string readonly toolInput: ToolInput readonly structured: Readonly> }) { - if (!input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return + if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return const files = Array.isArray(input.structured.files) ? input.structured.files.flatMap((file): string[] => { if (!file || typeof file !== "object") return [] diff --git a/packages/cli/src/acp/service.ts b/packages/cli/src/acp/service.ts index f20f31c9bc..963b5c3ed4 100644 --- a/packages/cli/src/acp/service.ts +++ b/packages/cli/src/acp/service.ts @@ -16,6 +16,8 @@ import type { CancelNotification, CloseSessionRequest, CloseSessionResponse, + DeleteSessionRequest, + DeleteSessionResponse, ForkSessionRequest, ForkSessionResponse, InitializeRequest, @@ -33,8 +35,6 @@ import type { ResumeSessionResponse, SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, - SetSessionModelRequest, - SetSessionModelResponse, SetSessionModeRequest, SetSessionModeResponse, } from "@agentclientprotocol/sdk" @@ -47,7 +47,8 @@ import { ACPError } from "./error" export const AuthMethodID = "opencode-login" -type Connection = Pick +type Connection = Pick & + Partial> type Catalog = { readonly providers: ConfigOptionProvider[] @@ -83,12 +84,12 @@ export interface Interface { newSession(input: NewSessionRequest): Promise loadSession(input: LoadSessionRequest): Promise listSessions(input: ListSessionsRequest): Promise + deleteSession(input: DeleteSessionRequest): Promise resumeSession(input: ResumeSessionRequest): Promise closeSession(input: CloseSessionRequest): Promise forkSession(input: ForkSessionRequest): Promise setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise setSessionMode(input: SetSessionModeRequest): Promise - setSessionModel(input: SetSessionModelRequest): Promise prompt(input: PromptRequest): Promise cancel(input: CancelNotification): Promise } @@ -98,6 +99,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti const catalogs = new Map>() const registeredMcp = new Map>() const active = new Map() + const capabilities = { writeTextFile: false } const catalog = (cwd: string) => { const cached = catalogs.get(cwd) @@ -157,6 +159,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti return { initialize: async (params) => { + capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true const authMethod: AuthMethod = { description: "Run `opencode auth login` in the terminal", name: "Login with opencode", @@ -173,7 +176,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti loadSession: true, mcpCapabilities: { http: true, sse: false }, promptCapabilities: { embeddedContext: true, image: true }, - sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} }, + sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} }, }, authMethods: [authMethod], agentInfo: { name: "OpenCode", version: OPENCODE_VERSION }, @@ -216,6 +219,14 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti ...(page.cursor.next ? { nextCursor: page.cursor.next } : {}), } }, + deleteSession: async (params) => { + await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => { + if (!isSessionNotFoundError(error)) throw error + }) + sessions.delete(params.sessionId) + registeredMcp.delete(params.sessionId) + return {} + }, resumeSession: async (params) => { const session = await getSession(input.client, params.sessionId) const state = await attach(session, session.location.directory, params.mcpServers ?? []) @@ -270,13 +281,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti await selectMode(input.client, await requireSession(params.sessionId), params.modeId) return {} }, - setSessionModel: async (params) => { - const state = await requireSession(params.sessionId) - const selected = requireModel(state.catalog, params.modelId) - state.model = selected - await input.client.session.switchModel({ sessionID: state.id, model: selected }) - return {} - }, prompt: async (params) => { const state = await requireSession(params.sessionId) if (active.has(state.id)) { @@ -295,7 +299,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti sessionID: state.id, cwd: state.cwd, start: prepared.start, - userMessageID: params.messageId, + writeTextFile: capabilities.writeTextFile, control, submit: (signal) => submitPrompt(input.client, state, prepared, signal), }).finally(() => { @@ -479,6 +483,7 @@ async function registerMcpServers( function mcpConfig(server: McpServer) { if ("type" in server) { + if (server.type === "acp") throw new Error("MCP-over-ACP is not supported") return { type: "remote" as const, url: server.url, diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index c0b914fc62..a3ae15113f 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -140,11 +140,11 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Flag.withDefault(false), ), replay: Flag.boolean("replay").pipe( - Flag.withDescription("Replay session history on resume and after resize"), - Flag.withDefault(true), + Flag.withDescription("Restore session history on resume and resize (disable with --no-replay)"), + Flag.optional, ), replayLimit: Flag.integer("replay-limit").pipe( - Flag.withDescription("Cap visible replay to the newest N messages"), + Flag.withDescription("Limit replay to the newest N messages (default: 200)"), Flag.optional, ), model: Flag.string("model").pipe( diff --git a/packages/cli/src/commands/handlers/mini.ts b/packages/cli/src/commands/handlers/mini.ts index 4f8a16a350..972c3bf962 100644 --- a/packages/cli/src/commands/handlers/mini.ts +++ b/packages/cli/src/commands/handlers/mini.ts @@ -28,8 +28,8 @@ export default Runtime.handler(Commands.commands.mini, (input) => model: Option.getOrUndefined(input.model), agent: Option.getOrUndefined(input.agent), prompt: Option.getOrUndefined(input.prompt), - replay: input.replay, - replayLimit: Option.getOrUndefined(input.replayLimit), + replay: Option.getOrUndefined(input.replay) ?? resolved.mini?.replay ?? true, + replayLimit: Option.getOrUndefined(input.replayLimit) ?? resolved.mini?.replay_limit, demo: input.demo, tuiConfig: resolved, config: { diff --git a/packages/cli/test/acp/event-behavior.test.ts b/packages/cli/test/acp/event-behavior.test.ts index 1f9935f5c8..ded7b7020a 100644 --- a/packages/cli/test/acp/event-behavior.test.ts +++ b/packages/cli/test/acp/event-behavior.test.ts @@ -439,6 +439,7 @@ describe("acp event behavior", () => { sessionID: "ses_cancel", cwd: "/workspace", start: { type: "input", id: "input_cancel" }, + writeTextFile: false, control, submit: async (signal) => { await fixture.client.session.prompt( @@ -481,6 +482,7 @@ describe("acp event behavior", () => { sessionID: "ses_cancel_admission", cwd: "/workspace", start: { type: "input", id: "input_cancel_admission" }, + writeTextFile: false, control, submit: (signal) => fixture.client.session.prompt( @@ -566,7 +568,7 @@ function turn(input: { sessionID: input.sessionID, cwd: "/workspace", start: { type: "input", id: input.inputID }, - userMessageID: `client_${input.inputID}`, + writeTextFile: false, control: { cancelled: false, admission: new AbortController() }, submit: (signal) => input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }), diff --git a/packages/cli/test/acp/event.test.ts b/packages/cli/test/acp/event.test.ts index e6d8db9deb..575705b294 100644 --- a/packages/cli/test/acp/event.test.ts +++ b/packages/cli/test/acp/event.test.ts @@ -85,7 +85,6 @@ test("acp prompt resolves after ordered turn updates", async () => { try { const id = "msg_prompt" - const userMessageID = "client-message" const response = await streamTurn({ client, connection: { @@ -97,7 +96,7 @@ test("acp prompt resolves after ordered turn updates", async () => { sessionID: "ses_test", cwd: "/workspace", start: { type: "input", id }, - userMessageID, + writeTextFile: false, control: { cancelled: false, admission: new AbortController() }, submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }), }) @@ -112,7 +111,7 @@ test("acp prompt resolves after ordered turn updates", async () => { }, }, ]) - expect(response).toMatchObject({ stopReason: "end_turn", userMessageId: userMessageID, usage: { totalTokens: 2 } }) + expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } }) } finally { events?.close() await server.stop(true) diff --git a/packages/cli/test/acp/initialize-auth.subprocess.test.ts b/packages/cli/test/acp/initialize-auth.subprocess.test.ts index 2303b5b3a1..904ceadd5a 100644 --- a/packages/cli/test/acp/initialize-auth.subprocess.test.ts +++ b/packages/cli/test/acp/initialize-auth.subprocess.test.ts @@ -14,6 +14,7 @@ describe("acp initialize/auth subprocess", () => { expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false) expect(initialized.agentCapabilities?.loadSession).toBe(true) expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({}) + expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({}) expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({}) expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({}) expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({}) diff --git a/packages/cli/test/acp/lifecycle.subprocess.test.ts b/packages/cli/test/acp/lifecycle.subprocess.test.ts index ffd0332620..4ae855a7f3 100644 --- a/packages/cli/test/acp/lifecycle.subprocess.test.ts +++ b/packages/cli/test/acp/lifecycle.subprocess.test.ts @@ -1,5 +1,6 @@ import type { CloseSessionResponse, + DeleteSessionResponse, ListSessionsResponse, LoadSessionResponse, ResumeSessionResponse, @@ -60,6 +61,20 @@ describe("acp lifecycle subprocess", () => { expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true) }, 60_000) + test("delete capability and delete request", async () => { + await using fixture = await createAcpFixture() + const acp = fixture.spawn() + const initialized = await initialize(acp) + expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({}) + const session = await newSession(acp, fixture.home) + + expect( + expectOk(await acp.request("session/delete", { sessionId: session.sessionId })), + ).toEqual({}) + const listed = expectOk(await acp.request("session/list", { cwd: fixture.home })) + expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false) + }, 60_000) + test("resume capability advertisement", async () => { await using fixture = await createAcpFixture() const initialized = await initialize(fixture.spawn()) diff --git a/packages/cli/test/acp/permission-behavior.test.ts b/packages/cli/test/acp/permission-behavior.test.ts index ad8ccd128a..355ff5bb41 100644 --- a/packages/cli/test/acp/permission-behavior.test.ts +++ b/packages/cli/test/acp/permission-behavior.test.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { streamTurn } from "../../src/acp/event" +import { syncEditedFiles } from "../../src/acp/permission" import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture" type SessionUpdateParams = Parameters[0] @@ -12,6 +13,27 @@ type Connection = Pick describe("acp permission behavior", () => { + test("does not sync edits when writeTextFile was not advertised", async () => { + const writes: Parameters[0][] = [] + + await syncEditedFiles({ + connection: { + writeTextFile: async (input) => { + writes.push(input) + return {} + }, + }, + writeTextFile: false, + sessionID: "ses_no_write", + cwd: "/workspace", + toolName: "edit", + toolInput: { filePath: "/workspace/file.ts" }, + structured: {}, + }) + + expect(writes).toEqual([]) + }) + test("forwards allow-once and allow-always selections to the generated client", async () => { const permissionRequests: RequestPermissionRequest[] = [] const fixture = createSseFixture({ @@ -465,6 +487,7 @@ function startTurn(fixture: Fixture, connection: Connection, sessionID: string, sessionID, cwd, start: { type: "input", id: inputID }, + writeTextFile: true, control: { cancelled: false, admission: new AbortController() }, submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }), }) diff --git a/packages/cli/test/acp/service-directory.test.ts b/packages/cli/test/acp/service-directory.test.ts index 9690622372..a3671753c9 100644 --- a/packages/cli/test/acp/service-directory.test.ts +++ b/packages/cli/test/acp/service-directory.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk" -import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture" +import { makeACPFixture, makeSession, secondModel } from "./service-fixture" describe("acp service directory behavior", () => { test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => { @@ -134,7 +134,6 @@ describe("acp service directory behavior", () => { configId: "mode", value: "plan", }) - await fixture.service.setSessionModel({ sessionId: session.sessionId, modelId: "test/test-model/high" }) await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" }) expect(currentValue(selectedModel, "model")).toBe("test/second-model") @@ -148,7 +147,6 @@ describe("acp service directory behavior", () => { ).toEqual([ { model: { providerID: "test", id: secondModel.id } }, { model: { providerID: "test", id: secondModel.id, variant: "medium" } }, - { model: { providerID: "test", id: testModel.id, variant: "high" } }, ]) expect( fixture.requests diff --git a/packages/cli/test/acp/service-lifecycle.test.ts b/packages/cli/test/acp/service-lifecycle.test.ts index 2c4992b8e5..35a46a0ee2 100644 --- a/packages/cli/test/acp/service-lifecycle.test.ts +++ b/packages/cli/test/acp/service-lifecycle.test.ts @@ -225,6 +225,33 @@ describe("acp service lifecycle", () => { "/api/session/missing/interrupt", ]) }) + + test("deletes sessions from backing and local storage", async () => { + await using fixture = makeACPFixture({ + fetch(request) { + if (request.method === "POST" && request.path === "/api/session") { + return Response.json({ data: makeSession("ses_delete") }) + } + if (request.method === "DELETE" && request.path === "/api/session/ses_delete") { + return new Response(null, { status: 204 }) + } + return undefined + }, + }) + const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] }) + + expect(await fixture.service.deleteSession({ sessionId: session.sessionId })).toEqual({}) + expect(fixture.requests).toContainEqual({ + method: "DELETE", + path: "/api/session/ses_delete", + query: {}, + body: undefined, + }) + const missing = await fixture.service + .setSessionConfigOption({ sessionId: session.sessionId, configId: "effort", value: "high" }) + .catch((error: unknown) => error) + expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: session.sessionId }) + }) }) function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) { diff --git a/packages/cli/test/acp/service-usage.test.ts b/packages/cli/test/acp/service-usage.test.ts index 1ad9eee7b7..e7abb74073 100644 --- a/packages/cli/test/acp/service-usage.test.ts +++ b/packages/cli/test/acp/service-usage.test.ts @@ -45,17 +45,14 @@ describe("acp service prompt routing and usage", () => { const commandResult = await fixture.service.prompt({ sessionId: session.sessionId, - messageId: "client-command", prompt: [{ type: "text", text: "/review now" }], }) const skillResult = await fixture.service.prompt({ sessionId: session.sessionId, - messageId: "client-skill", prompt: [{ type: "text", text: "/verify" }], }) const compactResult = await fixture.service.prompt({ sessionId: session.sessionId, - messageId: "client-compact", prompt: [{ type: "text", text: "/compact" }], }) @@ -154,13 +151,11 @@ describe("acp service prompt routing and usage", () => { const response = await fixture.service.prompt({ sessionId: session.sessionId, - messageId: "client-message", prompt: [{ type: "text", text: "hello" }], }) expect(response).toEqual({ stopReason: "end_turn", - userMessageId: "client-message", usage: { inputTokens: 100, outputTokens: 40, diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts index 34cf2a2cd2..e9304d194f 100644 --- a/packages/cli/test/mini.test.ts +++ b/packages/cli/test/mini.test.ts @@ -214,11 +214,15 @@ describe("mini command", () => { expect(result.exitCode).toBe(0) expect(result.stdout).toContain("--server string") expect(result.stdout).toContain("--prompt string") + expect(result.stdout).toContain("--replay") + expect(result.stdout).toContain("disable with --no-replay") + expect(result.stdout).toContain("--replay-limit integer") + expect(result.stdout).toContain("Limit replay to the newest N messages (default: 200)") expect(result.stdout).not.toContain("SUBCOMMANDS") }) test("routes local and explicit-server invocations into mini", async () => { - for (const args of [["mini"], ["mini", "--server", "http://127.0.0.1:1"]]) { + for (const args of [["mini"], ["mini", "--no-replay"], ["mini", "--server", "http://127.0.0.1:1"]]) { const result = await cli(args) expect(result.exitCode).toBe(1) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 6c869b0e2e..a08c7b3246 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -8,7 +8,7 @@ export type ModelRef = { id: string; providerID: string; variant?: string } export type ProviderSettings = { [x: string]: JsonValue } -export type AgentColor = string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info" +export type AgentColor = string export type PermissionV2Effect = "allow" | "deny" | "ask" diff --git a/packages/codemode/test/fixtures/opencode-v2-openapi.json b/packages/codemode/test/fixtures/opencode-v2-openapi.json index 543a418173..d430f5d21d 100644 --- a/packages/codemode/test/fixtures/opencode-v2-openapi.json +++ b/packages/codemode/test/fixtures/opencode-v2-openapi.json @@ -10561,26 +10561,10 @@ "additionalProperties": false }, "Agent.Color": { - "anyOf": [ + "type": "string", + "allOf": [ { - "type": "string", - "allOf": [ - { - "pattern": "^#[0-9a-fA-F]{6}$" - } - ] - }, - { - "type": "string", - "enum": [ - "primary", - "secondary", - "accent", - "success", - "warning", - "error", - "info" - ] + "pattern": "^#[0-9a-fA-F]{6}$" } ] }, diff --git a/packages/core/package.json b/packages/core/package.json index cf54590d52..7756c08d7e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -91,7 +91,7 @@ "@effect/sql-sqlite-bun": "catalog:", "@lydell/node-pty": "catalog:", "@modelcontextprotocol/sdk": "1.29.0", - "@ff-labs/fff-bun": "0.9.4", + "@ff-labs/fff-bun": "0.10.1", "@opencode-ai/codemode": "workspace:*", "@opencode-ai/effect-drizzle-sqlite": "workspace:*", "@opencode-ai/effect-sqlite-node": "workspace:*", diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index 6d749b8526..33a4d670af 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -422,7 +422,6 @@ function callOptions(request: LLMRequest): LanguageModelV3CallOptions { presencePenalty: request.generation?.presencePenalty, frequencyPenalty: request.generation?.frequencyPenalty, seed: request.generation?.seed, - responseFormat: responseFormat(request), tools: request.tools.map(tool), toolChoice: toolChoice(request.toolChoice), headers: request.http?.headers, @@ -527,12 +526,6 @@ function toolChoice(input: LLMRequest["toolChoice"]): LanguageModelV3ToolChoice return { type: input.type } } -function responseFormat(request: LLMRequest): LanguageModelV3CallOptions["responseFormat"] { - if (request.responseFormat?.type === "json") - return { type: "json", schema: request.responseFormat.schema as JSONSchema7 } - if (request.responseFormat) return { type: "text" } -} - function providerOptions(input: LLMRequest["providerOptions"]): SharedV3ProviderOptions | undefined { if (!input) return undefined return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)])) diff --git a/packages/core/src/catalog.ts b/packages/core/src/catalog.ts index 9f63444790..9b8f916e70 100644 --- a/packages/core/src/catalog.ts +++ b/packages/core/src/catalog.ts @@ -116,7 +116,7 @@ const layer = Layer.effect( draft.providers.set(providerID, record) } const model = - record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo) + record.models.get(modelID) ?? (ModelV2.Info.default(providerID, modelID) as ModelV2.MutableInfo) if (!record.models.has(modelID)) record.models.set(modelID, model) fn(model) model.id = modelID diff --git a/packages/core/src/config/agent.ts b/packages/core/src/config/agent.ts index fc5edf5119..075feea250 100644 --- a/packages/core/src/config/agent.ts +++ b/packages/core/src/config/agent.ts @@ -6,10 +6,7 @@ import { ConfigProvider } from "./provider" import { ConfigModel } from "./model" import { PositiveInt } from "../schema" -export const Color = Schema.Union([ - Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), - Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), -]) +export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)) export class Info extends Schema.Class("ConfigV2.Agent")({ model: ConfigModel.Selection.pipe(Schema.optional), diff --git a/packages/core/src/generate.ts b/packages/core/src/generate.ts index f4a79e9aa2..432827bce0 100644 --- a/packages/core/src/generate.ts +++ b/packages/core/src/generate.ts @@ -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()( - "Generate.UnavailableError", - { message: Schema.String, service: Schema.optional(Schema.String) }, -) {} +export class UnavailableError extends Schema.TaggedErrorClass()("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()("@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], +}) diff --git a/packages/core/src/github-copilot/models.ts b/packages/core/src/github-copilot/models.ts index 8790cefba2..52f98753b5 100644 --- a/packages/core/src/github-copilot/models.ts +++ b/packages/core/src/github-copilot/models.ts @@ -135,7 +135,7 @@ function build(id: ModelV2.ID, remote: UsableModel, baseURL: string, previous?: const released = previous?.time.released || Date.parse(version) return ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, id), + ...ModelV2.Info.default(ProviderV2.ID.githubCopilot, id), id, modelID: ModelV2.ID.make(remote.id), providerID: ProviderV2.ID.githubCopilot, diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 7be9fefe3f..adf1afe62b 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -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, diff --git a/packages/core/src/model-resolver.ts b/packages/core/src/model-resolver.ts new file mode 100644 index 0000000000..44faea8b12 --- /dev/null +++ b/packages/core/src/model-resolver.ts @@ -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()( + "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()( + "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 + readonly resolveModel: (model: ModelV2.Info, variant?: ModelV2.VariantID) => Effect.Effect +} + +export class Service extends Context.Service()("@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() + 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 => { + 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 + readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect +} + +export const fromCatalogModel = ( + model: ModelV2.Info, + credential?: Credential.Value, + dependencies?: Dependencies, +): Effect.Effect => { + 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) => { + const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings + return rest +} + +const codexModel = ( + model: ModelV2.Info, + credential: Credential.Value | undefined, + key: ReturnType | 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], +}) diff --git a/packages/core/src/plugin/provider/openai-codex.ts b/packages/core/src/plugin/provider/openai-codex.ts index 8d4389d969..eb734b29f3 100644 --- a/packages/core/src/plugin/provider/openai-codex.ts +++ b/packages/core/src/plugin/provider/openai-codex.ts @@ -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 diff --git a/packages/core/src/session/generate-node.ts b/packages/core/src/session/generate-node.ts index f26e2847c2..96804fc57f 100644 --- a/packages/core/src/session/generate-node.ts +++ b/packages/core/src/session/generate-node.ts @@ -74,7 +74,6 @@ export const layer = Layer.effect( system: contextEvent.system, messages: contextEvent.messages, tools: hookedTools, - toolChoice: "none", }), ) yield* Effect.logInfo("session generation usage diagnostic", { usage: response.usage }) diff --git a/packages/core/src/session/runner/model.ts b/packages/core/src/session/runner/model.ts index 6013ce7b74..d0902d7d67 100644 --- a/packages/core/src/session/runner/model.ts +++ b/packages/core/src/session/runner/model.ts @@ -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()( "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()( "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()( - "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()( - "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 @@ -94,9 +40,6 @@ export interface Interface { export class Service extends Context.Service()("@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() - 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 => { - 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 - readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect -} - -export const fromCatalogModel = ( - model: ModelV2.Info, - credential?: Credential.Value, - dependencies: Dependencies = {}, -): Effect.Effect => { - 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) => { - const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings - return rest -} - -const codexModel = ( - model: ModelV2.Info, - credential: Credential.Value | undefined, - key: ReturnType | 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] }) diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 6c0a342abe..61a49f57cf 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -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), diff --git a/packages/core/test/aisdk.test.ts b/packages/core/test/aisdk.test.ts index 0bc16e8f59..9e7e0d0c6e 100644 --- a/packages/core/test/aisdk.test.ts +++ b/packages/core/test/aisdk.test.ts @@ -12,7 +12,7 @@ const it = testEffect(AISDK.locationLayer) const model = (packageName: string, settings: Record = {}) => ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("catalog-model")), + ...ModelV2.Info.default(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("catalog-model")), modelID: ModelV2.ID.make("api-model"), package: ProviderV2.aisdk(packageName), settings, diff --git a/packages/core/test/config/agent.test.ts b/packages/core/test/config/agent.test.ts index 0380c38e3a..855ea370e2 100644 --- a/packages/core/test/config/agent.test.ts +++ b/packages/core/test/config/agent.test.ts @@ -1,4 +1,4 @@ -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import fs from "fs/promises" import path from "path" import { Effect, Schema } from "effect" @@ -23,6 +23,10 @@ const defaultPermissions = [ { action: "external_directory", resource: "*", effect: "ask" }, ] satisfies PermissionV2.Ruleset +test("rejects named agent color tokens", () => { + expect(() => decode({ agents: { reviewer: { color: "warning" } } })).toThrow() +}) + describe("ConfigAgentPlugin.Plugin", () => { it.effect("matches POSIX paths against home-relative permissions", () => Effect.gen(function* () { @@ -160,7 +164,7 @@ describe("ConfigAgentPlugin.Plugin", () => { description: "Reviews changes", mode: "subagent", hidden: true, - color: "warning", + color: "#ff6b6b", steps: 12, request: { headers: { first: "one", shared: "first" }, @@ -197,7 +201,7 @@ describe("ConfigAgentPlugin.Plugin", () => { description: "Reviews changes", mode: "subagent", hidden: true, - color: "warning", + color: "#ff6b6b", steps: 12, model: { providerID: "anthropic", id: "claude-sonnet" }, }) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 64e08b5e79..6584cc1c94 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -738,7 +738,7 @@ describe("Config", () => { system: "Find regressions.", mode: "subagent", hidden: false, - color: "warning", + color: "#ff6b6b", steps: 12, disabled: false, permissions: [{ action: "edit", resource: "*", effect: "deny" }], @@ -824,7 +824,7 @@ describe("Config", () => { expect(reviewer?.system).toBe("Find regressions.") expect(reviewer?.mode).toBe("subagent") expect(reviewer?.hidden).toBe(false) - expect(reviewer?.color).toBe("warning") + expect(reviewer?.color).toBe("#ff6b6b") expect(reviewer?.steps).toBe(12) expect(reviewer?.disabled).toBe(false) expect(reviewer?.permissions).toEqual([{ action: "edit", resource: "*", effect: "deny" }]) diff --git a/packages/core/test/config/provider.test.ts b/packages/core/test/config/provider.test.ts index c3c5b9c420..60597836f2 100644 --- a/packages/core/test/config/provider.test.ts +++ b/packages/core/test/config/provider.test.ts @@ -49,6 +49,86 @@ function withEnv(vars: Record, effect: () = const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigProviderPlugin.Plugin", () => { + it.effect("defaults custom models to agent capabilities", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("custom") + const modelID = ModelV2.ID.make("chat") + const config = Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "aisdk:@ai-sdk/openai-compatible", + models: { chat: {} }, + }, + }, + }), + }), + ]), + }) + + yield* addPlugin(config) + + const model = required(yield* catalog.model.get(providerID, modelID)) + expect(model.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] }) + }), + ) + + it.effect("preserves catalog capabilities unless config overrides them", () => + Effect.gen(function* () { + const catalog = yield* Catalog.Service + const providerID = ProviderV2.ID.make("custom") + const inheritedID = ModelV2.ID.make("inherited") + const overriddenID = ModelV2.ID.make("overridden") + yield* catalog.transform((draft) => { + draft.model.update(providerID, inheritedID, (model) => { + model.capabilities = { tools: false, input: ["text"], output: ["text"] } + }) + draft.model.update(providerID, overriddenID, (model) => { + model.capabilities = { tools: false, input: ["text"], output: ["text"] } + }) + }) + const config = Config.Service.of({ + entries: () => + Effect.succeed([ + new Config.Document({ + type: "document", + info: decode({ + providers: { + custom: { + package: "aisdk:@ai-sdk/openai-compatible", + models: { + inherited: { name: "Inherited" }, + overridden: { + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + }, + }, + }, + }, + }), + }), + ]), + }) + + yield* addPlugin(config) + + expect((yield* catalog.model.get(providerID, inheritedID))?.capabilities).toEqual({ + tools: false, + input: ["text"], + output: ["text"], + }) + expect((yield* catalog.model.get(providerID, overriddenID))?.capabilities).toEqual({ + tools: true, + input: ["text", "image"], + output: ["text"], + }) + }), + ) + it.effect("keeps configured model variant bodies unchanged", () => Effect.gen(function* () { const catalog = yield* Catalog.Service diff --git a/packages/core/test/generate.test.ts b/packages/core/test/generate.test.ts new file mode 100644 index 0000000000..c0ad30d383 --- /dev/null +++ b/packages/core/test/generate.test.ts @@ -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.default(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, + }) + }), +) diff --git a/packages/core/test/github-copilot/models.test.ts b/packages/core/test/github-copilot/models.test.ts index 31d8f710cd..9bd07971fd 100644 --- a/packages/core/test/github-copilot/models.test.ts +++ b/packages/core/test/github-copilot/models.test.ts @@ -49,12 +49,12 @@ test("defensively syncs advertised Copilot models", async () => { try { const existing = ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.githubCopilot, ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), name: "GPT-5 local", }) const stale = ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale")), + ...ModelV2.Info.default(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale")), modelID: ModelV2.ID.make("stale"), }) const models = await CopilotModels.get(server.url.origin, {}, [existing, stale]) diff --git a/packages/core/test/session-runner-model.test.ts b/packages/core/test/model-resolver.test.ts similarity index 80% rename from packages/core/test/session-runner-model.test.ts rename to packages/core/test/model-resolver.test.ts index 38fc707918..9eedc4265b 100644 --- a/packages/core/test/session-runner-model.test.ts +++ b/packages/core/test/model-resolver.test.ts @@ -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) }), ) }) diff --git a/packages/core/test/plugin/provider-alibaba.test.ts b/packages/core/test/plugin/provider-alibaba.test.ts deleted file mode 100644 index bb7f922e52..0000000000 --- a/packages/core/test/plugin/provider-alibaba.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect } from "bun:test" -import { createAlibaba } from "@ai-sdk/alibaba" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* AlibabaPlugin.effect(host) -}) - -describe("AlibabaPlugin", () => { - it.effect("creates an Alibaba SDK for @ai-sdk/alibaba", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), - modelID: ModelV2.ID.make("qwen"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/alibaba", - options: { name: "alibaba" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("ignores non-Alibaba SDK packages", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("qwen")), - modelID: ModelV2.ID.make("qwen"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "alibaba" }, - }) - expect(result.sdk).toBeUndefined() - }), - ) - - it.effect("matches the old bundled Alibaba SDK provider naming", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-alibaba"), ModelV2.ID.make("qwen")), - modelID: ModelV2.ID.make("qwen"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/alibaba", - options: { name: "custom-alibaba", apiKey: "test" }, - }) - const expected = createAlibaba({ apiKey: "test", ...{ name: "custom-alibaba" } }).languageModel("qwen") - const actual = result.sdk?.languageModel("qwen") - expect(actual?.provider).toBe(expected.provider) - expect(actual?.modelId).toBe(expected.modelId) - }), - ) - - it.effect("uses the default languageModel(modelID) behavior", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const item = ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("alibaba"), ModelV2.ID.make("alias")), - modelID: ModelV2.ID.make("qwen-plus"), - package: "aisdk:test-provider", - }) - const result = yield* aisdk.runSDK({ model: item, package: "@ai-sdk/alibaba", options: {} }) - const language = result.sdk?.languageModel(item.modelID ?? item.id) - expect(language?.modelId).toBe("qwen-plus") - expect(language?.provider).toBe("alibaba.chat") - }), - ) -}) diff --git a/packages/core/test/plugin/provider-amazon-bedrock.test.ts b/packages/core/test/plugin/provider-amazon-bedrock.test.ts index db63910c33..39f46c49a2 100644 --- a/packages/core/test/plugin/provider-amazon-bedrock.test.ts +++ b/packages/core/test/plugin/provider-amazon-bedrock.test.ts @@ -108,7 +108,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -134,7 +134,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -169,7 +169,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -190,7 +190,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -210,7 +210,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -230,7 +230,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -251,7 +251,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -281,7 +281,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -310,7 +310,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), modelID: ModelV2.ID.make("openai.gpt-5.5"), package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"), }), @@ -338,7 +338,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")), modelID: ModelV2.ID.make("openai.gpt-5.5"), package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"), }), @@ -347,7 +347,7 @@ describe("AmazonBedrockPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")), modelID: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"), package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"), }), @@ -365,7 +365,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/anthropic"), }), @@ -393,7 +393,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -425,7 +425,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -434,7 +434,7 @@ describe("AmazonBedrockPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -443,7 +443,7 @@ describe("AmazonBedrockPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -452,7 +452,7 @@ describe("AmazonBedrockPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -461,7 +461,7 @@ describe("AmazonBedrockPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -487,7 +487,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -574,7 +574,7 @@ describe("AmazonBedrockPlugin", () => { for (const item of cases) { yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)), + ...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)), modelID: ModelV2.ID.make(item.modelID), package: ProviderV2.aisdk("test-provider"), }), @@ -594,7 +594,7 @@ describe("AmazonBedrockPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")), modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"), package: ProviderV2.aisdk("test-provider"), }), diff --git a/packages/core/test/plugin/provider-anthropic.test.ts b/packages/core/test/plugin/provider-anthropic.test.ts index af7b80dc67..0c0bcba461 100644 --- a/packages/core/test/plugin/provider-anthropic.test.ts +++ b/packages/core/test/plugin/provider-anthropic.test.ts @@ -63,7 +63,7 @@ describe("AnthropicPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")), modelID: ModelV2.ID.make("claude-sonnet-4-5"), package: ProviderV2.aisdk("@ai-sdk/anthropic"), }), @@ -81,7 +81,7 @@ describe("AnthropicPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")), modelID: ModelV2.ID.make("claude-sonnet-4-5"), package: ProviderV2.aisdk("@ai-sdk/anthropic"), }), diff --git a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts index f52f013105..5b9ca4bf86 100644 --- a/packages/core/test/plugin/provider-azure-cognitive-services.test.ts +++ b/packages/core/test/plugin/provider-azure-cognitive-services.test.ts @@ -121,7 +121,7 @@ describe("AzureCognitiveServicesPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: "aisdk:test-provider", }), @@ -140,7 +140,7 @@ describe("AzureCognitiveServicesPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: "aisdk:test-provider", }), @@ -149,7 +149,7 @@ describe("AzureCognitiveServicesPlugin", () => { }) const ignored = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: "aisdk:test-provider", }), @@ -170,7 +170,7 @@ describe("AzureCognitiveServicesPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("messages-deployment")), + ...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("messages-deployment")), modelID: ModelV2.ID.make("messages-deployment"), package: "aisdk:test-provider", }), @@ -179,7 +179,7 @@ describe("AzureCognitiveServicesPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")), + ...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")), modelID: ModelV2.ID.make("chat-deployment"), package: "aisdk:test-provider", }), @@ -188,7 +188,7 @@ describe("AzureCognitiveServicesPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("language-deployment")), + ...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("language-deployment")), modelID: ModelV2.ID.make("language-deployment"), package: "aisdk:test-provider", }), diff --git a/packages/core/test/plugin/provider-azure.test.ts b/packages/core/test/plugin/provider-azure.test.ts index 6cf515c0c6..227e3bfa78 100644 --- a/packages/core/test/plugin/provider-azure.test.ts +++ b/packages/core/test/plugin/provider-azure.test.ts @@ -148,7 +148,7 @@ describe("AzurePlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: ProviderV2.aisdk("test-provider"), }), @@ -168,7 +168,7 @@ describe("AzurePlugin", () => { const exit = yield* aisdk .runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: ProviderV2.aisdk("test-provider"), }), @@ -189,7 +189,7 @@ describe("AzurePlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: ProviderV2.aisdk("test-provider"), }), @@ -208,7 +208,7 @@ describe("AzurePlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: ProviderV2.aisdk("test-provider"), }), @@ -227,7 +227,7 @@ describe("AzurePlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: ProviderV2.aisdk("test-provider"), body: { useCompletionUrls: true }, @@ -247,7 +247,7 @@ describe("AzurePlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: ProviderV2.aisdk("test-provider"), }), @@ -256,7 +256,7 @@ describe("AzurePlugin", () => { }) const ignored = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), + ...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("deployment")), modelID: ModelV2.ID.make("deployment"), package: ProviderV2.aisdk("test-provider"), }), @@ -280,7 +280,7 @@ describe("AzurePlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")), modelID: ModelV2.ID.make("messages-deployment"), package: ProviderV2.aisdk("test-provider"), }), @@ -289,7 +289,7 @@ describe("AzurePlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")), + ...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")), modelID: ModelV2.ID.make("language-deployment"), package: ProviderV2.aisdk("test-provider"), }), diff --git a/packages/core/test/plugin/provider-cerebras.test.ts b/packages/core/test/plugin/provider-cerebras.test.ts index eb5c4ec1bf..6722f48996 100644 --- a/packages/core/test/plugin/provider-cerebras.test.ts +++ b/packages/core/test/plugin/provider-cerebras.test.ts @@ -65,7 +65,7 @@ describe("CerebrasPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty( + ...ModelV2.Info.default( ProviderV2.ID.make("custom-cerebras"), ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), ), @@ -88,7 +88,7 @@ describe("CerebrasPlugin", () => { yield* addPlugin() yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty( + ...ModelV2.Info.default( ProviderV2.ID.make("custom-cerebras"), ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), ), @@ -110,7 +110,7 @@ describe("CerebrasPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty( + ...ModelV2.Info.default( ProviderV2.ID.make("custom-cerebras"), ModelV2.ID.make("llama-4-scout-17b-16e-instruct"), ), diff --git a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts index cbd2b95c9e..bd9d9e80cd 100644 --- a/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-ai-gateway.test.ts @@ -117,7 +117,7 @@ describe("CloudflareAIGatewayPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -139,7 +139,7 @@ describe("CloudflareAIGatewayPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -184,7 +184,7 @@ describe("CloudflareAIGatewayPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -214,7 +214,7 @@ describe("CloudflareAIGatewayPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -252,7 +252,7 @@ describe("CloudflareAIGatewayPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -284,7 +284,7 @@ describe("CloudflareAIGatewayPlugin", () => { yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -307,7 +307,7 @@ describe("CloudflareAIGatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -331,7 +331,7 @@ describe("CloudflareAIGatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -361,7 +361,7 @@ describe("CloudflareAIGatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), @@ -385,7 +385,7 @@ describe("CloudflareAIGatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty( + ...ModelV2.Info.default( ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("anthropic/claude-sonnet-4-5"), ), @@ -417,7 +417,7 @@ describe("CloudflareAIGatewayPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: "aisdk:test-provider", }), diff --git a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts index d67b8d91cc..5f4e3bc276 100644 --- a/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts +++ b/packages/core/test/plugin/provider-cloudflare-workers-ai.test.ts @@ -94,7 +94,7 @@ describe("CloudflareWorkersAIPlugin", () => { const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))) const sdk = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), modelID: ModelV2.ID.make("@cf/model"), package: provider.package, settings: provider.settings, @@ -138,7 +138,7 @@ describe("CloudflareWorkersAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), modelID: ModelV2.ID.make("@cf/model"), package: "aisdk:@ai-sdk/openai-compatible", settings: { baseURL: "https://proxy.example/v1" }, @@ -178,7 +178,7 @@ describe("CloudflareWorkersAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), modelID: ModelV2.ID.make("@cf/model"), package: "aisdk:@ai-sdk/openai-compatible", settings: { baseURL: "https://proxy.example/v1" }, @@ -207,7 +207,7 @@ describe("CloudflareWorkersAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), modelID: ModelV2.ID.make("@cf/model"), package: "aisdk:@ai-sdk/openai-compatible", settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1" }, @@ -233,7 +233,7 @@ describe("CloudflareWorkersAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("@cf/api-model"), package: "aisdk:test-provider", }), @@ -253,7 +253,7 @@ describe("CloudflareWorkersAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")), modelID: ModelV2.ID.make("@cf/model"), package: "aisdk:@ai-sdk/anthropic", settings: { baseURL: "https://proxy.example/v1" }, diff --git a/packages/core/test/plugin/provider-cohere.test.ts b/packages/core/test/plugin/provider-cohere.test.ts deleted file mode 100644 index a4109f74bd..0000000000 --- a/packages/core/test/plugin/provider-cohere.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect, mock } from "bun:test" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere" -import { ProviderV2 } from "@opencode-ai/core/provider" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const cohereOptions: Record[] = [] -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* CoherePlugin.effect(host) -}) - -function fakeSelectorSdk(calls: string[]) { - const make = (method: string) => (id: string) => { - calls.push(`${method}:${id}`) - return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 - } - return { - responses: make("responses"), - messages: make("messages"), - chat: make("chat"), - languageModel: make("languageModel"), - } -} - -void mock.module("@ai-sdk/cohere", () => ({ - createCohere: (options: Record) => { - cohereOptions.push({ ...options }) - return { - languageModel: (modelID: string) => ({ - modelID, - provider: `${options.name ?? "cohere"}.chat`, - specificationVersion: "v3", - }), - } - }, -})) - -describe("CoherePlugin", () => { - it.effect("creates a Cohere SDK only for @ai-sdk/cohere", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - - const ignored = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), - modelID: ModelV2.ID.make("command"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "cohere" }, - }) - expect(ignored.sdk).toBeUndefined() - - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("command")), - modelID: ModelV2.ID.make("command"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/cohere", - options: { name: "cohere" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("uses the model provider ID as the bundled SDK name", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-cohere"), ModelV2.ID.make("command-r-plus")), - modelID: ModelV2.ID.make("command-r-plus"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/cohere", - options: { name: "custom-cohere", apiKey: "test", baseURL: "https://cohere.example" }, - }) - - expect(cohereOptions.at(-1)).toEqual({ - name: "custom-cohere", - apiKey: "test", - baseURL: "https://cohere.example", - }) - expect(result.sdk?.languageModel("command-r-plus").provider).toBe("custom-cohere.chat") - }), - ) - - it.effect("leaves language selection to the default languageModel fallback", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const calls: string[] = [] - const sdk = fakeSelectorSdk(calls) - yield* addPlugin() - const result = yield* aisdk.runLanguage({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cohere"), ModelV2.ID.make("alias")), - modelID: ModelV2.ID.make("command-r-plus"), - package: "aisdk:test-provider", - }), - sdk, - options: {}, - }) - - expect(result.language).toBeUndefined() - expect(calls).toEqual([]) - expect(result.language ?? sdk.languageModel("command-r-plus")).toBeDefined() - expect(calls).toEqual(["languageModel:command-r-plus"]) - }), - ) -}) diff --git a/packages/core/test/plugin/provider-deepinfra.test.ts b/packages/core/test/plugin/provider-deepinfra.test.ts deleted file mode 100644 index 14c41e550d..0000000000 --- a/packages/core/test/plugin/provider-deepinfra.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect, mock } from "bun:test" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) -const deepinfraOptions: Record[] = [] -const deepinfraLanguageModels: string[] = [] - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* DeepInfraPlugin.effect(host) -}) - -void mock.module("@ai-sdk/deepinfra", () => ({ - createDeepInfra: (options: Record) => { - const captured = { ...options } - deepinfraOptions.push(captured) - return { - languageModel: (modelID: string) => { - deepinfraLanguageModels.push(modelID) - return { modelID, provider: `${captured.name ?? "deepinfra"}.chat`, specificationVersion: "v3" } - }, - } - }, -})) - -function resetDeepInfraMock() { - deepinfraOptions.length = 0 - deepinfraLanguageModels.length = 0 -} - -describe("DeepInfraPlugin", () => { - it.effect("creates a DeepInfra SDK for @ai-sdk/deepinfra", () => - Effect.gen(function* () { - resetDeepInfraMock() - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:@ai-sdk/deepinfra", - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("passes the model provider ID as the bundled DeepInfra SDK name", () => - Effect.gen(function* () { - resetDeepInfraMock() - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-deepinfra"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:@ai-sdk/deepinfra", - }), - package: "@ai-sdk/deepinfra", - options: { name: "custom-deepinfra", apiKey: "test" }, - }) - expect(result.sdk.languageModel("model").provider).toBe("custom-deepinfra.chat") - expect(deepinfraOptions).toEqual([{ name: "custom-deepinfra", apiKey: "test" }]) - }), - ) - - it.effect("uses the canonical provider ID as the bundled DeepInfra SDK name", () => - Effect.gen(function* () { - resetDeepInfraMock() - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:@ai-sdk/deepinfra", - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra", apiKey: "test" }, - }) - expect(result.sdk.languageModel("model").provider).toBe("deepinfra.chat") - expect(deepinfraOptions).toEqual([{ name: "deepinfra", apiKey: "test" }]) - }), - ) - - it.effect("matches only the exact bundled DeepInfra package", () => - Effect.gen(function* () { - resetDeepInfraMock() - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const packages = [ - "unmatched-package", - "@ai-sdk/deepinfra-compatible", - "file:///tmp/@ai-sdk/deepinfra-provider.js", - ] - yield* Effect.forEach(packages, (item) => - Effect.gen(function* () { - const ignored = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:@ai-sdk/deepinfra", - }), - package: item, - options: { name: "deepinfra" }, - }) - expect(ignored.sdk).toBeUndefined() - }), - ) - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:@ai-sdk/deepinfra", - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra" }, - }) - expect(result.sdk).toBeDefined() - expect(deepinfraOptions).toEqual([{ name: "deepinfra" }]) - }), - ) - - it.effect("uses the default languageModel selection for DeepInfra models", () => - Effect.gen(function* () { - resetDeepInfraMock() - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const sdkEvent = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("deepinfra"), ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct")), - modelID: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct"), - package: "aisdk:@ai-sdk/deepinfra", - }), - package: "@ai-sdk/deepinfra", - options: { name: "deepinfra" }, - }) - const result = yield* aisdk.runLanguage({ model: sdkEvent.model, sdk: sdkEvent.sdk, options: sdkEvent.options }) - const language = result.language ?? result.sdk.languageModel(result.model.modelID ?? result.model.id) - expect(language.provider).toBe("deepinfra.chat") - expect(deepinfraLanguageModels).toEqual(["meta-llama/Llama-3.3-70B-Instruct"]) - }), - ) -}) diff --git a/packages/core/test/plugin/provider-dynamic.test.ts b/packages/core/test/plugin/provider-dynamic.test.ts index d0b1a8af5a..341878e6de 100644 --- a/packages/core/test/plugin/provider-dynamic.test.ts +++ b/packages/core/test/plugin/provider-dynamic.test.ts @@ -53,7 +53,7 @@ describe("DynamicProviderPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), modelID: ModelV2.ID.make("test-model"), package: ProviderV2.aisdk(fixtureProvider), }), @@ -72,7 +72,7 @@ describe("DynamicProviderPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")), modelID: ModelV2.ID.make("test-model"), package: ProviderV2.aisdk(fixtureProvider), }), @@ -90,7 +90,7 @@ describe("DynamicProviderPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")), modelID: ModelV2.ID.make("test-model"), package: ProviderV2.aisdk(fixtureProvider), }), @@ -107,7 +107,7 @@ describe("DynamicProviderPlugin", () => { yield* addPlugin(npmEntrypoint(fixtureProviderPath)) const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")), + ...ModelV2.Info.default(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")), modelID: ModelV2.ID.make("test-model"), package: "aisdk:fixture-provider", }), @@ -125,7 +125,7 @@ describe("DynamicProviderPlugin", () => { const exit = yield* aisdk .language( ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("alias"), package: "aisdk:fixture-provider", }), @@ -143,7 +143,7 @@ describe("DynamicProviderPlugin", () => { const exit = yield* aisdk .language( ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("alias"), package: "aisdk:file:///missing/provider-factory.js", }), @@ -163,7 +163,7 @@ describe("DynamicProviderPlugin", () => { const exit = yield* aisdk .language( ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("alias"), package: "aisdk:fixture-provider", }), @@ -181,7 +181,7 @@ describe("DynamicProviderPlugin", () => { yield* addPlugin() const language = yield* aisdk.language( ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("test-model-api"), package: ProviderV2.aisdk(fixtureProvider), }), diff --git a/packages/core/test/plugin/provider-factory.test.ts b/packages/core/test/plugin/provider-factory.test.ts new file mode 100644 index 0000000000..d51d4900b4 --- /dev/null +++ b/packages/core/test/plugin/provider-factory.test.ts @@ -0,0 +1,60 @@ +import { expect } from "bun:test" +import { Effect } from "effect" +import { AISDK } from "@opencode-ai/core/aisdk" +import { ModelV2 } from "@opencode-ai/core/model" +import { PluginV2 } from "@opencode-ai/core/plugin" +import { PluginHost } from "@opencode-ai/core/plugin/host" +import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba" +import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere" +import { DeepInfraPlugin } from "@opencode-ai/core/plugin/provider/deepinfra" +import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway" +import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq" +import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral" +import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity" +import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai" +import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { testEffect } from "../lib/effect" +import { PluginTestLayer } from "./fixture" + +const modelID = ModelV2.ID.make("test-model") +const options = { name: "custom-provider", apiKey: "test", baseURL: "https://example.test" } +const providers = [ + { id: "alibaba", plugin: AlibabaPlugin, package: "@ai-sdk/alibaba", provider: "alibaba.chat" }, + { id: "cohere", plugin: CoherePlugin, package: "@ai-sdk/cohere", provider: "cohere.chat" }, + { id: "deepinfra", plugin: DeepInfraPlugin, package: "@ai-sdk/deepinfra", provider: "deepinfra.chat" }, + { id: "gateway", plugin: GatewayPlugin, package: "@ai-sdk/gateway", provider: "gateway" }, + { id: "groq", plugin: GroqPlugin, package: "@ai-sdk/groq", provider: "groq.chat" }, + { id: "mistral", plugin: MistralPlugin, package: "@ai-sdk/mistral", provider: "mistral.chat" }, + { id: "perplexity", plugin: PerplexityPlugin, package: "@ai-sdk/perplexity", provider: "perplexity" }, + { id: "togetherai", plugin: TogetherAIPlugin, package: "@ai-sdk/togetherai", provider: "togetherai.chat" }, + { id: "venice", plugin: VenicePlugin, package: "venice-ai-sdk-provider", provider: "custom-provider.chat" }, +] as const + +const it = testEffect(PluginTestLayer) + +providers.forEach((item) => + it.effect(`${item.id} loads only its exact package`, () => + Effect.gen(function* () { + const plugin = yield* PluginV2.Service + const aisdk = yield* AISDK.Service + const host = yield* PluginHost.make(plugin) + yield* item.plugin.effect(host) + const model = ModelV2.Info.make({ + ...ModelV2.Info.default(ProviderV2.ID.make(item.id), modelID), + modelID, + package: ProviderV2.aisdk(item.package), + }) + const matched = yield* aisdk.runSDK({ model, package: item.package, options }) + const ignored = yield* aisdk.runSDK({ model, package: `${item.package}/unsupported`, options }) + const language = matched.sdk?.languageModel(modelID) + + expect({ + provider: language?.provider, + modelID: language?.modelId, + version: language?.specificationVersion, + ignored: ignored.sdk === undefined, + }).toEqual({ provider: item.provider, modelID: "test-model", version: "v3", ignored: true }) + }), + ), +) diff --git a/packages/core/test/plugin/provider-gateway.test.ts b/packages/core/test/plugin/provider-gateway.test.ts deleted file mode 100644 index 722bbde9b6..0000000000 --- a/packages/core/test/plugin/provider-gateway.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect, mock } from "bun:test" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const gatewayCalls: Record[] = [] -const vercelGatewayModels = ["anthropic/claude-sonnet-4", "openai/gpt-5", "google/gemini-2.5-pro"] -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* GatewayPlugin.effect(host) -}) - -mock.module("@ai-sdk/gateway", () => ({ - createGateway(options: Record) { - gatewayCalls.push({ ...options }) - return { - languageModel(modelID: string) { - return { - modelId: modelID, - provider: options.name, - specificationVersion: "v3", - } - }, - } - }, -})) - -describe("GatewayPlugin", () => { - it.effect("creates a Gateway SDK for @ai-sdk/gateway", () => - Effect.gen(function* () { - gatewayCalls.length = 0 - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gateway"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/gateway", - options: { name: "gateway" }, - }) - expect(result.sdk).toBeDefined() - expect(gatewayCalls).toHaveLength(1) - }), - ) - - it.effect("passes the model providerID as the Gateway SDK name", () => - Effect.gen(function* () { - gatewayCalls.length = 0 - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make("anthropic/claude-sonnet-4")), - modelID: ModelV2.ID.make("anthropic/claude-sonnet-4"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/gateway", - options: { name: "vercel", apiKey: "test-key" }, - }) - - expect(gatewayCalls).toEqual([{ name: "vercel", apiKey: "test-key" }]) - expect(result.sdk.languageModel("anthropic/claude-sonnet-4").provider).toBe("vercel") - }), - ) - - it.effect("matches Vercel AI Gateway models by their @ai-sdk/gateway package", () => - Effect.gen(function* () { - gatewayCalls.length = 0 - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - - for (const modelID of vercelGatewayModels) { - const ignored = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), - modelID: ModelV2.ID.make(modelID), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/vercel", - options: { name: "vercel" }, - }) - expect(ignored.sdk).toBeUndefined() - - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("vercel"), ModelV2.ID.make(modelID)), - modelID: ModelV2.ID.make(modelID), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/gateway", - options: { name: "vercel" }, - }) - expect(result.sdk).toBeDefined() - } - - expect(gatewayCalls).toHaveLength(3) - }), - ) -}) diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index bb2443ae41..b39e06ac7b 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/packages/core/test/plugin/provider-github-copilot.test.ts @@ -99,7 +99,7 @@ describe("GithubCopilotPlugin", () => { yield* addPlugin() const ignored = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: "aisdk:test-provider", }), @@ -108,7 +108,7 @@ describe("GithubCopilotPlugin", () => { }) const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: "aisdk:test-provider", }), @@ -128,7 +128,7 @@ describe("GithubCopilotPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")), modelID: ModelV2.ID.make("claude-sonnet-4"), package: "aisdk:test-provider", }), @@ -147,7 +147,7 @@ describe("GithubCopilotPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("claude-sonnet-4"), package: "aisdk:test-provider", }), @@ -166,7 +166,7 @@ describe("GithubCopilotPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: "aisdk:test-provider", }), @@ -175,7 +175,7 @@ describe("GithubCopilotPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")), modelID: ModelV2.ID.make("gpt-5.1-codex"), package: "aisdk:test-provider", }), @@ -184,7 +184,7 @@ describe("GithubCopilotPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")), modelID: ModelV2.ID.make("gpt-4o"), package: "aisdk:test-provider", }), @@ -193,7 +193,7 @@ describe("GithubCopilotPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")), modelID: ModelV2.ID.make("gpt-5-mini"), package: "aisdk:test-provider", }), @@ -202,7 +202,7 @@ describe("GithubCopilotPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")), modelID: ModelV2.ID.make("gpt-5-mini-2025-08-07"), package: "aisdk:test-provider", }), @@ -227,7 +227,7 @@ describe("GithubCopilotPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("mai-code-1-flash-picker")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("mai-code-1-flash-picker")), modelID: ModelV2.ID.make("mai-code-1-flash-picker"), package: "aisdk:test-provider", settings: { endpoint: "responses" }, @@ -237,7 +237,7 @@ describe("GithubCopilotPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: "aisdk:test-provider", settings: { endpoint: "chat" }, @@ -257,7 +257,7 @@ describe("GithubCopilotPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")), modelID: ModelV2.ID.make("gpt-5"), package: "aisdk:test-provider", }), @@ -266,7 +266,7 @@ describe("GithubCopilotPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")), modelID: ModelV2.ID.make("gpt-5-mini"), package: "aisdk:test-provider", }), @@ -275,7 +275,7 @@ describe("GithubCopilotPlugin", () => { }) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")), + ...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")), modelID: ModelV2.ID.make("claude-sonnet-4"), package: "aisdk:test-provider", }), @@ -324,7 +324,7 @@ describe("GithubCopilotPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: "aisdk:test-provider", }), diff --git a/packages/core/test/plugin/provider-gitlab.test.ts b/packages/core/test/plugin/provider-gitlab.test.ts index ae66a61aa8..ac12ebfc9d 100644 --- a/packages/core/test/plugin/provider-gitlab.test.ts +++ b/packages/core/test/plugin/provider-gitlab.test.ts @@ -69,7 +69,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), modelID: ModelV2.ID.make("claude"), package: "aisdk:test-provider", }), @@ -107,7 +107,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), modelID: ModelV2.ID.make("claude"), package: "aisdk:test-provider", }), @@ -133,7 +133,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), modelID: ModelV2.ID.make("claude"), package: "aisdk:test-provider", }), @@ -175,7 +175,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), modelID: ModelV2.ID.make("claude"), package: "aisdk:test-provider", }), @@ -195,7 +195,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), modelID: ModelV2.ID.make("duo-workflow-custom"), package: "aisdk:test-provider", headers: {}, @@ -229,7 +229,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")), modelID: ModelV2.ID.make("duo-workflow-exact"), package: "aisdk:test-provider", }), @@ -257,7 +257,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")), modelID: ModelV2.ID.make("duo-workflow-custom"), package: "aisdk:test-provider", headers: {}, @@ -284,7 +284,7 @@ describe("GitLabPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), + ...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")), modelID: ModelV2.ID.make("claude"), package: "aisdk:test-provider", headers: { h: "v" }, diff --git a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts index 090abb945c..3b6726d373 100644 --- a/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts +++ b/packages/core/test/plugin/provider-google-vertex-anthropic.test.ts @@ -116,7 +116,7 @@ describe("GoogleVertexAnthropicPlugin", () => { yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty( + ...ModelV2.Info.default( ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make("claude-sonnet-4-5"), ), @@ -143,7 +143,7 @@ describe("GoogleVertexAnthropicPlugin", () => { yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty( + ...ModelV2.Info.default( ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make("claude-sonnet-4-5"), ), @@ -167,7 +167,7 @@ describe("GoogleVertexAnthropicPlugin", () => { yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), modelID: ModelV2.ID.make("claude-sonnet-4-5"), package: "aisdk:test-provider", }), @@ -187,7 +187,7 @@ describe("GoogleVertexAnthropicPlugin", () => { yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), modelID: ModelV2.ID.make("claude-sonnet-4-5"), package: "aisdk:test-provider", }), @@ -206,7 +206,7 @@ describe("GoogleVertexAnthropicPlugin", () => { yield* addPlugin(GoogleVertexAnthropicPlugin) const sdkResult = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), modelID: ModelV2.ID.make(" claude-sonnet-4-5 "), package: "aisdk:test-provider", }), @@ -215,7 +215,7 @@ describe("GoogleVertexAnthropicPlugin", () => { }) const languageResult = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")), modelID: ModelV2.ID.make(" claude-sonnet-4-5 "), package: "aisdk:test-provider", }), @@ -238,7 +238,7 @@ describe("GoogleVertexAnthropicPlugin", () => { yield* addPlugin(GoogleVertexAnthropicPlugin) yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make(" claude-sonnet-4-5 ")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make(" claude-sonnet-4-5 ")), modelID: ModelV2.ID.make(" claude-sonnet-4-5 "), package: "aisdk:test-provider", }), @@ -257,7 +257,7 @@ describe("GoogleVertexAnthropicPlugin", () => { yield* addPlugin(GoogleVertexAnthropicPlugin) const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")), modelID: ModelV2.ID.make("claude-sonnet-4-5"), package: "aisdk:test-provider", }), diff --git a/packages/core/test/plugin/provider-google-vertex.test.ts b/packages/core/test/plugin/provider-google-vertex.test.ts index 635beba3c1..15949b4945 100644 --- a/packages/core/test/plugin/provider-google-vertex.test.ts +++ b/packages/core/test/plugin/provider-google-vertex.test.ts @@ -172,7 +172,7 @@ describe("GoogleVertexPlugin", () => { const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))) yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), modelID: ModelV2.ID.make("gemini"), package: "aisdk:@ai-sdk/google-vertex", }), @@ -294,7 +294,7 @@ describe("GoogleVertexPlugin", () => { yield* addPlugin() yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), modelID: ModelV2.ID.make("gemini"), package: "aisdk:@ai-sdk/google-vertex", }), @@ -339,7 +339,7 @@ describe("GoogleVertexPlugin", () => { () => aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")), modelID: ModelV2.ID.make("gemini"), package: "aisdk:@ai-sdk/openai-compatible", }), @@ -367,7 +367,7 @@ describe("GoogleVertexPlugin", () => { yield* addPlugin() yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")), + ...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")), modelID: ModelV2.ID.make(" gemini-2.5-pro "), package: "aisdk:test-provider", }), diff --git a/packages/core/test/plugin/provider-google.test.ts b/packages/core/test/plugin/provider-google.test.ts index d04e00e3b3..2dea12a2e6 100644 --- a/packages/core/test/plugin/provider-google.test.ts +++ b/packages/core/test/plugin/provider-google.test.ts @@ -26,7 +26,7 @@ describe("GooglePlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")), modelID: ModelV2.ID.make("gemini"), package: "aisdk:@ai-sdk/google", }), @@ -45,7 +45,7 @@ describe("GooglePlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")), + ...ModelV2.Info.default(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")), modelID: ModelV2.ID.make("gemini"), package: "aisdk:@ai-sdk/google", }), @@ -63,7 +63,7 @@ describe("GooglePlugin", () => { yield* addPlugin() const sdkEvent = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("gemini-api"), package: "aisdk:@ai-sdk/google", }), @@ -88,7 +88,7 @@ describe("GooglePlugin", () => { const resolved = yield* aisdk.model( ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("gemini-api"), package: "aisdk:@ai-sdk/google", settings: { apiKey: "test" }, diff --git a/packages/core/test/plugin/provider-groq.test.ts b/packages/core/test/plugin/provider-groq.test.ts deleted file mode 100644 index b8900384b7..0000000000 --- a/packages/core/test/plugin/provider-groq.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect } from "bun:test" -import { createGroq } from "@ai-sdk/groq" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* GroqPlugin.effect(host) -}) - -describe("GroqPlugin", () => { - it.effect("creates a Groq SDK for @ai-sdk/groq", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), - modelID: ModelV2.ID.make("llama"), - package: "aisdk:@ai-sdk/groq", - }), - package: "@ai-sdk/groq", - options: { name: "groq" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("ignores non-Groq SDK packages", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), - modelID: ModelV2.ID.make("llama"), - package: "aisdk:@ai-sdk/groq", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "groq" }, - }) - expect(result.sdk).toBeUndefined() - }), - ) - - it.effect("only matches the bundled @ai-sdk/groq package exactly", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("llama")), - modelID: ModelV2.ID.make("llama"), - package: "aisdk:@ai-sdk/groq", - }), - package: "@ai-sdk/groq/compat", - options: { name: "groq" }, - }) - expect(result.sdk).toBeUndefined() - }), - ) - - it.effect("matches the old bundled Groq SDK provider naming", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-groq"), ModelV2.ID.make("llama")), - modelID: ModelV2.ID.make("llama"), - package: "aisdk:@ai-sdk/groq", - }), - package: "@ai-sdk/groq", - options: { name: "custom-groq", apiKey: "test" }, - }) - const expected = createGroq({ name: "custom-groq", apiKey: "test" } as Parameters[0] & { - name: string - }).languageModel("llama") - const actual = result.sdk?.languageModel("llama") - expect(actual?.provider).toBe(expected.provider) - expect(actual?.modelId).toBe(expected.modelId) - }), - ) - - it.effect("uses the default languageModel(modelID) behavior", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const sdk = createGroq({ name: "groq", apiKey: "test" } as Parameters[0] & { - name: string - }) - const result = yield* aisdk.runLanguage({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("groq"), ModelV2.ID.make("alias")), - modelID: ModelV2.ID.make("llama-api"), - package: "aisdk:@ai-sdk/groq", - }), - sdk, - options: { name: "groq", apiKey: "test" }, - }) - const language = result.language ?? sdk.languageModel(result.model.modelID ?? result.model.id) - expect(language.modelId).toBe("llama-api") - expect(language.provider).toBe("groq.chat") - }), - ) -}) diff --git a/packages/core/test/plugin/provider-mistral.test.ts b/packages/core/test/plugin/provider-mistral.test.ts deleted file mode 100644 index 182873482c..0000000000 --- a/packages/core/test/plugin/provider-mistral.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { describe, expect } from "bun:test" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* MistralPlugin.effect(host) -}) - -describe("MistralPlugin", () => { - it.effect("creates a Mistral SDK for @ai-sdk/mistral", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), - modelID: ModelV2.ID.make("mistral-large"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/mistral", - options: { name: "mistral" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("ignores non-Mistral SDK packages", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), - modelID: ModelV2.ID.make("mistral-large"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "mistral" }, - }) - expect(result.sdk).toBeUndefined() - }), - ) - - it.effect("matches the old bundled Mistral SDK provider name for the bundled provider ID", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const providers: string[] = [] - yield* addPlugin() - yield* aisdk.hook.sdk((event) => - Effect.sync(() => { - providers.push(event.sdk.languageModel("mistral-large").provider) - }), - ) - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("mistral-large")), - modelID: ModelV2.ID.make("mistral-large"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/mistral", - options: { name: "mistral" }, - }) - expect(result.sdk).toBeDefined() - expect(providers).toEqual(["mistral.chat"]) - }), - ) - - it.effect("matches the old bundled Mistral SDK provider name for custom provider IDs", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const providers: string[] = [] - yield* addPlugin() - yield* aisdk.hook.sdk((event) => - Effect.sync(() => { - providers.push(event.sdk.languageModel("mistral-large").provider) - }), - ) - yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-mistral"), ModelV2.ID.make("mistral-large")), - modelID: ModelV2.ID.make("mistral-large"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/mistral", - options: { name: "custom-mistral" }, - }) - expect(providers).toEqual(["mistral.chat"]) - }), - ) - - it.effect("leaves Mistral language selection on the default sdk.languageModel(modelID) path", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const calls: string[] = [] - const sdk = { - languageModel: (id: string) => { - calls.push(`languageModel:${id}`) - return { modelId: id, provider: "languageModel", specificationVersion: "v3" } as unknown as LanguageModelV3 - }, - } - yield* addPlugin() - const result = yield* aisdk.runLanguage({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("mistral"), ModelV2.ID.make("alias")), - modelID: ModelV2.ID.make("mistral-large"), - package: "aisdk:test-provider", - }), - sdk, - options: {}, - }) - const language = result.language ?? sdk.languageModel(result.model.modelID ?? result.model.id) - expect(calls).toEqual(["languageModel:mistral-large"]) - expect(language).toBeDefined() - }), - ) -}) diff --git a/packages/core/test/plugin/provider-openai-compatible.test.ts b/packages/core/test/plugin/provider-openai-compatible.test.ts index a954af9f00..e1cf1ed6c8 100644 --- a/packages/core/test/plugin/provider-openai-compatible.test.ts +++ b/packages/core/test/plugin/provider-openai-compatible.test.ts @@ -26,7 +26,7 @@ describe("OpenAICompatiblePlugin", () => { yield* addPlugin() const defaulted = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), modelID: ModelV2.ID.make("model"), package: "aisdk:test-provider", }), @@ -35,7 +35,7 @@ describe("OpenAICompatiblePlugin", () => { }) const disabled = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), modelID: ModelV2.ID.make("model"), package: "aisdk:test-provider", }), @@ -54,7 +54,7 @@ describe("OpenAICompatiblePlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")), modelID: ModelV2.ID.make("model"), package: "aisdk:test-provider", }), @@ -78,7 +78,7 @@ describe("OpenAICompatiblePlugin", () => { ) yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")), modelID: ModelV2.ID.make("model"), package: "aisdk:test-provider", }), @@ -99,7 +99,7 @@ describe("OpenAICompatiblePlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")), + ...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")), modelID: ModelV2.ID.make("model"), package: "aisdk:test-provider", }), diff --git a/packages/core/test/plugin/provider-openai.test.ts b/packages/core/test/plugin/provider-openai.test.ts index cd0c943442..741966eebe 100644 --- a/packages/core/test/plugin/provider-openai.test.ts +++ b/packages/core/test/plugin/provider-openai.test.ts @@ -68,7 +68,7 @@ describe("OpenAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -86,7 +86,7 @@ describe("OpenAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -105,7 +105,7 @@ describe("OpenAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("gpt-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -125,7 +125,7 @@ describe("OpenAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")), modelID: ModelV2.ID.make("gpt-5"), package: ProviderV2.aisdk("test-provider"), }), diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 1de22a03dd..f0b9fff8d7 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -293,7 +293,7 @@ describe("OpencodePlugin", () => { package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ - ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + ...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")), modelID: ModelV2.ID.make("paid"), package: ProviderV2.aisdk("test-provider"), cost: cost(1), @@ -320,7 +320,7 @@ describe("OpencodePlugin", () => { package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ - ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")), + ...ModelV2.Info.default(provider.id, ModelV2.ID.make("free")), modelID: ModelV2.ID.make("free"), package: ProviderV2.aisdk("test-provider"), cost: cost(0), @@ -347,7 +347,7 @@ describe("OpencodePlugin", () => { package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ - ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")), + ...ModelV2.Info.default(provider.id, ModelV2.ID.make("output-only")), modelID: ModelV2.ID.make("output-only"), package: ProviderV2.aisdk("test-provider"), cost: cost(0, 1), @@ -376,7 +376,7 @@ describe("OpencodePlugin", () => { package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ - ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + ...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")), modelID: ModelV2.ID.make("paid"), package: ProviderV2.aisdk("test-provider"), cost: cost(1), @@ -410,7 +410,7 @@ describe("OpencodePlugin", () => { package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ - ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + ...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")), modelID: ModelV2.ID.make("paid"), package: ProviderV2.aisdk("test-provider"), cost: cost(1), @@ -438,7 +438,7 @@ describe("OpencodePlugin", () => { settings: { apiKey: "configured" }, }) const model = ModelV2.Info.make({ - ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + ...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")), modelID: ModelV2.ID.make("paid"), package: ProviderV2.aisdk("test-provider"), cost: cost(1), @@ -468,7 +468,7 @@ describe("OpencodePlugin", () => { package: ProviderV2.aisdk("test-provider"), }) const model = ModelV2.Info.make({ - ...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")), + ...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")), modelID: ModelV2.ID.make("paid"), package: ProviderV2.aisdk("test-provider"), cost: cost(1), diff --git a/packages/core/test/plugin/provider-openrouter.test.ts b/packages/core/test/plugin/provider-openrouter.test.ts index 21ee3ae216..63520e01d3 100644 --- a/packages/core/test/plugin/provider-openrouter.test.ts +++ b/packages/core/test/plugin/provider-openrouter.test.ts @@ -54,7 +54,7 @@ describe("OpenRouterPlugin", () => { const ignored = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: ProviderV2.aisdk("test-provider"), }), @@ -65,7 +65,7 @@ describe("OpenRouterPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")), modelID: ModelV2.ID.make("openai/gpt-5"), package: ProviderV2.aisdk("test-provider"), }), diff --git a/packages/core/test/plugin/provider-perplexity.test.ts b/packages/core/test/plugin/provider-perplexity.test.ts deleted file mode 100644 index d66f5dd71c..0000000000 --- a/packages/core/test/plugin/provider-perplexity.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect } from "bun:test" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* PerplexityPlugin.effect(host) -}) - -function fakeSelectorSdk(calls: string[]) { - const make = (method: string) => (id: string) => { - calls.push(`${method}:${id}`) - return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 - } - return { - responses: make("responses"), - messages: make("messages"), - chat: make("chat"), - languageModel: make("languageModel"), - } -} - -describe("PerplexityPlugin", () => { - it.effect("creates a Perplexity SDK for the exact @ai-sdk/perplexity package", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), - modelID: ModelV2.ID.make("sonar"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/perplexity", - options: { name: "perplexity" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("ignores packages that are not the bundled Perplexity package", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), - modelID: ModelV2.ID.make("sonar"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/perplexity-compatible", - options: { name: "perplexity" }, - }) - expect(result.sdk).toBeUndefined() - }), - ) - - it.effect("uses the Perplexity provider ID as the SDK name for the bundled provider", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("sonar")), - modelID: ModelV2.ID.make("sonar"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/perplexity", - options: { name: "perplexity" }, - }) - expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") - }), - ) - - it.effect("creates bundled Perplexity SDKs for custom provider IDs", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-perplexity"), ModelV2.ID.make("sonar")), - modelID: ModelV2.ID.make("sonar"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/perplexity", - options: { name: "custom-perplexity" }, - }) - expect(result.sdk.languageModel("sonar").provider).toBe("perplexity") - }), - ) - - it.effect("leaves Perplexity language selection to the default languageModel fallback", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const calls: string[] = [] - yield* addPlugin() - const result = yield* aisdk.runLanguage({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("perplexity"), ModelV2.ID.make("alias")), - modelID: ModelV2.ID.make("sonar"), - package: "aisdk:test-provider", - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }) - expect(calls).toEqual([]) - expect(result.language).toBeUndefined() - }), - ) -}) diff --git a/packages/core/test/plugin/provider-sap-ai-core.test.ts b/packages/core/test/plugin/provider-sap-ai-core.test.ts index 26dc3ac86c..09d99867f2 100644 --- a/packages/core/test/plugin/provider-sap-ai-core.test.ts +++ b/packages/core/test/plugin/provider-sap-ai-core.test.ts @@ -48,7 +48,7 @@ function withEnv(vars: Record, effect: () = function model(providerID: string) { return ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")), + ...ModelV2.Info.default(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")), modelID: ModelV2.ID.make("sap-model"), package: ProviderV2.aisdk(fixtureProvider), }) diff --git a/packages/core/test/plugin/provider-snowflake-cortex.test.ts b/packages/core/test/plugin/provider-snowflake-cortex.test.ts index 92af623756..749f4c5520 100644 --- a/packages/core/test/plugin/provider-snowflake-cortex.test.ts +++ b/packages/core/test/plugin/provider-snowflake-cortex.test.ts @@ -58,7 +58,7 @@ describe("SnowflakeCortexPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")), + ...ModelV2.Info.default(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")), modelID: ModelV2.ID.make("gpt-4"), package: "aisdk:test-provider", }), @@ -77,7 +77,7 @@ describe("SnowflakeCortexPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + ...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), modelID: ModelV2.ID.make("claude-sonnet-4-6"), package: "aisdk:test-provider", }), @@ -97,7 +97,7 @@ describe("SnowflakeCortexPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + ...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), modelID: ModelV2.ID.make("claude-sonnet-4-6"), package: "aisdk:test-provider", }), @@ -121,7 +121,7 @@ describe("SnowflakeCortexPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + ...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), modelID: ModelV2.ID.make("claude-sonnet-4-6"), package: "aisdk:test-provider", }), @@ -141,7 +141,7 @@ describe("SnowflakeCortexPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + ...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), modelID: ModelV2.ID.make("claude-sonnet-4-6"), package: "aisdk:test-provider", }), @@ -165,7 +165,7 @@ describe("SnowflakeCortexPlugin", () => { yield* addPlugin() const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), + ...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")), modelID: ModelV2.ID.make("claude-sonnet-4-6"), package: "aisdk:test-provider", }), diff --git a/packages/core/test/plugin/provider-togetherai.test.ts b/packages/core/test/plugin/provider-togetherai.test.ts deleted file mode 100644 index 1fffb03156..0000000000 --- a/packages/core/test/plugin/provider-togetherai.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect } from "bun:test" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { TogetherAIPlugin } from "@opencode-ai/core/plugin/provider/togetherai" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* TogetherAIPlugin.effect(host) -}) - -function fakeSelectorSdk(calls: string[]) { - const make = (method: string) => (id: string) => { - calls.push(`${method}:${id}`) - return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 - } - return { - responses: make("responses"), - messages: make("messages"), - chat: make("chat"), - languageModel: make("languageModel"), - } -} - -describe("TogetherAIPlugin", () => { - it.effect("creates a TogetherAI SDK for @ai-sdk/togetherai", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/togetherai", - options: { name: "togetherai" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("matches the old bundled provider package exactly", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - - const ignored = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "file:///tmp/@ai-sdk/togetherai-provider.js", - options: { name: "togetherai" }, - }) - expect(ignored.sdk).toBeUndefined() - - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("togetherai"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/togetherai", - options: { name: "togetherai" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("creates bundled TogetherAI SDKs for custom provider IDs", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-togetherai"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/togetherai", - options: { name: "custom-togetherai" }, - }) - - expect(result.sdk.languageModel("model").provider).toBe("togetherai.chat") - }), - ) - - it.effect("defaults language selection to sdk.languageModel with the model API ID", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const calls: string[] = [] - yield* addPlugin() - - const result = yield* aisdk.runLanguage({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty( - ProviderV2.ID.make("togetherai"), - ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), - ), - modelID: ModelV2.ID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"), - package: "aisdk:test-provider", - }), - sdk: { languageModel: fakeSelectorSdk(calls).languageModel }, - options: {}, - }) - - expect(result.language).toBeUndefined() - expect(calls).toEqual([]) - expect( - result.language ?? fakeSelectorSdk(calls).languageModel(result.model.modelID ?? result.model.id), - ).toBeDefined() - expect(calls).toEqual(["languageModel:meta-llama/Llama-3.3-70B-Instruct-Turbo"]) - }), - ) -}) diff --git a/packages/core/test/plugin/provider-venice.test.ts b/packages/core/test/plugin/provider-venice.test.ts deleted file mode 100644 index 057d2c6ded..0000000000 --- a/packages/core/test/plugin/provider-venice.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { AISDK } from "@opencode-ai/core/aisdk" -import { describe, expect } from "bun:test" -import type { LanguageModelV3 } from "@ai-sdk/provider" -import { Effect } from "effect" -import { ModelV2 } from "@opencode-ai/core/model" -import { PluginV2 } from "@opencode-ai/core/plugin" -import { PluginHost } from "@opencode-ai/core/plugin/host" -import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice" -import { ProviderV2 } from "@opencode-ai/core/provider" -import { testEffect } from "../lib/effect" -import { PluginTestLayer } from "./fixture" - -const it = testEffect(PluginTestLayer) - -const addPlugin = Effect.fn(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const host = yield* PluginHost.make(plugin) - yield* VenicePlugin.effect(host) -}) - -function fakeSelectorSdk(calls: string[]) { - const make = (method: string) => (id: string) => { - calls.push(`${method}:${id}`) - return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3 - } - return { - responses: make("responses"), - messages: make("messages"), - chat: make("chat"), - languageModel: make("languageModel"), - } -} - -describe("VenicePlugin", () => { - it.effect("creates a Venice SDK for venice-ai-sdk-provider", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "venice-ai-sdk-provider", - options: { name: "venice" }, - }) - expect(result.sdk).toBeDefined() - }), - ) - - it.effect("uses the model provider ID as the bundled Venice SDK name", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const result = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-venice"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "venice-ai-sdk-provider", - options: { name: "custom-venice", apiKey: "test" }, - }) - expect(result.sdk).toBeDefined() - expect(result.sdk.languageModel("model").provider).toBe("custom-venice.chat") - }), - ) - - it.effect("only handles the bundled venice-ai-sdk-provider package", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - yield* addPlugin() - const similar = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "file:///tmp/venice-ai-sdk-provider.js", - options: { name: "venice" }, - }) - const other = yield* aisdk.runSDK({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("model")), - modelID: ModelV2.ID.make("model"), - package: "aisdk:test-provider", - }), - package: "@ai-sdk/openai-compatible", - options: { name: "venice" }, - }) - expect(similar.sdk).toBeUndefined() - expect(other.sdk).toBeUndefined() - }), - ) - - it.effect("leaves Venice language selection to the default languageModel fallback", () => - Effect.gen(function* () { - const plugin = yield* PluginV2.Service - const aisdk = yield* AISDK.Service - const calls: string[] = [] - yield* addPlugin() - const result = yield* aisdk.runLanguage({ - model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("venice"), ModelV2.ID.make("alias")), - modelID: ModelV2.ID.make("alias"), - package: "aisdk:test-provider", - }), - sdk: fakeSelectorSdk(calls), - options: {}, - }) - expect(calls).toEqual([]) - expect(result.language).toBeUndefined() - }), - ) -}) diff --git a/packages/core/test/plugin/provider-vercel.test.ts b/packages/core/test/plugin/provider-vercel.test.ts index 46d5fe25b8..29820b714c 100644 --- a/packages/core/test/plugin/provider-vercel.test.ts +++ b/packages/core/test/plugin/provider-vercel.test.ts @@ -59,7 +59,7 @@ describe("VercelPlugin", () => { yield* addPlugin() const event = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")), modelID: ModelV2.ID.make("v0-1.0-md"), package: "aisdk:@ai-sdk/vercel", }), diff --git a/packages/core/test/plugin/provider-xai.test.ts b/packages/core/test/plugin/provider-xai.test.ts index 04bfc508f7..4b3e672c1e 100644 --- a/packages/core/test/plugin/provider-xai.test.ts +++ b/packages/core/test/plugin/provider-xai.test.ts @@ -62,7 +62,7 @@ describe("XAIPlugin", () => { const ignored = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + ...ModelV2.Info.default(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), modelID: ModelV2.ID.make("grok-4"), package: "aisdk:@ai-sdk/xai", }), @@ -72,7 +72,7 @@ describe("XAIPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), + ...ModelV2.Info.default(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")), modelID: ModelV2.ID.make("grok-4"), package: "aisdk:@ai-sdk/xai", }), @@ -92,7 +92,7 @@ describe("XAIPlugin", () => { const result = yield* aisdk.runSDK({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")), + ...ModelV2.Info.default(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")), modelID: ModelV2.ID.make("grok-4"), package: "aisdk:@ai-sdk/xai", }), @@ -112,7 +112,7 @@ describe("XAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")), + ...ModelV2.Info.default(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")), modelID: ModelV2.ID.make("grok-4"), package: "aisdk:@ai-sdk/xai", }), @@ -133,7 +133,7 @@ describe("XAIPlugin", () => { yield* addPlugin() const result = yield* aisdk.runLanguage({ model: ModelV2.Info.make({ - ...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")), + ...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")), modelID: ModelV2.ID.make("grok-4"), package: "aisdk:@ai-sdk/xai", }), diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index d37cc83c12..3ad15fc730 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -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( diff --git a/packages/core/test/session-generate.test.ts b/packages/core/test/session-generate.test.ts index 3f0183167c..e78f41bdac 100644 --- a/packages/core/test/session-generate.test.ts +++ b/packages/core/test/session-generate.test.ts @@ -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( @@ -300,7 +301,7 @@ it.effect("generates from fresh settled Session context without durable mutation ), ).toEqual(["Settled partial answer"]) expect(requests[0]?.tools).toMatchObject([{ name: "lookup", description: "Hooked lookup" }]) - expect(requests[0]?.toolChoice).toMatchObject({ type: "none" }) + expect(requests[0]?.toolChoice).toBeUndefined() expect(yield* durableState(db, sessionID)).toEqual(before) }), ) diff --git a/packages/core/test/session-runner-recorded.test.ts b/packages/core/test/session-runner-recorded.test.ts index d26fa37df4..fb3619e4c6 100644 --- a/packages/core/test/session-runner-recorded.test.ts +++ b/packages/core/test/session-runner-recorded.test.ts @@ -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) }) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 6ce9cf0f20..86e9d7c576 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -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 diff --git a/packages/core/test/shared-schema.test.ts b/packages/core/test/shared-schema.test.ts index 37b67c63a8..937cd3e1f0 100644 --- a/packages/core/test/shared-schema.test.ts +++ b/packages/core/test/shared-schema.test.ts @@ -170,8 +170,8 @@ test("Core reuses the canonical shared schemas", async () => { for (const [core, shared] of schemas) expect(core).toBe(shared) expect(Agent.Info.empty(Agent.ID.make("test"))).toEqual(AgentV2.Info.empty(AgentV2.ID.make("test"))) - expect(Model.Info.empty(Provider.ID.make("test"), Model.ID.make("model"))).toEqual( - ModelV2.Info.empty(ProviderV2.ID.make("test"), ModelV2.ID.make("model")), + expect(Model.Info.default(Provider.ID.make("test"), Model.ID.make("model"))).toEqual( + ModelV2.Info.default(ProviderV2.ID.make("test"), ModelV2.ID.make("model")), ) expect(Provider.Info.empty(Provider.ID.make("test"))).toEqual(ProviderV2.Info.empty(ProviderV2.ID.make("test"))) expect(Skill.Source.key(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/tmp") }))).toBe( diff --git a/packages/core/test/tool-patch.test.ts b/packages/core/test/tool-patch.test.ts index f43b49342c..86ae95e183 100644 --- a/packages/core/test/tool-patch.test.ts +++ b/packages/core/test/tool-patch.test.ts @@ -387,17 +387,6 @@ describe("PatchTool", () => { ), ) - it.live("updates an empty file", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "empty.txt") - yield* Effect.promise(() => fs.writeFile(target, "")) - yield* executeTool(registry, call("*** Begin Patch\n*** Update File: empty.txt\n@@\n+First line\n*** End Patch")) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("First line\n") - }), - ), - ) - it.live("rejects deleting a directory", () => withTempTool((directory, registry) => Effect.gen(function* () { @@ -410,40 +399,6 @@ describe("PatchTool", () => { ), ) - it.live("supports an end-of-file anchor", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "tail.txt") - yield* Effect.promise(() => fs.writeFile(target, "first\nsecond")) - yield* executeTool( - registry, - call( - "*** Begin Patch\n*** Update File: tail.txt\n@@\n first\n-second\n+second updated\n*** End of File\n*** End Patch", - ), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("first\nsecond updated\n") - }), - ), - ) - - it.live("applies an end-of-file chunk to the final duplicate", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "duplicates.txt") - yield* Effect.promise(() => fs.writeFile(target, "marker\nend\nmiddle\nmarker\nend\n")) - yield* executeTool( - registry, - call( - "*** Begin Patch\n*** Update File: duplicates.txt\n@@\n-marker\n-end\n+marker changed\n+end\n*** End of File\n*** End Patch", - ), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe( - "marker\nend\nmiddle\nmarker changed\nend\n", - ) - }), - ), - ) - it.live("rejects a missing second chunk context", () => withTempTool((directory, registry) => Effect.gen(function* () { @@ -513,20 +468,6 @@ describe("PatchTool", () => { ), ) - it.live("applies multiple hunks to one file", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "multi.txt") - yield* Effect.promise(() => fs.writeFile(target, "a\nb\nc\nd\n")) - yield* executeTool( - registry, - call("*** Begin Patch\n*** Update File: multi.txt\n@@\n-b\n+B\n@@\n-d\n+D\n*** End Patch"), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("a\nB\nc\nD\n") - }), - ), - ) - it.live("applies successive update operations to one file", () => withTempTool((directory, registry) => Effect.gen(function* () { @@ -566,110 +507,6 @@ describe("PatchTool", () => { ), ) - it.live("appends a trailing newline on update", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "no-newline.txt") - yield* Effect.promise(() => fs.writeFile(target, "no newline at end")) - yield* executeTool( - registry, - call( - "*** Begin Patch\n*** Update File: no-newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch", - ), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("first line\nsecond line\n") - }), - ), - ) - - it.live("disambiguates change context with an @@ header", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "context.txt") - yield* Effect.promise(() => fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n")) - yield* executeTool( - registry, - call("*** Begin Patch\n*** Update File: context.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch"), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe( - "fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n", - ) - }), - ), - ) - - it.live("parses a heredoc-wrapped patch", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - yield* executeTool( - registry, - call("cat <<'EOF'\n*** Begin Patch\n*** Add File: heredoc.txt\n+with cat\n*** End Patch\nEOF"), - ) - expect(yield* Effect.promise(() => fs.readFile(path.join(directory, "heredoc.txt"), "utf8"))).toBe( - "with cat\n", - ) - }), - ), - ) - - it.live("parses a heredoc-wrapped patch without cat", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - yield* executeTool( - registry, - call("< fs.readFile(path.join(directory, "heredoc.txt"), "utf8"))).toBe( - "without cat\n", - ) - }), - ), - ) - - it.live("matches with trailing whitespace differences", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "trailing.txt") - yield* Effect.promise(() => fs.writeFile(target, "line1 \nline2\nline3 \n")) - yield* executeTool( - registry, - call("*** Begin Patch\n*** Update File: trailing.txt\n@@\n-line2\n+changed\n*** End Patch"), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("line1 \nchanged\nline3 \n") - }), - ), - ) - - it.live("matches with leading whitespace differences", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "leading.txt") - yield* Effect.promise(() => fs.writeFile(target, " line1\nline2\n line3\n")) - yield* executeTool( - registry, - call("*** Begin Patch\n*** Update File: leading.txt\n@@\n-line2\n+changed\n*** End Patch"), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe(" line1\nchanged\n line3\n") - }), - ), - ) - - it.live("matches with Unicode punctuation differences", () => - withTempTool((directory, registry) => - Effect.gen(function* () { - const target = path.join(directory, "unicode.txt") - yield* Effect.promise(() => fs.writeFile(target, "He said “hello”\nsome—dash\nend\n")) - yield* executeTool( - registry, - call( - '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch', - ), - ) - expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe('He said "hi"\nsome—dash\nend\n') - }), - ), - ) - it.live("rejects an update with missing context", () => withTempTool((directory, registry) => Effect.gen(function* () { diff --git a/packages/core/test/tool-search.test.ts b/packages/core/test/tool-search.test.ts index 41e25953c4..4367d1b82e 100644 --- a/packages/core/test/tool-search.test.ts +++ b/packages/core/test/tool-search.test.ts @@ -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`) }), diff --git a/packages/docs/README.md b/packages/docs/README.md index 17b06848e1..1aff8cf6dc 100644 --- a/packages/docs/README.md +++ b/packages/docs/README.md @@ -19,4 +19,15 @@ bun validate bun broken-links ``` +The V2 theme token reference is generated from +`packages/tui/src/theme/v2/schema.ts`. Regenerate it after schema changes: + +```bash +bun run generate +``` + +`bun validate` checks that the committed snippet is current. The repository's +generation workflow also refreshes it on pushes to `dev`, so Mintlify always +receives the generated MDX as part of the published docs tree. + The hosted preview is available at [opencode.mintlify.site](https://opencode.mintlify.site). diff --git a/packages/docs/agents.mdx b/packages/docs/agents.mdx index a81ae6ec3c..ca7ee80a20 100644 --- a/packages/docs/agents.mdx +++ b/packages/docs/agents.mdx @@ -89,7 +89,7 @@ becomes `system`: description: Reviews changes without modifying files mode: subagent model: anthropic/claude-sonnet-4-5#high -color: warning +color: "#ff6b6b" steps: 8 permissions: - action: edit @@ -118,7 +118,7 @@ Use the `agents` field in any [OpenCode configuration file](/config): "mode": "all", "model": "anthropic/claude-sonnet-4-5#high", "system": "Review the current changes. Report findings before any summary.", - "color": "warning", + "color": "#ff6b6b", "steps": 8, "permissions": [ { "action": "edit", "resource": "*", "effect": "deny" }, @@ -250,8 +250,7 @@ security boundary. ### `color` -Sets the agent's UI color. Use a six-digit hex color such as `#ff6b6b`, or one -of `primary`, `secondary`, `accent`, `success`, `warning`, `error`, or `info`. +Sets the agent's UI color. Use a six-digit hex color such as `#ff6b6b`. ### `disabled` diff --git a/packages/docs/docs.json b/packages/docs/docs.json index ff41ab4b92..a9349f2963 100644 --- a/packages/docs/docs.json +++ b/packages/docs/docs.json @@ -38,6 +38,7 @@ "attachments", "compaction", "warming", + "themes", "formatters", "lsp", "references" diff --git a/packages/docs/index.mdx b/packages/docs/index.mdx index 9095853903..bbb3ccfdfe 100644 --- a/packages/docs/index.mdx +++ b/packages/docs/index.mdx @@ -158,6 +158,6 @@ limitations and safety details. ## Customize -Make OpenCode your own by [picking a theme](https://opencode.ai/docs/themes), [customizing +Make OpenCode your own by [picking a theme](/themes), [customizing keybinds](https://opencode.ai/docs/keybinds), [configuring formatters](/formatters), [creating commands](/commands), or editing the [OpenCode config](/config). diff --git a/packages/docs/models.mdx b/packages/docs/models.mdx index 60fd63812a..213bfac523 100644 --- a/packages/docs/models.mdx +++ b/packages/docs/models.mdx @@ -94,9 +94,10 @@ You can also map a friendly catalog ID to a different API model ID with `modelID } ``` -Here `openai/coding-default` is the selectable catalog reference, while `gpt-5.2` is sent to the provider. When adding a -model that is not already in the catalog, set accurate `capabilities` and `limit` values so OpenCode can expose tools and -enforce the correct context limits. Set `disabled: true` on a model entry to hide it from the available catalog. +Here `openai/coding-default` is the selectable catalog reference, while `gpt-5.2` is sent to the provider. A model that is +not already in the catalog defaults to tool support, text and image input, and text output. Set accurate `capabilities` +and `limit` values when those defaults do not match the model or OpenCode needs to enforce its context limits. Set +`disabled: true` on a model entry to hide it from the available catalog. OpenAI-compatible models that stream reasoning through a custom assistant-message field can set `compatibility.reasoningField`: @@ -193,8 +194,9 @@ For an OpenAI-compatible server, define a provider package, endpoint, and at lea } ``` -Use the server's real model name, limits, modalities, and tool support. OpenCode cannot infer these for a model you add -manually. If the endpoint requires a key, add `apiKey` to provider `settings` using an environment substitution such as +Use the server's real model name, limits, modalities, and tool support. OpenCode applies the custom-model capability +defaults described above but cannot infer the server's actual limits or whether those defaults are accurate. If the +endpoint requires a key, add `apiKey` to provider `settings` using an environment substitution such as `"apiKey": "{env:LOCAL_API_KEY}"`; do not commit secrets. ### Model references diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index 543a418173..d430f5d21d 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -10561,26 +10561,10 @@ "additionalProperties": false }, "Agent.Color": { - "anyOf": [ + "type": "string", + "allOf": [ { - "type": "string", - "allOf": [ - { - "pattern": "^#[0-9a-fA-F]{6}$" - } - ] - }, - { - "type": "string", - "enum": [ - "primary", - "secondary", - "accent", - "success", - "warning", - "error", - "info" - ] + "pattern": "^#[0-9a-fA-F]{6}$" } ] }, diff --git a/packages/docs/package.json b/packages/docs/package.json index 01f35998c5..b8a9ead7ca 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -3,11 +3,15 @@ "name": "@opencode-ai/docs", "private": true, "scripts": { - "dev": "bun --bun mint dev --no-open --port 3333", - "validate": "bun --bun mint validate", + "dev": "bun run generate && bun --bun mint dev --no-open --port 3333", + "generate": "bun script/generate-theme-tokens.ts", + "check:generated": "bun script/generate-theme-tokens.ts --check", + "validate": "bun run check:generated && bun --bun mint validate", "broken-links": "bun --bun mint broken-links" }, "devDependencies": { - "mint": "4.2.666" + "effect": "catalog:", + "mint": "4.2.666", + "prettier": "3.6.2" } } diff --git a/packages/docs/script/generate-theme-tokens.ts b/packages/docs/script/generate-theme-tokens.ts new file mode 100644 index 0000000000..25f614d119 --- /dev/null +++ b/packages/docs/script/generate-theme-tokens.ts @@ -0,0 +1,136 @@ +#!/usr/bin/env bun + +import { Schema, SchemaAST } from "effect" +import { format } from "prettier" +import { ThemeDefinition, ThemeDocument } from "../../tui/src/theme/v2/schema" + +const target = import.meta.dir + "/../snippets/generated/theme-tokens.mdx" +const root = requireObject(ThemeDefinition.ast) +const hue = requireObject(requireField(root, "hue").type) +const hueNames = hue.propertySignatures.map((field) => String(field.name)) +const hueSteps = requireObject(requireField(hue, hueNames[0]).type).propertySignatures.map((field) => + String(field.name), +) +const contexts = root.propertySignatures + .map((field) => String(field.name)) + .filter((name) => name.startsWith("@context:")) +const tokens = root.propertySignatures + .filter((field) => { + const name = String(field.name) + return name !== "hue" && name !== "categorical" && !name.startsWith("@context:") + }) + .flatMap((field) => tokenPaths(field.type, String(field.name))) +const groups = Map.groupBy(tokens, (token) => + token + .split(".") + .slice(0, token.split(".").length > 2 ? 2 : 1) + .join("."), +) +const table = [...groups] + .map(([group, values]) => `| \`${group}\` | ${values.map((value) => `\`${value}\``).join("
")} |`) + .join("\n") +const example = { + version: 2, + light: { + hue: { + accent: "$hue.purple", + interactive: "$hue.purple", + }, + text: { + default: "$hue.neutral.900", + }, + background: { + default: "#fafafa", + }, + }, + dark: { + mergeMode: true, + text: { + default: "$hue.neutral.100", + }, + background: { + default: "#101014", + }, + }, +} satisfies ThemeDocument +Schema.decodeUnknownSync(ThemeDocument)(example) +const output = await format( + `{/* Generated by packages/docs/script/generate-theme-tokens.ts. Do not edit. */} + +\`\`\`json title="my-theme.json" +${JSON.stringify(example, null, 2)} +\`\`\` + +## Token reference + +This reference is generated from the Effect schema in +\`packages/tui/src/theme/v2/schema.ts\`. Changes to the runtime schema update +this section through \`bun run generate\`. + +### Hue tokens + +Every hue is a ${hueSteps.length}-step scale. Define a scale with all of these +steps, or alias it to another hue with a value such as \`$hue.blue\`. + +| | Values | +| --- | --- | +| Hues | ${hueNames.map((name) => `\`${name}\``).join(", ")} | +| Steps | ${hueSteps.map((step) => `\`${step}\``).join(", ")} | + +Reference a hue color as \`$hue..\`, for example +\`$hue.interactive.500\`. + +### Semantic tokens + +Semantic values can reference another token by prefixing its path with \`$\`, +for example \`$text.default\`. Stateful tokens inherit their \`default\` +value when a state is omitted. + +| Group | Tokens | +| --- | --- | +${table} + +### Contexts + +${contexts.map((context) => `\`${context}\``).join(" and ")} accept partial +overrides of the semantic tokens above. Components apply these contexts to +surfaces that need different contrast without changing the base theme. +`, + { parser: "mdx", printWidth: 120, semi: false }, +) + +if (process.argv.includes("--check")) { + const current = await Bun.file(target).text() + if (current === output) process.exit(0) + console.error("Generated theme token documentation is stale. Run `bun run generate` from packages/docs.") + process.exit(1) +} + +await Bun.write(target, output) + +function requireObject(ast: SchemaAST.AST): SchemaAST.Objects { + if (SchemaAST.isObjects(ast)) return ast + if (SchemaAST.isUnion(ast)) { + const object = ast.types.map(findObject).find((value) => value !== undefined) + if (object) return object + } + throw new Error(`Expected an object schema, received ${ast._tag}`) +} + +function findObject(ast: SchemaAST.AST): SchemaAST.Objects | undefined { + if (SchemaAST.isObjects(ast)) return ast + if (SchemaAST.isUnion(ast)) return ast.types.map(findObject).find((value) => value !== undefined) + if (SchemaAST.isSuspend(ast)) return findObject(ast.thunk()) +} + +function requireField(ast: SchemaAST.Objects, name: string) { + const field = ast.propertySignatures.find((field) => String(field.name) === name) + if (field) return field + throw new Error(`Theme schema field not found: ${name}`) +} + +function tokenPaths(ast: SchemaAST.AST, prefix: string): string[] { + const object = findObject(ast) + if (!object || object.propertySignatures.length === 0) return [prefix] + return object.propertySignatures.flatMap((field) => tokenPaths(field.type, `${prefix}.${String(field.name)}`)) +} diff --git a/packages/docs/snippets/generated/theme-tokens.mdx b/packages/docs/snippets/generated/theme-tokens.mdx new file mode 100644 index 0000000000..932072f6cb --- /dev/null +++ b/packages/docs/snippets/generated/theme-tokens.mdx @@ -0,0 +1,79 @@ +{/* Generated by packages/docs/script/generate-theme-tokens.ts. Do not edit. */} + +```json title="my-theme.json" +{ + "version": 2, + "light": { + "hue": { + "accent": "$hue.purple", + "interactive": "$hue.purple" + }, + "text": { + "default": "$hue.neutral.900" + }, + "background": { + "default": "#fafafa" + } + }, + "dark": { + "mergeMode": true, + "text": { + "default": "$hue.neutral.100" + }, + "background": { + "default": "#101014" + } + } +} +``` + +## Token reference + +This reference is generated from the Effect schema in +`packages/tui/src/theme/v2/schema.ts`. Changes to the runtime schema update +this section through `bun run generate`. + +### Hue tokens + +Every hue is a 9-step scale. Define a scale with all of these +steps, or alias it to another hue with a value such as `$hue.blue`. + +| | Values | +| ----- | -------------------------------------------------------------------------------------------------------- | +| Hues | `gray`, `red`, `orange`, `yellow`, `green`, `cyan`, `blue`, `purple`, `accent`, `interactive`, `neutral` | +| Steps | `100`, `200`, `300`, `400`, `500`, `600`, `700`, `800`, `900` | + +Reference a hue color as `$hue..`, for example +`$hue.interactive.500`. + +### Semantic tokens + +Semantic values can reference another token by prefixing its path with `$`, +for example `$text.default`. Stateful tokens inherit their `default` +value when a state is omitted. + +| Group | Tokens | +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `text` | `text.default`
`text.subdued` | +| `text.action` | `text.action.primary.default`
`text.action.primary.$hovered`
`text.action.primary.$focused`
`text.action.primary.$pressed`
`text.action.primary.$selected`
`text.action.primary.$disabled`
`text.action.destructive.default`
`text.action.destructive.$hovered`
`text.action.destructive.$focused`
`text.action.destructive.$pressed`
`text.action.destructive.$selected`
`text.action.destructive.$disabled` | +| `text.formfield` | `text.formfield.default`
`text.formfield.$hovered`
`text.formfield.$focused`
`text.formfield.$pressed`
`text.formfield.$selected`
`text.formfield.$disabled` | +| `text.feedback` | `text.feedback.error.default`
`text.feedback.error.subdued`
`text.feedback.warning.default`
`text.feedback.warning.subdued`
`text.feedback.success.default`
`text.feedback.success.subdued`
`text.feedback.info.default`
`text.feedback.info.subdued` | +| `background` | `background.default` | +| `background.surface` | `background.surface.offset`
`background.surface.overlay` | +| `background.action` | `background.action.primary.default`
`background.action.primary.$hovered`
`background.action.primary.$focused`
`background.action.primary.$pressed`
`background.action.primary.$selected`
`background.action.primary.$disabled`
`background.action.destructive.default`
`background.action.destructive.$hovered`
`background.action.destructive.$focused`
`background.action.destructive.$pressed`
`background.action.destructive.$selected`
`background.action.destructive.$disabled` | +| `background.formfield` | `background.formfield.default`
`background.formfield.$hovered`
`background.formfield.$focused`
`background.formfield.$pressed`
`background.formfield.$selected`
`background.formfield.$disabled` | +| `background.feedback` | `background.feedback.error.default`
`background.feedback.warning.default`
`background.feedback.success.default`
`background.feedback.info.default` | +| `border` | `border.default` | +| `scrollbar` | `scrollbar.default` | +| `diff.text` | `diff.text.added`
`diff.text.removed`
`diff.text.context`
`diff.text.hunkHeader` | +| `diff.background` | `diff.background.added`
`diff.background.removed`
`diff.background.context` | +| `diff.highlight` | `diff.highlight.added`
`diff.highlight.removed` | +| `diff.lineNumber` | `diff.lineNumber.text`
`diff.lineNumber.background.added`
`diff.lineNumber.background.removed` | +| `syntax` | `syntax.comment`
`syntax.keyword`
`syntax.function`
`syntax.variable`
`syntax.string`
`syntax.number`
`syntax.type`
`syntax.operator`
`syntax.punctuation` | +| `markdown` | `markdown.text`
`markdown.heading`
`markdown.link`
`markdown.linkText`
`markdown.code`
`markdown.blockQuote`
`markdown.emphasis`
`markdown.strong`
`markdown.horizontalRule`
`markdown.listItem`
`markdown.listEnumeration`
`markdown.image`
`markdown.imageText`
`markdown.codeBlock` | + +### Contexts + +`@context:elevated` and `@context:overlay` accept partial +overrides of the semantic tokens above. Components apply these contexts to +surfaces that need different contrast without changing the base theme. diff --git a/packages/docs/themes.mdx b/packages/docs/themes.mdx new file mode 100644 index 0000000000..49f41768f6 --- /dev/null +++ b/packages/docs/themes.mdx @@ -0,0 +1,129 @@ +--- +title: "Themes" +description: "Choose a built-in TUI theme or create a custom color scheme." +--- + +import ThemeTokens from "/snippets/generated/theme-tokens.mdx" + +OpenCode includes built-in light and dark themes and can load custom themes +from your global configuration or a project directory. The default theme is +`opencode`. + +## Choose a theme + +In the full-screen TUI, run: + +```text +/themes +``` + +You can also open the picker with `ctrl+x`, then `t`, using the +default keybindings. + +Use `/settings` to change both the theme and its color mode. OpenCode supports +three modes: + +| Mode | Behavior | +| -------- | -------------------------------------------------------- | +| `system` | Follow the terminal's detected light or dark appearance. | +| `dark` | Always use the theme's dark colors. | +| `light` | Always use the theme's light colors. | + +Your selection is stored in `~/.config/opencode/cli.json`, or the equivalent +path under `$XDG_CONFIG_HOME`: + +```json title="cli.json" +{ + "theme": { + "name": "tokyonight", + "mode": "system" + } +} +``` + + + Theme selection applies to the full-screen TUI. Direct interactive runs use colors derived from the terminal palette + and honor only the color mode. + + +## Built-in themes + +OpenCode currently includes: + +| | | | +| ------------ | ------------------- | ---------------------- | +| `aura` | `ayu` | `carbonfox` | +| `catppuccin` | `catppuccin-frappe` | `catppuccin-macchiato` | +| `cobalt2` | `cursor` | `dracula` | +| `everforest` | `flexoki` | `github` | +| `gruvbox` | `kanagawa` | `lucent-orng` | +| `material` | `matrix` | `mercury` | +| `monokai` | `nightowl` | `nord` | +| `one-dark` | `opencode` | `orng` | +| `osaka-jade` | `palenight` | `rosepine` | +| `solarized` | `synthwave84` | `tokyonight` | +| `vercel` | `vesper` | `zenburn` | + +When OpenCode can read your terminal palette, the picker also includes +`system`. The `system` theme generates its colors from your terminal's +foreground, background, and ANSI palette. + +## Custom themes + +Create a JSON file in either of these locations: + +```text +~/.config/opencode/themes/my-theme.json +.opencode/themes/my-theme.json +``` + +OpenCode checks the global theme directory first, followed by every +`.opencode/themes` directory from the filesystem root down to the current +directory. A more local file with the same filename overrides an earlier one. +The filename becomes the theme name, so `my-theme.json` appears as `my-theme`. + +Custom theme files must be strict JSON. Comments and trailing commas are not +supported. + +### Format + +V2 themes organize colors into hue scales and semantic tokens. Set `version` +to `2` and define at least one of `light` or `dark`: + + + Native V2 custom theme files are not loaded directly by the current beta. Existing custom files use the V1 format and + are migrated to these tokens at runtime. This reference tracks the native V2 schema while direct file loading is + completed. + + +By default, a theme inherits OpenCode's complete theme, so you only need to +define overrides. Set `mergeMode` to `true` to inherit one mode from the other +before applying that mode's overrides. Set `standalone` to `true` only when you +intend to supply a complete independent theme. + +Each token accepts: + +- A hex color such as `"#5c9cf5"` +- `"transparent"` to use the terminal default +- A hue reference such as `"$hue.blue.500"` +- Another semantic token reference such as `"$text.default"` + +Syntax and markdown tokens accept hex colors and hue references. Other +semantic tokens can reference any semantic token. + + + +If you add or edit a custom theme while OpenCode is running, restart the TUI to +reload it. + +## Terminal colors + +Themes display most accurately in a terminal with truecolor support. Check +your terminal with: + +```bash +echo $COLORTERM +``` + +Most modern terminals report `truecolor` or `24bit`. Without truecolor, +OpenCode approximates theme colors using the available terminal palette. diff --git a/packages/schema/src/agent.ts b/packages/schema/src/agent.ts index 8806a9c469..8001a2967e 100644 --- a/packages/schema/src/agent.ts +++ b/packages/schema/src/agent.ts @@ -16,10 +16,7 @@ export type ID = typeof ID.Type export const Name = Schema.String.pipe(Schema.brand("Agent.Name")) export type Name = typeof Name.Type -export const Color = Schema.Union([ - Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)), - Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]), -]).annotate({ identifier: "Agent.Color" }) +export const Color = Schema.String.annotate({ identifier: "Agent.Color" }) export type Color = typeof Color.Type export interface Info extends Schema.Schema.Type {} diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 81caea654f..2f9d1dd7cc 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -106,13 +106,13 @@ export const Info = Schema.Struct({ .annotate({ identifier: "Model.Info" }) .pipe( statics(() => ({ - empty: (providerID: Provider.ID, id: ID) => + default: (providerID: Provider.ID, id: ID) => ({ id, modelID: id, providerID, name: id, - capabilities: { tools: false, input: [], output: [] }, + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, variants: [], time: { released: 0 }, cost: [], diff --git a/packages/schema/test/agent.test.ts b/packages/schema/test/agent.test.ts new file mode 100644 index 0000000000..30535ebaec --- /dev/null +++ b/packages/schema/test/agent.test.ts @@ -0,0 +1,10 @@ +import { expect, test } from "bun:test" +import { Schema } from "effect" +import { Agent } from "../src/agent.js" + +test("Agent.Color preserves configured colors at the public boundary", () => { + const encode = Schema.encodeSync(Agent.Color) + + expect(encode("info")).toBe("info") + expect(encode("custom-color")).toBe("custom-color") +}) diff --git a/packages/schema/test/contract-hygiene.test.ts b/packages/schema/test/contract-hygiene.test.ts index c441ad88c3..46784ee27d 100644 --- a/packages/schema/test/contract-hygiene.test.ts +++ b/packages/schema/test/contract-hygiene.test.ts @@ -20,6 +20,12 @@ import { PersistedRevert } from "../src/session-revert.js" import { optional } from "../src/schema.js" describe("contract hygiene", () => { + test("restricts agent colors to six-digit hex values", () => { + const decode = Schema.decodeUnknownSync(Agent.Color) + expect(decode("#ff6b6b")).toBe("#ff6b6b") + expect(() => decode("warning")).toThrow() + }) + test("keeps absolute costs distinct from model rates", () => { const usd = Money.USD.make(1) const rate = Money.USDPerMillionTokens.make(1) @@ -77,7 +83,7 @@ describe("contract hygiene", () => { test("model defaults and provider overlays preserve public invariants", () => { const id = Model.ID.make("model") - expect(Model.Info.empty(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] }) + expect(Model.Info.default(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] }) expect(() => Schema.decodeUnknownSync(Provider.Info)({ id: "provider", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 5e600b65e8..91b286e798 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -65,6 +65,7 @@ import { DialogThemeList } from "./component/dialog-theme-list" import { DialogHelp } from "./ui/dialog-help" import { DialogAgent } from "./component/dialog-agent" import { DialogSessionList } from "./component/dialog-session-list" +import { ThemeErrorToast } from "./component/theme-error-toast" import { ThemeProvider, useTheme } from "./context/theme" import { Home } from "./routes/home" import { Session } from "./routes/session" @@ -337,6 +338,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { + diff --git a/packages/tui/src/component/bg-pulse.tsx b/packages/tui/src/component/bg-pulse.tsx index 2112fe4420..064cc314f9 100644 --- a/packages/tui/src/component/bg-pulse.tsx +++ b/packages/tui/src/component/bg-pulse.tsx @@ -70,7 +70,7 @@ declare module "@opentui/solid" { extend({ go_upsell_art: GoUpsellArtRenderable }) export function BgPulse() { - const { theme } = useTheme() + const { themeV2, mode } = useTheme().contextual("elevated") const renderer = useRenderer() let targetFps = renderer.targetFps let maxFps = renderer.maxFps @@ -91,9 +91,9 @@ export function BgPulse() { ) diff --git a/packages/tui/src/component/devtools-bar.tsx b/packages/tui/src/component/devtools-bar.tsx index fc168260a2..45c031069c 100644 --- a/packages/tui/src/component/devtools-bar.tsx +++ b/packages/tui/src/component/devtools-bar.tsx @@ -1,4 +1,4 @@ -import { TextAttributes } from "@opentui/core" +import { TextAttributes, type Renderable } from "@opentui/core" import { TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@opentui/solid" import { open } from "node:fs/promises" import { tmpdir } from "node:os" @@ -33,6 +33,7 @@ export function DevToolsBar() { const plugins = usePlugin() const theme = useTheme() const keymap = Keymap.use() + const renderer = useRenderer() const dimensions = useTerminalDimensions() const { themeV2, mode, supports, setMode } = theme const elevatedTheme = theme.contextual("elevated").themeV2 @@ -41,6 +42,7 @@ export function DevToolsBar() { const [dumpPath, setDumpPath] = createSignal() const [dumpError, setDumpError] = createSignal() const [frontendSamples, setFrontendSamples] = createSignal([]) + let focus: Renderable | null const connected = createMemo(() => client.connection.status() === "connected") const serverIndicator = createMemo(() => connectionIndicator(client.connection.status(), client.connection.attempt())) const themePerformance = createMemo( @@ -54,11 +56,27 @@ export function DevToolsBar() { address: info.urls[0] ? new URL(info.urls[0]).host : "Unknown", } }) - const toggle = (next: Panel) => setPanel((current) => (current === next ? undefined : next)) + const close = () => { + setPanel() + setTimeout(() => { + if (panel() || !focus || focus.isDestroyed) return + focus.focus() + focus = null + }, 1) + } + const toggle = (next: Panel) => { + if (panel() === next) return close() + if (!panel()) { + focus = renderer.currentFocusedRenderable + focus?.blur() + } + setPanel(next) + } const nextMode = () => (mode() === "dark" ? "light" : "dark") const canSwitchMode = () => supports(nextMode()) const runtime = createMemo(() => runtimeStatus(frontendSamples())) const timing = () => config.data.debug?.timing ?? false + const turnTokens = () => config.data.debug?.turn_tokens ?? false const offEscape = keymap.intercept( "key", @@ -66,7 +84,7 @@ export function DevToolsBar() { if (!panel() || event.name !== "escape") return event.preventDefault() event.stopPropagation() - setPanel() + close() }, { priority: 10 }, ) @@ -204,7 +222,7 @@ export function DevToolsBar() { width={dimensions().width} height={Math.max(0, dimensions().height - 1)} backgroundColor="transparent" - onMouseUp={() => setPanel()} + onMouseUp={close} /> toggle("server")}> @@ -352,6 +370,16 @@ export function DevToolsBar() { > {timing() ? "[x]" : "[ ]"} Time to first draw + + void config.update((draft) => { + draft.debug = { ...draft.debug, turn_tokens: !turnTokens() } + }) + } + hoverBackground + > + {turnTokens() ? "[x]" : "[ ]"} Turn token usage + {(group) => ( @@ -403,7 +431,7 @@ function PanelBox(props: ParentProps) { position="absolute" zIndex={2600} bottom={1} - left={0} + left={-1} width={42} paddingLeft={2} paddingRight={2} diff --git a/packages/tui/src/component/dialog-config.tsx b/packages/tui/src/component/dialog-config.tsx index ab84fdf623..1ca585ec13 100644 --- a/packages/tui/src/component/dialog-config.tsx +++ b/packages/tui/src/component/dialog-config.tsx @@ -223,10 +223,10 @@ const settings: Setting[] = [ labels: ["off", "on"], }, { - title: "DevTools: Timing", + title: "Turn token usage", category: "Debug", - path: ["debug", "timing"], - default: true, + path: ["debug", "turn_tokens"], + default: false, values: [false, true], labels: ["off", "on"], }, diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 04783790e4..b1067fa859 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -20,7 +20,7 @@ export function DialogSessionList() { const dialog = useDialog() const route = useRoute() const data = useData() - const { theme } = useTheme() + const { themeV2, mode } = useTheme().contextual("elevated") const client = useClient() const local = useLocal() const toast = useToast() @@ -109,12 +109,13 @@ export function DialogSessionList() { value: session.id, category, footer, - bg: deleting ? theme.error : undefined, + bg: deleting ? themeV2.background.action.destructive.focused : undefined, + fg: deleting ? themeV2.text.action.destructive.focused : undefined, gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running") ? () => : slot === undefined ? undefined - : () => {slot}, + : () => {slot}, } } @@ -142,12 +143,14 @@ export function DialogSessionList() { }} emptyView={ - No sessions available + No sessions available } noMatchView={ - {searchState().message} + + {searchState().message} + } onMove={() => setToDelete(undefined)} diff --git a/packages/tui/src/component/dialog-stash.tsx b/packages/tui/src/component/dialog-stash.tsx index cefe315ee3..80aa75250c 100644 --- a/packages/tui/src/component/dialog-stash.tsx +++ b/packages/tui/src/component/dialog-stash.tsx @@ -29,7 +29,7 @@ function getStashPreview(input: string, maxLength: number = 50): string { export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { const dialog = useDialog() const stash = usePromptStash() - const { theme } = useTheme() + const { themeV2 } = useTheme().contextual("elevated") const shortcuts = Keymap.useShortcuts() const [toDelete, setToDelete] = createSignal() @@ -45,7 +45,8 @@ export function DialogStash(props: { onSelect: (entry: StashEntry) => void }) { title: isDeleting ? `Press ${shortcuts.get("stash.delete")} again to confirm` : getStashPreview(entry.prompt.text), - bg: isDeleting ? theme.error : undefined, + bg: isDeleting ? themeV2.background.action.destructive.focused : undefined, + fg: isDeleting ? themeV2.text.action.destructive.focused : undefined, value: index, description: getRelativeTime(entry.timestamp), footer: lineCount > 1 ? `~${lineCount} lines` : undefined, diff --git a/packages/tui/src/component/prompt/autocomplete.tsx b/packages/tui/src/component/prompt/autocomplete.tsx index 5dd80ee396..921d096819 100644 --- a/packages/tui/src/component/prompt/autocomplete.tsx +++ b/packages/tui/src/component/prompt/autocomplete.tsx @@ -12,7 +12,7 @@ import { getScrollAcceleration } from "../../util/scroll" import { useTuiPaths } from "../../context/runtime" import { useConfig } from "../../config" import { useLocation } from "../../context/location" -import { useTheme, selectedForeground } from "../../context/theme" +import { useTheme } from "../../context/theme" import { SplitBorder } from "../../ui/border" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "../../util/locale" @@ -57,7 +57,7 @@ export function Autocomplete(props: { const data = useData() const keymap = Keymap.use() const keymapCommands = Keymap.useCommands() - const { theme } = useTheme() + const { themeV2 } = useTheme().contextual("overlay") const dimensions = useTerminalDimensions() const frecency = useFrecency() const config = useConfig().data @@ -698,11 +698,11 @@ export function Autocomplete(props: { width={position().width} zIndex={100} {...SplitBorder} - borderColor={theme.border} + borderColor={themeV2.border.default} > (scroll = r)} - backgroundColor={theme.backgroundMenu} + backgroundColor={themeV2.background.default} height={height()} scrollbarOptions={{ visible: false }} scrollAcceleration={scrollAcceleration()} @@ -711,7 +711,9 @@ export function Autocomplete(props: { each={options()} fallback={ - {emptyMessage()} + + {emptyMessage()} + } > @@ -719,7 +721,7 @@ export function Autocomplete(props: { { setStore("input", "mouse") @@ -734,11 +736,17 @@ export function Autocomplete(props: { }} onMouseUp={() => select()} > - + {option().display} - + {" " + option().description?.trimStart()} diff --git a/packages/tui/src/component/theme-error-toast.tsx b/packages/tui/src/component/theme-error-toast.tsx new file mode 100644 index 0000000000..f10a11bdc9 --- /dev/null +++ b/packages/tui/src/component/theme-error-toast.tsx @@ -0,0 +1,20 @@ +import { onCleanup } from "solid-js" +import { useTheme } from "../context/theme" +import { useToast } from "../ui/toast" + +export function ThemeErrorToast() { + const theme = useTheme() + const toast = useToast() + + onCleanup( + theme.onError(({ name, error }) => + toast.show({ + variant: "error", + title: `Failed to load theme: ${name}`, + message: error.message, + }), + ), + ) + + return null +} diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 3d66818390..424e8dbc9a 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -142,6 +142,12 @@ export const Info = Schema.Struct({ mono: Schema.optional(Schema.Boolean).annotate({ description: "Use monochrome ASCII output", }), + replay: Schema.optional(Schema.Boolean).annotate({ + description: "Restore session history on resume and terminal resize", + }), + replay_limit: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))).annotate({ + description: "Maximum number of newest messages restored during replay", + }), }), ).annotate({ description: "Mini transcript presentation settings" }), hints: Schema.optional( @@ -153,6 +159,7 @@ export const Info = Schema.Struct({ Schema.Struct({ devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }), timing: Schema.optional(Schema.Boolean).annotate({ description: "Show time-to-first-draw diagnostics" }), + turn_tokens: Schema.optional(Schema.Boolean).annotate({ description: "Show per-turn token usage diagnostics" }), }), ).annotate({ description: "Debugging settings" }), animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }), diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index ddf3823e75..a5a18ff077 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -298,6 +298,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "session.created": result.session.invalidate(event.data.sessionID) void result.session.sync(event.data.sessionID) + // Band-aid: a newly created session starts empty, so live events can be its source of truth. + // Fetching pending inputs and projected messages separately lets promotion move an input between snapshots, + // causing both requests to miss it and overwrite event-built state. Skip those racy initial reads until + // hydration can load pending and projected messages atomically. + sync.complete(`session.pending:${event.data.sessionID}`) + sync.complete(`session.message:${event.data.sessionID}`) break case "session.deleted": removeSession(event.data.sessionID) diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx index 03716cdf12..98faea2f9d 100644 --- a/packages/tui/src/context/local.tsx +++ b/packages/tui/src/context/local.tsx @@ -23,16 +23,6 @@ import { useRoute } from "./route" import { useData } from "./data" import { usePermission } from "./permission" -export type LocalTheme = { - secondary: RGBA - accent: RGBA - success: RGBA - warning: RGBA - primary: RGBA - error: RGBA - info: RGBA -} - export function parseModel(model: string) { const [providerID, ...rest] = model.split("/") return { @@ -60,7 +50,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const data = useData() const client = useClient() const toast = useToast() - const { theme, themeV2, mode } = useTheme() + const { themeV2, mode } = useTheme() const route = useRoute() const paths = useTuiPaths() const args = useArgs() @@ -128,12 +118,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ if (index === -1) return colors()[0] const agent = visibleAgents()[index] - if (agent?.color) { - const color = agent.color - if (color.startsWith("#")) return RGBA.fromHex(color) - // already validated by config, just satisfying TS here - return theme[color as keyof typeof theme] as RGBA - } + if (agent?.color) return RGBA.fromHex(agent.color) return colors()[index % colors().length] }, } diff --git a/packages/tui/src/context/theme.tsx b/packages/tui/src/context/theme.tsx index 88f91cb50d..9b53e8d07a 100644 --- a/packages/tui/src/context/theme.tsx +++ b/packages/tui/src/context/theme.tsx @@ -4,23 +4,21 @@ import { DEFAULT_THEMES, addTheme, allThemes, - generateSyntax, hasTheme, - isTheme, - resolveTheme, + parseTheme, selectedForeground, setCustomThemes, setSystemTheme, subscribeThemes, upsertTheme, type Theme, - type ThemeJson, + type ThemeDocumentSource, } 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" -import { resolveThemeFile } from "../theme/v2/resolve" -import { migrateV1 } from "../theme/v2/v1-migrate" +import { resolveThemeDocument } from "../theme/v2/resolve" import { themeModes } from "../theme/v2/select" import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js" import { createStore, produce } from "solid-js/store" @@ -30,6 +28,36 @@ import { Global } from "@opencode-ai/util/global" import { DevTools } from "../devtools" const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" }) +export type ThemeError = { name: string; error: Error } +type ThemeErrorHandler = (event: ThemeError) => void + +function createThemeErrors() { + let handler: ThemeErrorHandler | undefined + let pending: ThemeError | undefined + + return { + emit(name: string, cause: unknown) { + const event = { name, error: cause instanceof Error ? cause : new Error(String(cause)) } + if (handler) { + handler(event) + return + } + pending = event + }, + onError(next: ThemeErrorHandler) { + handler = next + if (pending) { + next(pending) + pending = undefined + } + return () => { + if (handler === next) handler = undefined + } + }, + } +} + +const themeErrors = createThemeErrors() export type ThemeSource = Readonly<{ discover(): Promise> @@ -62,7 +90,7 @@ export { const THEME_REFRESH_DELAYS = [250, 1000] as const type State = { - themes: Record + themes: Record mode: "dark" | "light" lock: "dark" | "light" | undefined active: string @@ -71,7 +99,6 @@ type State = { type ContextName = "elevated" | "overlay" type ThemeService = { - theme: Theme themeV2: ComponentTheme contextual(context: ContextName): ThemeService readonly selected: string @@ -86,6 +113,7 @@ type ThemeService = { unlock(): void setMode(mode?: "dark" | "light", persist?: boolean): boolean set(theme: string): boolean + onError(handler: ThemeErrorHandler): () => void readonly ready: boolean } @@ -141,12 +169,7 @@ const themeContext = createSimpleContext({ return themes .discover() .then((themes) => { - setCustomThemes( - Object.entries(themes).reduce>((result, [name, theme]) => { - if (isTheme(theme)) result[name] = theme - return result - }, {}), - ) + setCustomThemes(themes) }) .catch(() => setStore("active", "opencode")) } @@ -271,48 +294,35 @@ const themeContext = createSimpleContext({ }) const initStarted = performance.now() - const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode) - const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode")) - const file = createMemo(() => migrateV1(source())) - const modes = createMemo(() => themeModes(file())) - const mode = () => { - const supported = modes() - if (supported.includes(store.mode)) return store.mode - return supported[0] ?? store.mode - } - const values = createMemo(() => resolveTheme(source(), mode())) - const valuesV2 = createMemo(() => resolveThemeFile(file(), mode(), sourceName())) + const selected = createMemo(() => { + const name = store.themes[store.active] ? store.active : "opencode" + try { + return loadTheme(store.themes[name], name, store.mode) + } catch (error) { + if (name === "opencode") throw error + themeErrors.emit(name, error) + setStore("active", "opencode") + return loadTheme(store.themes.opencode, "opencode", store.mode) + } + }) + const modes = () => selected().modes + const mode = () => selected().mode + const valuesV2 = () => selected().theme valuesV2() themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`) const themeV2 = createComponentTheme(valuesV2, mode) const contextsV2 = { - elevated: createComponentTheme(() => { - const theme = valuesV2().contexts["@context:elevated"] - if (!theme) throw new Error("Theme context is not defined: elevated") - return theme - }, mode), - overlay: createComponentTheme(() => { - const theme = valuesV2().contexts["@context:overlay"] - if (!theme) throw new Error("Theme context is not defined: overlay") - return theme - }, mode), + elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode), + overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode), } - createEffect(() => renderer.setBackgroundColor(values().background)) + createEffect(() => renderer.setBackgroundColor(valuesV2().background.default)) - const syntax = createSyntaxStyleMemo(() => generateSyntax(values())) - - const theme = new Proxy(values(), { - get(_target, prop) { - // @ts-expect-error Properties are forwarded to the current reactive value. - return values()[prop] - }, - }) + const syntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode())) function contextual(context: ContextName) { return contextualServices[context] } const service: ThemeService = { - theme, themeV2, contextual, get selected() { @@ -342,6 +352,7 @@ const themeContext = createSimpleContext({ .catch(() => {}) return true }, + onError: themeErrors.onError, get ready() { return store.ready }, @@ -365,6 +376,14 @@ export function ThemeContextProvider(props: ParentProps<{ context: ContextName } ) } + +function loadTheme(source: ThemeDocumentSource, name: string, requested: "dark" | "light") { + const document = parseTheme(source, name) + const modes = themeModes(document) + const mode = modes.includes(requested) ? requested : (modes[0] ?? requested) + return { modes, mode, theme: resolveThemeDocument(document, mode) } +} + export function createSyntaxStyleMemo(factory: () => SyntaxStyle) { const renderer = useRenderer() const retained = new Set() diff --git a/packages/tui/src/mini/theme.ts b/packages/tui/src/mini/theme.ts index 736eed08bb..3c5afed16e 100644 --- a/packages/tui/src/mini/theme.ts +++ b/packages/tui/src/mini/theme.ts @@ -10,7 +10,7 @@ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui" import { ansiToRgba } from "../theme/color" import { resolveThemeColors } from "../theme/resolve" import { terminalMode } from "../theme/system" -import type { ThemeJson } from "../theme/v1" +import type { ThemeV1Json } from "../theme/v1" import type { EntryKind, RunTuiConfig } from "./types" type Tone = { @@ -184,7 +184,7 @@ function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number) return nearestIndexed(indexed, mixed) } -export function resolveTheme(theme: ThemeJson, pick: "dark" | "light"): TuiThemeCurrent { +export function resolveTheme(theme: ThemeV1Json, pick: "dark" | "light"): TuiThemeCurrent { const resolved = resolveThemeColors(theme, pick, (code) => RGBA.fromIndex(code, ansiToRgba(code))) return { ...resolved.theme, @@ -246,7 +246,7 @@ function generateMutedTextColor(bg: RGBA, isDark: boolean, map: (rgba: RGBA) => return map(RGBA.fromInts(gray, gray, gray)) } -export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeJson { +export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeV1Json { const bg_snapshot = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) const fg_snapshot = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) const bg = RGBA.defaultBackground(bg_snapshot) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 69c9682ec1..9b03a40422 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1029,11 +1029,13 @@ export function Session() { ) } -function SessionRowView(props: { +type SessionRowViewProps = { row: SessionRow message: (messageID: string) => SessionMessageInfo | undefined boundaryID?: string -}) { +} + +function SessionRowView(props: SessionRowViewProps) { return ( @@ -1072,11 +1074,109 @@ function SessionRowView(props: { )} + + {(row) => ( + + )} + ) } +function TurnTokenUsage(props: { + messageIDs: string[] + previousCacheRead?: number + message: (messageID: string) => SessionMessageInfo | undefined +}) { + const config = useConfig() + const { themeV2 } = useTheme() + const steps = createMemo(() => { + let previousCacheRead = props.previousCacheRead + return props.messageIDs.flatMap((messageID) => { + const message = props.message(messageID) + if (message?.type !== "assistant" || !message.tokens) return [] + const total = + message.tokens.input + + message.tokens.output + + message.tokens.reasoning + + message.tokens.cache.read + + message.tokens.cache.write + if (total === 0) return [] + const newTokens = total - message.tokens.cache.read + const cacheBust = + previousCacheRead !== undefined && message.tokens.cache.read < previousCacheRead + ? previousCacheRead - message.tokens.cache.read + : undefined + previousCacheRead = message.tokens.cache.read + return [ + { + finish: message.finish === "tool-calls" ? "tool-call" : (message.finish ?? "unknown"), + newTokens, + cached: message.tokens.cache.read, + total, + cacheBust, + }, + ] + }) + }) + const columns = createMemo(() => ({ + step: Math.max("Step".length, ...steps().map((item) => item.finish.length)), + newTokens: Math.max("New".length, ...steps().map((item) => item.newTokens.toLocaleString().length)), + cached: Math.max("Cached".length, ...steps().map((item) => item.cached.toLocaleString().length)), + total: Math.max("Total".length, ...steps().map((item) => item.total.toLocaleString().length)), + })) + return ( + 0}> + + + + ◈ + + + Tokens + + + + + {"Step".padEnd(columns().step + 2)} + {"New".padStart(columns().newTokens)} + {" "} + {"Cached".padStart(columns().cached)} + {" "} + {"Total".padStart(columns().total)} + + + + {(item) => ( + + + {item.finish.padEnd(columns().step + 2)} + + {item.newTokens.toLocaleString().padStart(columns().newTokens)} + + {" "} + {item.cached.toLocaleString().padStart(columns().cached)} + {" "} + {item.total.toLocaleString().padStart(columns().total)} + + + + ! Cache bust: {item.cacheBust?.toLocaleString()} fewer cached tokens than the previous step + + + + )} + + + + ) +} + function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) { const { themeV2 } = useTheme() const shortcut = Keymap.useShortcut("session.background") diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index dc4a681bb9..c6b0d744c9 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -1,6 +1,7 @@ import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client" import { createEffect, on, onCleanup, type Accessor } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" +import { useConfig } from "../../config" import { useData } from "../../context/data" import { useClient } from "../../context/client" @@ -27,18 +28,25 @@ export type SessionRow = completed: boolean } | { type: "assistant-footer"; messageID: string } + | { type: "turn-usage"; messageIDs: string[]; previousCacheRead?: number } export function createSessionRows(sessionID: Accessor) { const data = useData() const client = useClient() + const config = useConfig() const [rows, setRows] = createStore([]) const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID + const turnTokens = () => config.data.debug?.turn_tokens === true function reduce() { const messages = data.session.message.list(sessionID()) const inputs = new Set(data.session.input.list(sessionID())) const boundary = revertBoundary() - const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs) + const rows = reduceSessionRows( + boundary ? messages.filter((message) => message.id < boundary) : messages, + inputs, + turnTokens(), + ) partitionPending(rows, pendingPermissions()) const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID)) rows.splice( @@ -127,6 +135,10 @@ export function createSessionRows(sessionID: Accessor) { ), ) + createEffect( + on(turnTokens, () => setRows(reconcile(reduce()))), + ) + const appendMessage = (messageID: string) => setRows( produce((draft) => { @@ -246,9 +258,12 @@ export function createSessionRows(sessionID: Accessor) { data.on("session.step.ended", (event) => { if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return appendFooter(event.data.assistantMessageID) + if (turnTokens()) setRows(reconcile(reduce())) }), data.on("session.step.failed", (event) => { - if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID) + if (event.data.sessionID !== sessionID()) return + appendFooter(event.data.assistantMessageID) + if (turnTokens()) setRows(reconcile(reduce())) }), ] onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe())) @@ -256,10 +271,17 @@ export function createSessionRows(sessionID: Accessor) { return rows } -export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set()) { +export function reduceSessionRows( + messages: SessionMessageInfo[], + inputs = new Set(), + turnTokens = false, +) { const isInput = (message: SessionMessageInfo) => inputs.has(message.id) const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running") const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs]) + const usage = turnTokens + ? { steps: [] as SessionMessageAssistant[], previousTurnCacheRead: undefined as number | undefined } + : undefined return [ ...messages.filter((message) => !pending.has(message.id)), ...pendingCompactions, @@ -271,20 +293,47 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S rows.push({ type: "message", messageID: message.id }) return rows } + usage?.steps.push(message) const ordinals = { text: 0, reasoning: 0 } message.content.forEach((part) => { const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}` if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return append(rows, { messageID: message.id, partID }, part) }) - if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) { + const terminal = (message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error + if (terminal || message.retry) { completePrevious(rows) rows.push({ type: "assistant-footer", messageID: message.id }) } + if (terminal && usage) { + const stepsWithUsage = usage.steps.filter(hasTokenUsage) + const last = stepsWithUsage.at(-1) + if (last) { + rows.push({ + type: "turn-usage", + messageIDs: stepsWithUsage.map((step) => step.id), + ...(usage.previousTurnCacheRead === undefined + ? {} + : { previousCacheRead: usage.previousTurnCacheRead }), + }) + usage.previousTurnCacheRead = last.tokens.cache.read + } + usage.steps.length = 0 + } return rows }, []) } +function hasTokenUsage( + message: SessionMessageAssistant, +): message is SessionMessageAssistant & { tokens: NonNullable } { + return message.tokens !== undefined && tokenTotal(message.tokens) > 0 +} + +function tokenTotal(tokens: NonNullable) { + return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write +} + export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageInfo[]) { const byID = new Map(messages.map((message) => [message.id, message])) const seen = new Set() @@ -309,6 +358,8 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map = {} -let customThemes: Record = {} -let systemTheme: ThemeJson | undefined -const listeners = new Set<(themes: Record) => void>() +export type ThemeDocumentSource = Record + +const pluginThemes: Record = {} +let customThemes: Record = {} +let systemTheme: ThemeDocumentSource | undefined +const listeners = new Set<(themes: Record) => void>() +const parsed = new WeakMap() +const decodeThemeDocument = Schema.decodeUnknownSync(ThemeDocument) function listThemes() { // Priority: defaults < plugin installs < custom files < generated system. - const themes = { + const themes: Record = { ...DEFAULT_THEMES, ...pluginThemes, ...customThemes, @@ -31,23 +40,40 @@ export function allThemes() { return listThemes() } -export function isTheme(theme: unknown): theme is ThemeJson { - if (typeof theme !== "object" || theme === null || Array.isArray(theme)) return false - const value = Reflect.get(theme, "theme") - return typeof value === "object" && value !== null && !Array.isArray(value) +export function isThemeSource(source: unknown): source is ThemeDocumentSource { + if (typeof source !== "object" || source === null || Array.isArray(source)) return false + return "theme" in source || "version" in source } -export function subscribeThemes(listener: (themes: Record) => void) { +export function parseTheme(source: ThemeDocumentSource, name = "theme") { + const cached = parsed.get(source) + if (cached) return cached + + const version = source.version ?? 1 + const document = + version === 1 + ? migrateV1(source as ThemeV1Json) + : version === 2 + ? decodeV2Theme(source, name) + : unsupportedThemeVersion(version) + + parsed.set(source, document) + return document +} + +export function subscribeThemes(listener: (themes: Record) => void) { listeners.add(listener) return () => listeners.delete(listener) } -export function setCustomThemes(themes: Record) { - customThemes = themes +export function setCustomThemes(themes: Record) { + customThemes = Object.fromEntries( + Object.entries(themes).filter((entry): entry is [string, ThemeDocumentSource] => isThemeSource(entry[1])), + ) syncThemes() } -export function setSystemTheme(theme: ThemeJson | undefined) { +export function setSystemTheme(theme: ThemeDocumentSource | undefined) { systemTheme = theme syncThemes() } @@ -59,7 +85,7 @@ export function hasTheme(name: string) { export function addTheme(name: string, theme: unknown) { if (!name) return false - if (!isTheme(theme)) return false + if (!isThemeSource(theme)) return false if (hasTheme(name)) return false pluginThemes[name] = theme syncThemes() @@ -68,7 +94,7 @@ export function addTheme(name: string, theme: unknown) { export function upsertTheme(name: string, theme: unknown) { if (!name) return false - if (!isTheme(theme)) return false + if (!isThemeSource(theme)) return false if (customThemes[name] !== undefined) { customThemes[name] = theme } else { @@ -78,7 +104,7 @@ export function upsertTheme(name: string, theme: unknown) { return true } -export function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme { +export function resolveTheme(theme: ThemeV1Json, mode: "dark" | "light"): Theme { const resolved = resolveThemeColors(theme, mode) return { ...resolved.theme, @@ -86,3 +112,15 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme { thinkingOpacity: resolved.thinkingOpacity, } } + +function decodeV2Theme(source: ThemeDocumentSource, name: string) { + try { + return decodeThemeDocument(source) + } catch (error) { + throw themeDecodeError(error, name) + } +} + +function unsupportedThemeVersion(version: unknown): never { + throw new Error(`Unsupported theme version: ${String(version)}`) +} diff --git a/packages/tui/src/theme/resolve.ts b/packages/tui/src/theme/resolve.ts index b8cf36866c..6d028e8fa9 100644 --- a/packages/tui/src/theme/resolve.ts +++ b/packages/tui/src/theme/resolve.ts @@ -1,9 +1,9 @@ import { RGBA } from "@opentui/core" import { ansiToRgba } from "./color" -import type { ColorValue, Theme, ThemeColor, ThemeJson } from "./v1" +import type { ColorValue, Theme, ThemeColor, ThemeV1Json } from "./v1" export function resolveThemeColors( - theme: ThemeJson, + theme: ThemeV1Json, mode: "dark" | "light", resolveAnsi: (code: number) => RGBA = ansiToRgba, ) { @@ -43,7 +43,9 @@ export function resolveThemeColors( ? resolveColor(theme.theme.selectedListItemText!) : resolved.background!, backgroundMenu: - theme.theme.backgroundMenu === undefined ? resolved.backgroundElement! : resolveColor(theme.theme.backgroundMenu), + theme.theme.backgroundMenu === undefined + ? resolved.backgroundElement! + : resolveColor(theme.theme.backgroundMenu), } satisfies Omit, hasSelectedListItemText, thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6, diff --git a/packages/tui/src/theme/v1.ts b/packages/tui/src/theme/v1.ts index 483882e274..5cf1df1b9f 100644 --- a/packages/tui/src/theme/v1.ts +++ b/packages/tui/src/theme/v1.ts @@ -98,7 +98,7 @@ export type Variant = { light: HexColor | RefName } export type ColorValue = HexColor | RefName | Variant | RGBA | number -export type ThemeJson = { +export type ThemeV1Json = { $schema?: string defs?: Record theme: Omit, "selectedListItemText" | "backgroundMenu"> & { @@ -108,7 +108,7 @@ export type ThemeJson = { } } -export const DEFAULT_THEMES: Record = { +export const DEFAULT_THEMES: Record = { aura, ayu, catppuccin, diff --git a/packages/tui/src/theme/v2/defaults.ts b/packages/tui/src/theme/v2/defaults.ts index 666f45014c..e9eb352a07 100644 --- a/packages/tui/src/theme/v2/defaults.ts +++ b/packages/tui/src/theme/v2/defaults.ts @@ -1,4 +1,4 @@ -import type { HueName, ThemeFile } from "./schema" +import type { HueName, ThemeDocument } from "./schema" export const DEFAULT_CATEGORICAL = [ "blue", @@ -437,4 +437,4 @@ export const DEFAULT_THEME = { }, }, }, -} satisfies ThemeFile +} satisfies ThemeDocument diff --git a/packages/tui/src/theme/v2/index.ts b/packages/tui/src/theme/v2/index.ts index 953112da50..b6cfbb98f5 100644 --- a/packages/tui/src/theme/v2/index.ts +++ b/packages/tui/src/theme/v2/index.ts @@ -16,7 +16,7 @@ export { SyntaxDefinition, SyntaxToken, ThemeDefinition, - ThemeFile, + ThemeDocument, type BackgroundDefinition, type DiffDefinition, type FileThemeDefinition, diff --git a/packages/tui/src/theme/v2/resolve.ts b/packages/tui/src/theme/v2/resolve.ts index 74d06e8f1a..500f853dbd 100644 --- a/packages/tui/src/theme/v2/resolve.ts +++ b/packages/tui/src/theme/v2/resolve.ts @@ -11,7 +11,7 @@ import { HueAlias, HueStep, ThemeDefinition, - ThemeFile, + ThemeDocument, } from "./schema" import type { ActionStateKey, @@ -26,7 +26,6 @@ import type { import { selectTheme, selectThemeMode } from "./select" const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition) -const decodeThemeFileSchema = Schema.decodeUnknownSync(ThemeFile) function decodeThemeDefinition(input: unknown) { try { @@ -36,27 +35,18 @@ function decodeThemeDefinition(input: unknown) { } } -function decodeThemeFile(input: unknown, name: string) { - try { - return decodeThemeFileSchema(input) - } catch (error) { - throw themeDecodeError(error, name) - } -} - -function themeDecodeError(error: unknown, name: string) { +export function themeDecodeError(error: unknown, name: string) { const message = Schema.isSchemaError(error) ? error.message : String(error) const value = /got ("[^"]*"|\S+)/.exec(message)?.[1] ?? "value" return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error }) } -export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark", name = "theme") { - const decoded = decodeThemeFile(file, name) - const selected = selectThemeMode(decoded, mode) +export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "dark") { + const selected = selectThemeMode(document, mode) const definition = selected.expanded ? selected.theme : expandTheme(selected.theme) const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode)) const core = expandTokens(fallback()) - const merged = decoded.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition) + const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition) if (!merged["hue"]) throw new Error("Standalone themes must provide hues") return resolveExpandedTheme({ ...merged, diff --git a/packages/tui/src/theme/v2/schema.ts b/packages/tui/src/theme/v2/schema.ts index 076bdfc635..2256a3a1d6 100644 --- a/packages/tui/src/theme/v2/schema.ts +++ b/packages/tui/src/theme/v2/schema.ts @@ -243,7 +243,7 @@ const MergeModeDefinition = Schema.Struct({ "@context:overlay": Schema.optional(ThemeTokensDefinition), }) export type MergeModeDefinition = Schema.Schema.Type -export const ModeDefinition = Schema.Union([FileThemeDefinition, MergeModeDefinition]) +export const ModeDefinition = Schema.Union([MergeModeDefinition, FileThemeDefinition]) export type ModeDefinition = Schema.Schema.Type const FileMetadata = { @@ -251,8 +251,8 @@ const FileMetadata = { version: Schema.Literal(2), standalone: Schema.optional(Schema.Boolean), } -export const ThemeFile = Schema.Union([ +export const ThemeDocument = Schema.Union([ Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }), Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }), ]) -export type ThemeFile = Schema.Schema.Type +export type ThemeDocument = Schema.Schema.Type diff --git a/packages/tui/src/theme/v2/select.ts b/packages/tui/src/theme/v2/select.ts index c763179f53..17d47488fa 100644 --- a/packages/tui/src/theme/v2/select.ts +++ b/packages/tui/src/theme/v2/select.ts @@ -5,45 +5,45 @@ import type { Mode, ModeDefinition, ThemeDefinition, - ThemeFile, + ThemeDocument, } from "./index" export function selectTheme( - file: ThemeFile & { light: ThemeDefinition; dark: ThemeDefinition }, + document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition }, mode?: Mode, ): ThemeDefinition -export function selectTheme(file: ThemeFile, mode?: Mode): FileThemeDefinition -export function selectTheme(file: ThemeFile, mode?: Mode) { - return selectThemeMode(file, mode).theme +export function selectTheme(document: ThemeDocument, mode?: Mode): FileThemeDefinition +export function selectTheme(document: ThemeDocument, mode?: Mode) { + return selectThemeMode(document, mode).theme } export function selectThemeMode( - file: ThemeFile, + document: ThemeDocument, mode: Mode = "light", ): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } { - const modes = themeModes(file) + const modes = themeModes(document) const selectedMode = modes.includes(mode) ? mode : modes[0] - const selected = file[selectedMode] + const selected = document[selectedMode] if (!selected) throw new Error("Theme must provide at least one mode") - if (merges(file.light) && merges(file.dark)) throw new Error("Light and dark themes cannot both merge modes") + if (merges(document.light) && merges(document.dark)) throw new Error("Light and dark themes cannot both merge modes") if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false } const otherMode = selectedMode === "light" ? "dark" : "light" - const other = file[otherMode] + const other = document[otherMode] if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`) const merged = mergeTheme(expandTheme(other), expandTheme(selected)) if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`) return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true } } -export function themeModes(file: ThemeFile): readonly Mode[] { - if (merges(file.light) && !file.dark) throw new Error("The light theme cannot merge without a dark theme") - if (merges(file.dark) && !file.light) throw new Error("The dark theme cannot merge without a light theme") - return (["light", "dark"] as const).filter((mode) => file[mode] !== undefined) +export function themeModes(document: ThemeDocument): readonly Mode[] { + if (merges(document.light) && !document.dark) throw new Error("The light theme cannot merge without a dark theme") + if (merges(document.dark) && !document.light) throw new Error("The dark theme cannot merge without a light theme") + return (["light", "dark"] as const).filter((mode) => document[mode] !== undefined) } -export function supportsThemeMode(file: ThemeFile, mode: Mode) { - return themeModes(file).includes(mode) +export function supportsThemeMode(document: ThemeDocument, mode: Mode) { + return themeModes(document).includes(mode) } function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition { diff --git a/packages/tui/src/theme/v2/syntax.ts b/packages/tui/src/theme/v2/syntax.ts new file mode 100644 index 0000000000..4a8917f089 --- /dev/null +++ b/packages/tui/src/theme/v2/syntax.ts @@ -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 { + return { scope, style: { foreground, ...style } } +} diff --git a/packages/tui/src/theme/v2/v1-migrate.ts b/packages/tui/src/theme/v2/v1-migrate.ts index 42e7c31b96..4fdea0d135 100644 --- a/packages/tui/src/theme/v2/v1-migrate.ts +++ b/packages/tui/src/theme/v2/v1-migrate.ts @@ -1,8 +1,8 @@ import { RGBA } from "@opentui/core" import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color" -import type { Theme, ThemeJson } from "../index" +import type { Theme, ThemeV1Json } from "../v1" import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults" -import type { FileThemeDefinition, Mode, ThemeFile } from "./index" +import type { FileThemeDefinition, Mode, ThemeDocument } from "./index" import { HueStep } from "./schema" type ThemeColor = Exclude @@ -14,7 +14,7 @@ const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "succes const minimumChroma = 0.03 const lightThreshold = 0.6 -export function migrateV1(theme: ThemeJson): ThemeFile { +export function migrateV1(theme: ThemeV1Json): ThemeDocument { const light = resolveV1(theme, "light") const dark = resolveV1(theme, "dark") if (light.background.a > 0 && dark.background.a > 0 && light.background.equals(dark.background)) { @@ -234,7 +234,7 @@ function ambiguous(color: RGBA, chroma = toOklch(color).c) { return color.toInts()[3] === 0 || chroma < minimumChroma } -function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme { +function resolveV1(theme: ThemeV1Json, mode: "dark" | "light"): Theme { const defs = theme.defs ?? {} function resolveColor(value: unknown, chain: string[] = []): RGBA { diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 257de75b17..88248c92d3 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -1,6 +1,6 @@ import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { Keymap, type KeymapCommand } from "../context/keymap" -import { useTheme, selectedForeground } from "../context/theme" +import { useTheme } from "../context/theme" import { entries, filter, flatMap, groupBy, pipe } from "remeda" import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" @@ -74,6 +74,7 @@ export interface DialogSelectOption { categoryView?: JSX.Element disabled?: boolean bg?: RGBA + fg?: RGBA gutter?: () => JSX.Element margin?: JSX.Element onSelect?: (ctx: DialogContext) => void @@ -91,7 +92,7 @@ export function DialogSelect(props: DialogSelectProps) { type VisibleAction = (Action & { label: string }) | FooterHint const dialog = useDialog() - const { theme } = useTheme() + const { themeV2, mode } = useTheme().contextual("elevated") const config = useConfig().data const scrollAcceleration = createMemo(() => getScrollAcceleration(config)) @@ -517,29 +518,44 @@ export function DialogSelect(props: DialogSelectProps) { if (!isActionItem(action.item)) return ( - + {action.item.title}{" "} - {action.item.label} + {action.item.label} ) const item = action.item const active = createMemo(() => isActionFocused(item)) const disabled = createMemo(() => isActionDisabled(item)) - const fg = selectedForeground(theme) return ( trigger(item)} > {item.title} - {item.label} + + {" " + item.label} + ) } @@ -549,11 +565,11 @@ export function DialogSelect(props: DialogSelectProps) { {props.titleView ?? ( - + {props.title} )} - dialog.clear()}> + dialog.clear()}> esc @@ -567,9 +583,9 @@ export function DialogSelect(props: DialogSelectProps) { props.onFilter?.(e) }) }} - focusedBackgroundColor={theme.backgroundPanel} - cursorColor={theme.primary} - focusedTextColor={theme.textMuted} + focusedBackgroundColor={themeV2.background.formfield.focused} + cursorColor={themeV2.text.formfield.focused} + focusedTextColor={themeV2.text.formfield.focused} ref={(r) => { input = r input.traits = { status: "FILTER" } @@ -580,7 +596,7 @@ export function DialogSelect(props: DialogSelectProps) { }, 1) }} placeholder={props.placeholder ?? "Search"} - placeholderColor={theme.textMuted} + placeholderColor={themeV2.text.subdued} /> @@ -594,14 +610,14 @@ export function DialogSelect(props: DialogSelectProps) { fallback={ props.emptyView ?? ( - No items available + No items available ) } > {props.noMatchView ?? ( - No results found + No results found )} @@ -623,7 +639,10 @@ export function DialogSelect(props: DialogSelectProps) { + {category} } @@ -672,8 +691,8 @@ export function DialogSelect(props: DialogSelectProps) { backgroundColor={ active() ? actionFocused() - ? theme.backgroundElement - : (option.bg ?? theme.primary) + ? themeV2.background.surface.overlay + : (option.bg ?? themeV2.background.action.primary.focused) : RGBA.fromInts(0, 0, 0, 0) } > @@ -692,6 +711,7 @@ export function DialogSelect(props: DialogSelectProps) { active={active()} current={current()} muted={actionFocused()} + activeColor={option.fg} gutter={option.gutter} /> @@ -699,7 +719,7 @@ export function DialogSelect(props: DialogSelectProps) { {(detail) => ( {option.detailsWrap @@ -745,15 +765,15 @@ function Option(props: { titleWidth?: number truncateTitle?: boolean | "left" gutter?: () => JSX.Element + activeColor?: RGBA onMouseOver?: () => void }) { - const { theme } = useTheme() - const fg = selectedForeground(theme) + const { themeV2 } = useTheme().contextual("elevated") const text = createMemo(() => { - if (props.active && !props.muted) return fg - if (props.muted && (props.active || props.current)) return theme.textMuted - if (props.current) return theme.primary - return theme.text + if (props.active && !props.muted) return props.activeColor ?? themeV2.text.action.primary.focused + if (props.muted && (props.active || props.current)) return themeV2.text.subdued + if (props.current) return themeV2.text.formfield.selected + return themeV2.text.default }) return ( @@ -783,12 +803,14 @@ function Option(props: { ? Locale.truncateLeft(props.title, props.titleWidth ?? 61) : Locale.truncate(props.title, props.titleWidth ?? 61))} - {props.description} + + {" " + props.description} + - {props.footer} + {props.footer} diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 88b2175f26..e7e6146f4b 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -5,12 +5,14 @@ import type { OpenCodeEvent } from "@opencode-ai/client" import { SessionMessage } from "@opencode-ai/core/session/message" import { EventV2 } from "@opencode-ai/core/event" import { createEffect, onMount, type ParentProps } from "solid-js" +import { ConfigProvider } from "../../../src/config" import { ClientProvider, useClient } from "../../../src/context/client" import { DataProvider as DataProviderBase, useData } from "../../../src/context/data" import { LocationProvider, useLocation } from "../../../src/context/location" import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows" import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client" import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" const formFields = [{ key: "authorization", type: "external", url: "https://example.com" }] satisfies [ { @@ -32,14 +34,18 @@ function emitEvent(events: ReturnType, event: OpenCode events.emit({ ...event, location: { directory } }) } +const config = createTuiResolvedConfig() + function DataProvider(props: ParentProps) { return ( - - - - {props.children} - - + + + + + {props.children} + + + ) } diff --git a/packages/tui/test/cli/tui/theme-mode.test.tsx b/packages/tui/test/cli/tui/theme-mode.test.tsx index cb195e384b..0b8123f05c 100644 --- a/packages/tui/test/cli/tui/theme-mode.test.tsx +++ b/packages/tui/test/cli/tui/theme-mode.test.tsx @@ -1,10 +1,13 @@ /** @jsxImportSource @opentui/solid */ import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" +import { RGBA } from "@opentui/core" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { DEFAULT_THEMES } from "../../../src/theme" +import { DEFAULT_THEME } from "../../../src/theme/v2/defaults" +import { selectTheme } from "../../../src/theme/v2/select" import { ConfigProvider } from "../../../src/config" -import { ThemeProvider, useTheme } from "../../../src/context/theme" +import { ThemeProvider, useTheme, type ThemeError } from "../../../src/context/theme" async function wait(fn: () => boolean) { const started = Date.now() @@ -24,6 +27,7 @@ test("uses an available mode while retaining the pinned preference", async () => const darkOnly = structuredClone(DEFAULT_THEMES.opencode) darkOnly.theme.background = "#111111" darkOnly.theme.text = "#eeeeee" + const native = { version: 2, dark: { text: { default: "#abcdef" } } } as const let theme: ReturnType | undefined function Probe() { @@ -42,7 +46,7 @@ test("uses an available mode while retaining the pinned preference", async () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual }) }} + source={{ discover: () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual, native }) }} > @@ -66,6 +70,85 @@ test("uses an available mode while retaining the pinned preference", async () => expect(current().set("dual")).toBeTrue() await wait(() => current().mode() === "dark") expect(current().modes()).toEqual(["light", "dark"]) + expect(current().set("native")).toBeTrue() + await wait(() => current().selected === "native") + expect(current().modes()).toEqual(["dark"]) + expect(current().themeV2.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue() + } finally { + app.renderer.destroy() + } +}) + +test.each([ + ["schema", { version: 2, light: { categorical: [] } }], + ["mode merging", { version: 2, light: { mergeMode: true } }], + ["token reference", { version: 2, light: { text: { default: "$missing" } } }], +] as const)("falls back to OpenCode when configured V2 theme %s is invalid", async (_label, source) => { + let theme: ReturnType | undefined + let failure: ThemeError | undefined + let unsubscribe: (() => void) | undefined + + function Probe() { + const value = useTheme() + theme = value + unsubscribe = value.onError((error) => (failure = error)) + return {value.selected} + } + + const app = await testRender( + () => ( + + Promise.resolve({ invalid: source }) }}> + + + + ), + { width: 20, height: 2 }, + ) + app.renderer.start() + + try { + await wait(() => theme?.ready === true) + expect(theme?.selected).toBe("opencode") + expect(failure?.name).toBe("invalid") + expect(failure?.error).toBeInstanceOf(Error) + expect(failure?.error.message.length).toBeGreaterThan(0) + } finally { + unsubscribe?.() + app.renderer.destroy() + } +}) + +test("contextual themes fall back to a standalone theme's base view", async () => { + const standalone = { + version: 2, + standalone: true, + dark: { hue: selectTheme(DEFAULT_THEME, "dark").hue }, + } as const + let theme: ReturnType | undefined + + function Probe() { + theme = useTheme() + return {theme.selected} + } + + const app = await testRender( + () => ( + + Promise.resolve({ standalone }) }}> + + + + ), + { width: 20, height: 2 }, + ) + app.renderer.start() + + try { + await wait(() => theme?.ready === true) + if (!theme) throw new Error("Theme provider is not mounted") + expect(theme.contextual("elevated").themeV2.text.default).toBe(theme.themeV2.text.default) + expect(theme.contextual("overlay").themeV2.background.default).toBe(theme.themeV2.background.default) } finally { app.renderer.destroy() } diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index 7a663cc117..c349ea5224 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -1,13 +1,25 @@ /** @jsxImportSource @opentui/solid */ import { testRender } from "@opentui/solid" import { expect, test } from "bun:test" +import { Schema } from "effect" import { resolve, ConfigProvider, + Info, useConfig, type Interface, } from "../src/config" +test("validates mini replay settings", () => { + const decode = Schema.decodeUnknownSync(Info) + + expect(decode({ mini: { replay: false, replay_limit: 50 } })).toEqual({ + mini: { replay: false, replay_limit: 50 }, + }) + expect(() => decode({ mini: { replay_limit: 0 } })).toThrow() + expect(() => decode({ mini: { replay_limit: 1.5 } })).toThrow() +}) + test("resolves nested config and keybind defaults", () => { const config = resolve( { diff --git a/packages/tui/test/theme.test.ts b/packages/tui/test/theme.test.ts index b2208f42c4..05d13d8601 100644 --- a/packages/tui/test/theme.test.ts +++ b/packages/tui/test/theme.test.ts @@ -2,7 +2,16 @@ import { expect, test } from "bun:test" import { mkdir, writeFile } from "node:fs/promises" import path from "node:path" import type { TerminalColors } from "@opentui/core" -import { DEFAULT_THEMES, addTheme, allThemes, hasTheme, resolveTheme } from "../src/theme" +import { + DEFAULT_THEMES, + addTheme, + allThemes, + hasTheme, + parseTheme, + resolveTheme, + setCustomThemes, + upsertTheme, +} from "../src/theme" import { discoverThemes, themeDirectories } from "../src/theme/discovery" import { terminalMode } from "../src/theme/system" import { tmpdir } from "./fixture/fixture" @@ -10,7 +19,7 @@ import { tmpdir } from "./fixture/fixture" test("addTheme writes into module theme store", () => { const name = `plugin-theme-${Date.now()}` expect(addTheme(name, DEFAULT_THEMES.opencode)).toBe(true) - expect(allThemes()[name]).toBeDefined() + expect(allThemes()[name]).toBe(DEFAULT_THEMES.opencode) }) test("addTheme keeps first theme for duplicate names", () => { @@ -22,15 +31,92 @@ test("addTheme keeps first theme for duplicate names", () => { expect(addTheme(name, one)).toBe(true) expect(addTheme(name, two)).toBe(false) - expect(allThemes()[name]!.theme.primary).toBe("#101010") + expect(allThemes()[name]).toBe(one) }) -test("addTheme ignores entries without a theme object", () => { +test("addTheme ignores values without a V1 theme or version", () => { const name = `plugin-theme-invalid-${Date.now()}` expect(addTheme(name, { defs: { a: "#ffffff" } })).toBe(false) + expect(addTheme(name, { light: {} })).toBe(false) expect(allThemes()[name]).toBeUndefined() }) +test("addTheme defers validation of versioned sources", () => { + const name = `plugin-theme-versioned-${Date.now()}` + expect(addTheme(name, { version: 2 })).toBe(true) + expect(() => parseTheme(allThemes()[name]!, name)).toThrow(`Invalid theme: ${name}`) +}) + +test("parseTheme delegates malformed V1 sources and rejects unknown versions", () => { + expect(() => parseTheme({})).toThrow() + expect(() => parseTheme({ version: 3 })).toThrow("Unsupported theme version: 3") +}) + +test("parses unversioned and explicit V1 themes lazily once", () => { + const unversioned = structuredClone(DEFAULT_THEMES.opencode) + const explicit = { ...structuredClone(DEFAULT_THEMES.opencode), version: 1 } + const first = parseTheme(unversioned, "unversioned") + const second = parseTheme(explicit, "explicit") + + expect(first.version).toBe(2) + expect(second.version).toBe(2) + expect(parseTheme(unversioned, "unversioned")).toBe(first) + expect(parseTheme(explicit, "explicit")).toBe(second) +}) + +test("decodes native V2 themes lazily once", () => { + const name = `plugin-theme-v2-${Date.now()}` + const source = { version: 2, light: { categorical: ["red"] } } as const + + expect(addTheme(name, source)).toBe(true) + expect(allThemes()[name]).toBe(source) + const document = parseTheme(allThemes()[name]!, name) + expect(document.light?.categorical).toEqual(["red"]) + expect(parseTheme(allThemes()[name]!, name)).toBe(document) +}) + +test("defers invalid V2 errors until parsing", () => { + const name = `plugin-theme-invalid-v2-${Date.now()}` + expect(addTheme(name, { version: 2, light: { categorical: [] } })).toBe(true) + expect(() => parseTheme(allThemes()[name]!, name)).toThrow(`Invalid theme: ${name}`) +}) + +test("defers invalid V1 errors until parsing", () => { + const name = `plugin-theme-invalid-v1-${Date.now()}` + const source = structuredClone(DEFAULT_THEMES.opencode) + source.defs = { ...source.defs, one: "two", two: "one" } + source.theme.primary = "one" + + expect(addTheme(name, source)).toBe(true) + expect(() => parseTheme(allThemes()[name]!, name)).toThrow("Circular color reference") +}) + +test("replacement sources receive independent parse caches", () => { + const name = `plugin-theme-replace-${Date.now()}` + const first = structuredClone(DEFAULT_THEMES.opencode) + const second = structuredClone(DEFAULT_THEMES.opencode) + second.theme.primary = "#123456" + + expect(addTheme(name, first)).toBe(true) + const previous = parseTheme(allThemes()[name]!, name) + expect(upsertTheme(name, second)).toBe(true) + const next = parseTheme(allThemes()[name]!, name) + expect(next).not.toBe(previous) + expect(parseTheme(allThemes()[name]!, name)).toBe(next) +}) + +test("custom themes retain precedence over plugin themes", () => { + const name = `plugin-theme-precedence-${Date.now()}` + const plugin = structuredClone(DEFAULT_THEMES.opencode) + const custom = structuredClone(DEFAULT_THEMES.opencode) + + expect(addTheme(name, plugin)).toBe(true) + setCustomThemes({ [name]: custom }) + expect(allThemes()[name]).toBe(custom) + setCustomThemes({}) + expect(allThemes()[name]).toBe(plugin) +}) + test("hasTheme checks theme presence", () => { const name = `plugin-theme-has-${Date.now()}` expect(hasTheme(name)).toBe(false) diff --git a/packages/tui/test/theme/v2/resolve.test.ts b/packages/tui/test/theme/v2/resolve.test.ts index 237b4c79fc..9d42b40341 100644 --- a/packages/tui/test/theme/v2/resolve.test.ts +++ b/packages/tui/test/theme/v2/resolve.test.ts @@ -1,16 +1,21 @@ import { expect, test } from "bun:test" import { RGBA } from "@opentui/core" +import { parseTheme, type ThemeDocumentSource } from "../../../src/theme" import { DEFAULT_THEME } from "../../../src/theme/v2/defaults" -import type { ThemeDefinition } from "../../../src/theme/v2" -import { resolveTheme, resolveThemeFile } from "../../../src/theme/v2/resolve" +import type { Mode, ThemeDefinition } from "../../../src/theme/v2" +import { resolveTheme, resolveThemeDocument } from "../../../src/theme/v2/resolve" import { selectTheme } from "../../../src/theme/v2/select" const light = selectTheme(DEFAULT_THEME, "light") const dark = selectTheme(DEFAULT_THEME, "dark") -test("resolves one-mode files with defaults for the available mode", () => { - const resolvedLight = resolveThemeFile({ version: 2, light: {} }, "dark") - const resolvedDark = resolveThemeFile({ version: 2, dark: {} }, "light") +function resolveSource(source: ThemeDocumentSource, mode?: Mode, name?: string) { + return resolveThemeDocument(parseTheme(source, name), mode) +} + +test("resolves one-mode documents with defaults for the available mode", () => { + const resolvedLight = resolveSource({ version: 2, light: {} }, "dark") + const resolvedDark = resolveSource({ version: 2, dark: {} }, "light") expect(resolvedLight.background.default.equals(resolveTheme(light).background.default)).toBeTrue() expect(resolvedDark.background.default.equals(resolveTheme(dark).background.default)).toBeTrue() @@ -18,26 +23,19 @@ test("resolves one-mode files with defaults for the available mode", () => { expect(resolvedDark.categorical.length).toBeGreaterThan(0) }) -test("rejects theme files without a mode", () => { - // @ts-expect-error Runtime decoding also enforces the at-least-one-mode invariant. - expect(() => resolveThemeFile({ version: 2 })).toThrow("Invalid theme") +test("rejects theme documents without a mode", () => { + expect(() => resolveSource({ version: 2 })).toThrow("Invalid theme") }) test("validates and resolves categorical hues in configured order", () => { - const theme = resolveThemeFile({ version: 2, light: { categorical: ["accent", "red", "interactive"] } }, "light") + const theme = resolveSource({ version: 2, light: { categorical: ["accent", "red", "interactive"] } }, "light") expect(theme.categorical[0]).toBe(theme.hue.accent) expect(theme.categorical[1]).toBe(theme.hue.red) expect(theme.categorical[2]).toBe(theme.hue.interactive) expect(theme.contexts["@context:elevated"]?.categorical).toBe(theme.categorical) - expect(() => resolveThemeFile({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme") - expect(() => - resolveThemeFile( - // @ts-expect-error Runtime decoding rejects unknown categorical hue names. - { version: 2, light: { categorical: ["magenta"] } }, - "light", - ), - ).toThrow("Invalid theme") + expect(() => resolveSource({ version: 2, light: { categorical: [] } }, "light")).toThrow("Invalid theme") + expect(() => resolveSource({ version: 2, light: { categorical: ["magenta"] } }, "light")).toThrow("Invalid theme") }) test("uses the default categorical order for direct definitions", () => { @@ -93,7 +91,7 @@ test("resolves base hue aliases and rejects circular hue aliases", () => { ...light, hue: { ...light.hue, blue: "$hue.red", purple: "$hue.blue" }, }) - const overridden = resolveThemeFile({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light") + const overridden = resolveSource({ version: 2, light: { hue: { blue: "$hue.red" } }, dark: {} }, "light") expect(aliased.hue.blue).not.toBe(aliased.hue.red) expect(aliased.hue.blue[500].equals(aliased.hue.red[500])).toBeTrue() @@ -131,8 +129,8 @@ test("steps by hue source when adjacent colors have equal values", () => { expect(theme.increase(theme.hue.neutral[300])).toBe(theme.hue.neutral[400]) }) -test("merges partial files with the selected OpenCode defaults", () => { - const theme = resolveThemeFile( +test("merges partial documents with the selected OpenCode defaults", () => { + const theme = resolveSource( { version: 2, light: { @@ -150,7 +148,7 @@ test("merges partial files with the selected OpenCode defaults", () => { }) test("expands user structural fallbacks before merging defaults", () => { - const expanded = resolveThemeFile( + const expanded = resolveSource( { version: 2, light: { @@ -161,7 +159,7 @@ test("expands user structural fallbacks before merging defaults", () => { }, "light", ) - const isolatedState = resolveThemeFile( + const isolatedState = resolveSource( { version: 2, light: { @@ -181,9 +179,9 @@ test("expands user structural fallbacks before merging defaults", () => { }) test("standalone themes skip OpenCode defaults and use the red core fallback", () => { - const file = { version: 2, standalone: true, light: { hue: light.hue }, dark: { hue: dark.hue } } as const - const lightTheme = resolveThemeFile(file, "light") - const darkTheme = resolveThemeFile(file, "dark") + const document = { version: 2, standalone: true, light: { hue: light.hue }, dark: { hue: dark.hue } } as const + const lightTheme = resolveSource(document, "light") + const darkTheme = resolveSource(document, "dark") expect(lightTheme.text.default.toInts()).toEqual([255, 0, 0, 255]) expect(lightTheme.background.default.toInts()).toEqual([255, 0, 0, 255]) @@ -192,8 +190,15 @@ test("standalone themes skip OpenCode defaults and use the red core fallback", ( }) test("uses defaults for the selected mode when it merges the other mode", () => { - const theme = resolveThemeFile({ version: 2, light: { hue: light.hue }, dark: { mergeMode: true } }, "dark") - expect(theme.background.default.toInts()).toEqual(resolveTheme(dark).background.default.toInts()) + const theme = resolveSource( + { + version: 2, + light: { hue: light.hue, background: { default: "#123456" } }, + dark: { mergeMode: true }, + }, + "dark", + ) + expect(theme.background.default.toInts()).toEqual([18, 52, 86, 255]) }) test("resolves matched action variants and states", () => { @@ -210,7 +215,7 @@ test("resolves matched action variants and states", () => { }) test("resolves elevated hover surfaces from direct colors", () => { - const theme = resolveThemeFile( + const theme = resolveSource( { version: 2, light: { background: { surface: { offset: "#123456", overlay: "#234567" } } }, @@ -224,7 +229,7 @@ test("resolves elevated hover surfaces from direct colors", () => { }) test("resolves transparent colors", () => { - const theme = resolveThemeFile({ + const theme = resolveSource({ version: 2, light: { background: { formfield: { default: "transparent" } } }, dark: { background: { formfield: { default: "transparent" } } }, @@ -234,7 +239,7 @@ test("resolves transparent colors", () => { test("reports theme decoding failures as native errors", () => { expect(() => - resolveThemeFile( + resolveSource( { version: 2, light: { text: { default: "opaque" } }, diff --git a/packages/tui/test/theme/v2/select.test.ts b/packages/tui/test/theme/v2/select.test.ts index 792321de13..00f0bd437d 100644 --- a/packages/tui/test/theme/v2/select.test.ts +++ b/packages/tui/test/theme/v2/select.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import type { HueDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2" +import type { HueDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2" import { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "../../../src/theme/v2/select" const hue = {} as HueDefinition @@ -11,20 +11,20 @@ const dark = { } satisfies ThemeDefinition test("requires and selects independent light and dark themes", () => { - const file = { version: 2, light, dark } satisfies ThemeFile - expect(selectTheme(file)).toBe(light) - expect(selectTheme(file, "light")).toBe(light) - expect(selectTheme(file, "dark")).toBe(dark) - expect(selectThemeMode(file, "dark").mode).toBe("dark") + const document = { version: 2, light, dark } satisfies ThemeDocument + expect(selectTheme(document)).toBe(light) + expect(selectTheme(document, "light")).toBe(light) + expect(selectTheme(document, "dark")).toBe(dark) + expect(selectThemeMode(document, "dark").mode).toBe("dark") }) test("merges an expanded mode override over the other mode", () => { - const file = { + const document = { version: 2, light, dark: { mergeMode: true, text: { default: "#ffffff" } }, - } satisfies ThemeFile - const selected = selectTheme(file, "dark") + } satisfies ThemeDocument + const selected = selectTheme(document, "dark") expect(selected.hue).toBeDefined() expect(selected.text?.default).toBe("#ffffff") @@ -41,8 +41,8 @@ test("replaces categorical order in a merge mode", () => { }) test("selects the available mode when the requested mode is missing", () => { - const lightOnly = { version: 2, light } satisfies ThemeFile - const darkOnly = { version: 2, dark } satisfies ThemeFile + const lightOnly = { version: 2, light } satisfies ThemeDocument + const darkOnly = { version: 2, dark } satisfies ThemeDocument expect(themeModes(lightOnly)).toEqual(["light"]) expect(themeModes(darkOnly)).toEqual(["dark"]) @@ -62,10 +62,10 @@ test("rejects a merge mode without its base mode", () => { }) test("rejects mutual mode merging", () => { - const file = { + const document = { version: 2, light: { mergeMode: true }, dark: { mergeMode: true }, - } satisfies ThemeFile - expect(() => selectTheme(file)).toThrow("cannot both merge") + } satisfies ThemeDocument + expect(() => selectTheme(document)).toThrow("cannot both merge") }) diff --git a/packages/tui/test/theme/v2/types.test.ts b/packages/tui/test/theme/v2/types.test.ts index a44322d71a..f98a013f5e 100644 --- a/packages/tui/test/theme/v2/types.test.ts +++ b/packages/tui/test/theme/v2/types.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeFile } from "../../../src/theme/v2" +import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "../../../src/theme/v2" const text = { default: "$hue.neutral.900", @@ -51,11 +51,11 @@ const definition = { "@context:overlay": { background: { default: "$hue.neutral.300" } }, } satisfies ThemeDefinition -const file = { version: 2, light: definition, dark: definition } satisfies ThemeFile -const lightOnly = { version: 2, light: definition } satisfies ThemeFile -const darkOnly = { version: 2, dark: definition } satisfies ThemeFile -// @ts-expect-error A theme file must provide at least one mode. -const empty = { version: 2 } satisfies ThemeFile +const document = { version: 2, light: definition, dark: definition } satisfies ThemeDocument +const lightOnly = { version: 2, light: definition } satisfies ThemeDocument +const darkOnly = { version: 2, dark: definition } satisfies ThemeDocument +// @ts-expect-error A theme document must provide at least one mode. +const empty = { version: 2 } satisfies ThemeDocument test("supports property-first definitions, variants, states, and contexts", () => { expect(text.action.primary.$hovered).toBe("$hue.neutral.200") @@ -68,7 +68,7 @@ test("supports property-first definitions, variants, states, and contexts", () = expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800") expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300") expect(definition.categorical).toEqual(["blue", "accent"]) - expect(file.light).toBe(definition) + expect(document.light).toBe(definition) expect(lightOnly.light).toBe(definition) expect(darkOnly.dark).toBe(definition) expect(empty.version).toBe(2) diff --git a/packages/tui/test/theme/v2/v1-migrate.test.ts b/packages/tui/test/theme/v2/v1-migrate.test.ts index 84c3639b09..ced1ae4800 100644 --- a/packages/tui/test/theme/v2/v1-migrate.test.ts +++ b/packages/tui/test/theme/v2/v1-migrate.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import { DEFAULT_THEMES, resolveTheme as resolveV1 } from "../../../src/theme" -import { resolveThemeFile } from "../../../src/theme/v2/resolve" +import { resolveThemeDocument } from "../../../src/theme/v2/resolve" import { selectThemeMode, themeModes } from "../../../src/theme/v2/select" import { migrateV1 } from "../../../src/theme/v2/v1-migrate" import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "../../../src/theme/v2/defaults" @@ -9,7 +9,7 @@ test("migrates resolved V1 modes into literal V2 tokens", () => { const migrated = migrateV1(DEFAULT_THEMES.opencode) if (!migrated.light || !migrated.dark) throw new Error("Expected both modes") const legacy = resolveV1(DEFAULT_THEMES.opencode, "light") - const resolved = resolveThemeFile(migrated, "light") + const resolved = resolveThemeDocument(migrated, "light") expect(migrated.standalone).toBeTrue() expect(migrated.light.categorical?.length).toBeGreaterThan(0) @@ -83,8 +83,8 @@ test("infers chromatic hues, anchors light and dark colors, and aliases ambiguou expect(migrated.light.hue?.purple).toBe("$hue.gray") expect(migrated.light.hue?.accent).toBe("$hue.gray") expect(migrated.light.hue?.interactive).toBe("$hue.gray") - expect(() => resolveThemeFile(migrated, "light")).not.toThrow() - expect(() => resolveThemeFile(migrated, "dark")).not.toThrow() + expect(() => resolveThemeDocument(migrated, "light")).not.toThrow() + expect(() => resolveThemeDocument(migrated, "dark")).not.toThrow() }) test("orders categorical hues by V1 semantic color mapping", () => { @@ -185,7 +185,7 @@ test("migrates every built-in V1 theme in its supported modes", () => { for (const source of Object.values(DEFAULT_THEMES)) { const migrated = migrateV1(source) for (const mode of themeModes(migrated)) { - expect(resolveThemeFile(migrated, mode).text.default).toBeDefined() + expect(resolveThemeDocument(migrated, mode).text.default).toBeDefined() } } }) diff --git a/packages/www/content/docs/(docs)/agents.mdx b/packages/www/content/docs/(docs)/agents.mdx index b1275960cb..d5668402db 100644 --- a/packages/www/content/docs/(docs)/agents.mdx +++ b/packages/www/content/docs/(docs)/agents.mdx @@ -89,7 +89,7 @@ becomes `system`: description: Reviews changes without modifying files mode: subagent model: anthropic/claude-sonnet-4-5#high -color: warning +color: "#ff6b6b" steps: 8 permissions: - action: edit @@ -118,7 +118,7 @@ Use the `agents` field in any [OpenCode configuration file](/docs/config): "mode": "all", "model": "anthropic/claude-sonnet-4-5#high", "system": "Review the current changes. Report findings before any summary.", - "color": "warning", + "color": "#ff6b6b", "steps": 8, "permissions": [ { "action": "edit", "resource": "*", "effect": "deny" }, @@ -250,8 +250,7 @@ security boundary. ### `color` -Sets the agent's UI color. Use a six-digit hex color such as `#ff6b6b`, or one -of `primary`, `secondary`, `accent`, `success`, `warning`, `error`, or `info`. +Sets the agent's UI color. Use a six-digit hex color such as `#ff6b6b`. ### `disabled` diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 543a418173..d430f5d21d 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -10561,26 +10561,10 @@ "additionalProperties": false }, "Agent.Color": { - "anyOf": [ + "type": "string", + "allOf": [ { - "type": "string", - "allOf": [ - { - "pattern": "^#[0-9a-fA-F]{6}$" - } - ] - }, - { - "type": "string", - "enum": [ - "primary", - "secondary", - "accent", - "success", - "warning", - "error", - "info" - ] + "pattern": "^#[0-9a-fA-F]{6}$" } ] }, diff --git a/patches/@ff-labs%2Ffff-bun@0.9.3.patch b/patches/@ff-labs%2Ffff-bun@0.9.3.patch deleted file mode 100644 index 23a7dd54fb..0000000000 --- a/patches/@ff-labs%2Ffff-bun@0.9.3.patch +++ /dev/null @@ -1,31 +0,0 @@ -diff --git a/src/download.ts b/src/download.ts -index 3454256..6dca25a 100644 ---- a/src/download.ts -+++ b/src/download.ts -@@ -7,7 +7,7 @@ - */ - -+declare const FFF_LIBC: "gnu" | "musl"; - import { existsSync } from "node:fs"; --import { createRequire } from "node:module"; - import { dirname, join } from "node:path"; - import { fileURLToPath } from "node:url"; - import { getLibFilename, getNpmPackageName } from "./platform"; -@@ -54,14 +54,10 @@ export function binaryExists(): boolean { - * in the same directory. - */ - function resolveFromNpmPackage(): string | null { -- const packageName = getNpmPackageName(); -- - try { -- // Use createRequire to resolve the platform package's location -- const require = createRequire(join(getPackageDir(), "package.json")); -- const packageJsonPath = require.resolve(`${packageName}/package.json`); -- const packageDir = dirname(packageJsonPath); -- const binaryPath = join(packageDir, getLibFilename()); -+ const binaryPath = require( -+ `@ff-labs/fff-bin-${process.platform === "linux" ? `linux-${process.arch}-${typeof FFF_LIBC === "string" ? FFF_LIBC : getNpmPackageName().endsWith("musl") ? "musl" : "gnu"}` : `${process.platform}-${process.arch}`}/${process.platform === "win32" ? "fff_c.dll" : process.platform === "darwin" ? "libfff_c.dylib" : "libfff_c.so"}`, -+ ); - - if (existsSync(binaryPath)) { - return binaryPath; diff --git a/script/generate.ts b/script/generate.ts index 8fc251d89d..dbf38f8a3c 100755 --- a/script/generate.ts +++ b/script/generate.ts @@ -6,4 +6,6 @@ await $`bun ./packages/sdk/js/script/build.ts` await $`bun dev generate > ../sdk/openapi.json`.cwd("packages/opencode") +await $`bun run generate`.cwd("packages/docs") + await $`./script/format.ts`