Compare commits

..

10 Commits

Author SHA1 Message Date
Kit Langton 48ea84109e refactor(ai): move credential lowering into providers 2026-08-19 17:34:13 -04:00
Kit Langton 6adb98c266 refactor(ai): flatten provider request options (#43513) 2026-08-19 17:32:29 -04:00
Kit Langton 83ffd292f8 docs(ai): remove stale design documents (#43512) 2026-08-19 17:32:23 -04:00
opencode-agent[bot] 8fb534d03e feat(tui): close tabs with middle click (#43497)
Co-authored-by: kitlangton <7587245+kitlangton@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-08-19 16:40:21 -04:00
opencode-agent[bot] d4a0f163a9 test(core): isolate model resolver auth config (#43509)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 15:25:23 -05:00
Aiden Cline a9caf9154e fix: move around ai pkg logic (#43504) 2026-08-19 15:24:03 -05:00
opencode-agent[bot] e0be1c0e13 fix(ai): match AI SDK Azure URL handling (#43506)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-19 15:04:54 -05:00
Aiden Cline 8a03b87c1f fix(ai): avoid azure chat v1 url duplication (#43505) 2026-08-19 14:50:00 -05:00
opencode-agent[bot] 173e3b0d48 chore: update nix node_modules hashes 2026-08-19 18:59:43 +00:00
ReStranger 1df9092f07 feat(ci): enable nix-hashes ci in v2 branch (#43482)
Signed-off-by: ReStranger <restranger@disroot.org>
2026-08-19 20:37:59 +02:00
33 changed files with 183 additions and 155 deletions
+1 -1
View File
@@ -6,7 +6,7 @@ permissions:
on:
workflow_dispatch:
push:
branches: [dev, beta]
branches: [dev, beta, v2]
paths:
- "bun.lock"
- "package.json"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"nodeModules": {
"x86_64-linux": "sha256-yYd6nV4YRnTYqf5W5T7oXNo7bSgr1Vzjhy7d5YjA5Vo=",
"x86_64-linux": "sha256-IxkSw0gK/qkMHZGVHqjwgM9BKhzbQX6hyF9SWUNtpzg=",
"aarch64-linux": "sha256-YVjpbil0QswVwi6NtVYFq3xCqpsfveG1chlNVCVI0MU=",
"aarch64-darwin": "sha256-CdL2mI84pawH2H5i9qu8A6IWbkmKOYHlJS+DI/Mafdw=",
"x86_64-darwin": "sha256-NtswwfU5WYv99bEmI4XeLwjhBGcS9ZMYLRo4MQRNtLo="
-48
View File
@@ -131,54 +131,6 @@ Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
### Folder layout
```
packages/ai/src/
schema/ canonical Schema model, split by concern
ids.ts branded IDs, literal types, ProviderMetadata
options.ts Generation/Provider/Http options, Limits, LanguageModel, cache policy
messages.ts content parts, Message, ToolDefinition, LLMRequest
events.ts Usage, individual events, LLMEvent, LLMResponse
errors.ts error reasons, AIError, ToolFailure
index.ts barrel
llm.ts request constructors and convenience helpers
route/
index.ts @opencode-ai/ai/route advanced barrel
client.ts Route.make + LLMClient.stream/generate
executor.ts RequestExecutor service + transport error mapping
protocol.ts Protocol type + Protocol.make
endpoint.ts Endpoint type + Endpoint.path
auth.ts Auth type + Auth.bearer / Auth.header / Auth.none
auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper
framing.ts Framing type + Framing.sse
transport/ transport implementations
index.ts Transport execution types + HttpTransport / WebSocketTransport namespaces
websocket-channel.ts generic sequential channel executor/driver contract
http.ts HttpTransport.httpJson — POST + framing
websocket.ts direct one-request channel executor + raw socket adapter
protocols/
shared.ts ProviderShared toolkit used inside protocol impls
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
open-responses.ts provider-neutral Responses protocol baseline
open-responses-channel.ts provider-neutral Responses WebSocket transport factory
openai-responses.ts OpenAI tools/events and channel policy composed over OpenResponses
anthropic-messages.ts
gemini.ts
bedrock-converse.ts
bedrock-event-stream.ts framing for AWS event-stream binary frames
openai-compatible-chat.ts route that reuses OpenAIChat.protocol, no canonical URL
openai-compatible-responses.ts deployment adapter that reuses OpenResponses.protocol, no canonical URL
utils/ per-protocol helpers (auth, cache, media, tool-stream, ...)
providers/
openai-compatible.ts generic Chat helper + family model helpers
openai-compatible-responses.ts generic Responses helper
openai-compatible-profile.ts family defaults (deepseek, togetherai, ...)
azure.ts / amazon-bedrock.ts / cloudflare.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts
tool.ts typed Tool.make helper
tool-runtime.ts narrow one-call typed tool dispatcher
```
The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata. `OpenAIResponses` composes the provider-neutral `OpenResponses` protocol; the baseline never imports the OpenAI extension.
### Shared protocol helpers
+2 -8
View File
@@ -34,10 +34,8 @@ Run `LLMClient.stream(request)` instead of `generate` when you want incremental
Use `Image.generate` with an image model for direct asset generation:
```ts
import { Effect, Layer } from "effect"
import { Image, ImageClient, ImageInput } from "@opencode-ai/ai"
import { Image, ImageInput } from "@opencode-ai/ai"
import { OpenAI } from "@opencode-ai/ai/providers"
import { RequestExecutor } from "@opencode-ai/ai/route"
const program = Effect.gen(function* () {
const response = yield* Image.generate({
@@ -54,10 +52,6 @@ const program = Effect.gen(function* () {
return response.images // GeneratedImage[] with owned bytes or a provider URL
})
const imageLayer = ImageClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
await Effect.runPromise(program.pipe(Effect.provide(imageLayer)))
```
Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:
@@ -205,7 +199,7 @@ The hosted result is represented as a provider-executed tool call and tool resul
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
- **`Message.system(...)` / `Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model. Top-level `request.system` is the initial prompt; a system message in history is a chronological operator update.
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
- **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
+2 -1
View File
@@ -22,6 +22,7 @@ const apiKey = Config.redacted("OPENAI_API_KEY")
const model = OpenAI.configure({
apiKey,
generation: { maxTokens: 160 },
store: false,
}).model("gpt-4o-mini")
// 2. Build a provider-neutral request. This is useful when reusing one request
@@ -193,7 +194,7 @@ const FakeAdapter = Route.make({
provider: "fake-echo",
protocol: FakeProtocol,
endpoint: Endpoint.path("/v1/echo", { baseURL: "https://fake.local" }),
auth: Auth.none,
auth: Auth.passthrough,
framing: Framing.sse,
})
@@ -1,5 +1,19 @@
import { Option, Schema } from "effect"
import { TextVerbosity, type LLMRequest } from "../../schema/index.js"
import type { LLMRequest } from "../../schema/index.js"
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {})
export const ReasoningEffort = Schema.declare<ReasoningEffort>(
(value): value is ReasoningEffort => typeof value === "string",
{ title: "ReasoningEffort" },
)
export const TextVerbosities = ["low", "medium", "high"] as const
export type TextVerbosity = (typeof TextVerbosities)[number] | (string & {})
export const TextVerbosity = Schema.declare<TextVerbosity>(
(value): value is TextVerbosity => typeof value === "string",
{ title: "TextVerbosity" },
)
export const ResponseIncludables = [
"file_search_call.results",
@@ -19,7 +33,6 @@ export type ServiceTier = (typeof ServiceTiers)[number]
export const Truncations = ["auto", "disabled"] as const
export type Truncation = (typeof Truncations)[number]
export const ReasoningEffort = Schema.String
export const TextVerbositySchema = TextVerbosity
export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
(value): value is ResponseIncludable => typeof value === "string",
@@ -1,8 +1,9 @@
import { ReasoningEfforts } from "../../schema/index.js"
import { OpenResponsesOptions } from "./open-responses-options.js"
export const OpenAIReasoningEfforts = ReasoningEfforts
export type OpenAIReasoningEffort = string
export const OpenAIReasoningEfforts = OpenResponsesOptions.ReasoningEfforts
export type OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
export const OpenAITextVerbosities = OpenResponsesOptions.TextVerbosities
export type OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
@@ -12,7 +13,7 @@ export const OpenAIServiceTiers = OpenResponsesOptions.ServiceTiers
export type OpenAIServiceTier = OpenResponsesOptions.ServiceTier
export const OpenAIReasoningEffort = OpenResponsesOptions.ReasoningEffort
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbositySchema
export const OpenAITextVerbosity = OpenResponsesOptions.TextVerbosity
export const OpenAIResponseIncludable = OpenResponsesOptions.ResponseIncludableSchema
export const OpenAIServiceTier = OpenResponsesOptions.ServiceTierSchema
+3 -1
View File
@@ -40,7 +40,9 @@ export const routeDefaults = (input: Defaults) => ({
export const bearerCredentialValue = (input: Credential) => (input.type === "key" ? input.value : input.accessToken)
export const bearerAuthOption = (input: Credential): AuthOverride => ({ auth: Auth.bearer(bearerCredentialValue(input)) })
export const bearerAuthOption = (input: Credential): AuthOverride => ({
auth: Auth.bearer(bearerCredentialValue(input)),
})
export const apiKeyOrBearerAuthOption = (
input: Credential,
@@ -49,9 +49,7 @@ export const provider = {
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (input) =>
configure({
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.bearerAuthOption(input.credential)
: { apiKey: input.settings.apiKey }),
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
provider: input.settings.provider,
providerOptions: input.settings.providerOptions,
@@ -71,9 +71,7 @@ export const provider = {
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (input) =>
configure({
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.bearerAuthOption(input.credential)
: { apiKey: input.settings.apiKey }),
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
provider: input.settings.provider,
providerOptions: input.settings.providerOptions,
+1 -3
View File
@@ -156,9 +156,7 @@ const config = (input: ProviderPackage.ModelInput<Settings>): Config => {
}
return {
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.bearerAuthOption(input.credential)
: { apiKey: settings.apiKey }),
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: settings.apiKey }),
baseURL: settings.baseURL,
headers: Object.keys(headers).length === 0 ? undefined : headers,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
+1 -3
View File
@@ -194,9 +194,7 @@ export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (input) =>
configure({
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.bearerAuthOption(input.credential)
: { apiKey: input.settings.apiKey }),
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
providerOptions: input.settings.providerOptions,
}).model(input.id)
+1 -3
View File
@@ -98,9 +98,7 @@ export const provider = configure()
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (input) =>
configure({
...ProviderPackage.routeDefaults(input.defaults),
...(input.credential
? ProviderPackage.bearerAuthOption(input.credential)
: { apiKey: input.settings.apiKey }),
...(input.credential ? ProviderPackage.bearerAuthOption(input.credential) : { apiKey: input.settings.apiKey }),
baseURL: input.settings.baseURL,
providerOptions: input.settings.providerOptions,
}).model(input.id)
+2 -1
View File
@@ -1,6 +1,7 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids.js"
import { ModelID, ProviderID, RouteID } from "./ids.js"
import { ProviderMetadata } from "./messages.js"
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
+15 -2
View File
@@ -1,8 +1,21 @@
import { Schema } from "effect"
import { ContentBlockID, FinishReason, ProviderMetadata, ToolCallID } from "./ids.js"
import { Message, ToolCallPart, ToolOutput, ToolResultPart, ToolResultValue, type ContentPart } from "./messages.js"
import { LLM } from "@opencode-ai/schema/llm"
import { ContentBlockID, ToolCallID } from "./ids.js"
import {
Message,
ProviderMetadata,
ToolCallPart,
ToolOutput,
ToolResultPart,
ToolResultValue,
type ContentPart,
} from "./messages.js"
import { ProviderFailureClassification } from "./errors.js"
export const FinishReason = LLM.FinishReason
export type FinishReason = Schema.Schema.Type<typeof FinishReason>
export { ProviderMetadata } from "./messages.js"
/**
* Token usage reported by an LLM provider.
*
-20
View File
@@ -1,8 +1,4 @@
import { Schema } from "effect"
import { ProviderMetadata } from "@opencode-ai/schema/ai"
import { LLM } from "@opencode-ai/schema/llm"
export { ProviderMetadata }
/** Stable string identifier for a protocol implementation. */
export const ProtocolID = Schema.String
@@ -26,19 +22,3 @@ export type ContentBlockID = Schema.Schema.Type<typeof ContentBlockID>
export const ToolCallID = Schema.String
export type ToolCallID = Schema.Schema.Type<typeof ToolCallID>
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export const ReasoningEffort = Schema.String
export type ReasoningEffort = Schema.Schema.Type<typeof ReasoningEffort>
export const TextVerbosity = Schema.Literals(["low", "medium", "high"])
export type TextVerbosity = Schema.Schema.Type<typeof TextVerbosity>
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
export const FinishReason = LLM.FinishReason
export type FinishReason = Schema.Schema.Type<typeof FinishReason>
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
export type JsonSchema = Schema.Schema.Type<typeof JsonSchema>
+9 -1
View File
@@ -1,16 +1,24 @@
import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids.js"
import {
CacheHint,
CachePolicy,
GenerationOptions,
HttpOptions,
JsonSchema,
LanguageModelSchema,
ProviderOptions,
} from "./options.js"
import { isRecord } from "../utils/record.js"
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({
identifier: "LLM.ProviderMetadata",
})
export type ProviderMetadata = Schema.Schema.Type<typeof ProviderMetadata>
const systemPartSchema = Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
+4 -1
View File
@@ -1,8 +1,11 @@
import { Schema } from "effect"
import { JsonSchema, ModelID, ProviderID } from "./ids.js"
import { ModelID, ProviderID } from "./ids.js"
import type { AnyRoute } from "../route/client.js"
import { isRecord } from "../utils/record.js"
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
export type JsonSchema = Schema.Schema.Type<typeof JsonSchema>
export const mergeJsonRecords = (
...items: ReadonlyArray<Record<string, unknown> | undefined>
): Record<string, unknown> | undefined => {
+13 -19
View File
@@ -17,31 +17,25 @@ describe("request option precedence", () => {
test("deep-merges provider option records and replaces arrays, primitives, and null", () => {
const merged = mergeProviderOptions(
{
openai: {
include: ["route"],
metadata: { route: true, shared: "route" },
nullable: "route",
primitive: "route",
},
include: ["route"],
metadata: { route: true, shared: "route" },
nullable: "route",
primitive: "route",
},
{
openai: {
include: ["model"],
metadata: { model: true, shared: "model" },
nullable: null,
primitive: "model",
},
include: ["model"],
metadata: { model: true, shared: "model" },
nullable: null,
primitive: "model",
},
{ openai: { metadata: { request: true }, primitive: false } },
{ metadata: { request: true }, primitive: false },
)
expect(merged).toEqual({
openai: {
include: ["model"],
metadata: { route: true, model: true, request: true, shared: "model" },
nullable: null,
primitive: false,
},
include: ["model"],
metadata: { route: true, model: true, request: true, shared: "model" },
nullable: null,
primitive: false,
})
})
@@ -4,10 +4,11 @@ import { GoogleVertexResponses } from "../../src/providers.js"
const model = GoogleVertexResponses.configure({ accessToken: "test", project: "project" }).model("gemini")
LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "high" } })
LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({
model,
prompt: "Hello",
// @ts-expect-error Vertex Responses verbosity uses the Open Responses union.
providerOptions: { textVerbosity: "verbose" },
// @ts-expect-error Vertex Responses verbosity must be a string.
providerOptions: { textVerbosity: 1 },
})
@@ -4,6 +4,10 @@ import { OpenAICompatibleResponses } from "../../src/providers.js"
const model = OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("model")
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningSummary: "detailed" } })
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
LLM.request({ model, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({
model,
@@ -2,8 +2,14 @@ import { LLM } from "../../src/index.js"
import { OpenAI } from "../../src/providers.js"
const selected = OpenAI.responses("gpt-5")
const chat = OpenAI.chat("gpt-4o-mini")
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "low" } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { textVerbosity: "verbose" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "max" } })
LLM.request({ model: chat, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({
model: selected,
@@ -4,6 +4,7 @@ import { XAI } from "../../src/providers.js"
const model = XAI.provider.model("grok-4")
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "high" } })
LLM.request({ model, prompt: "Hello", providerOptions: { reasoningEffort: "experimental" } })
LLM.request({
model,
+13 -13
View File
@@ -15,13 +15,15 @@ const authHeaders = (
env: Record<string, string> = {},
) =>
Effect.runPromise(
selected.route.auth.apply({
request: LLM.request({ model: selected, prompt: "hello" }),
method: "POST",
url: "https://example.test/v1",
body: "{}",
headers: Headers.fromInput(headers),
}).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))),
selected.route.auth
.apply({
request: LLM.request({ model: selected, prompt: "hello" }),
method: "POST",
url: "https://example.test/v1",
body: "{}",
headers: Headers.fromInput(headers),
})
.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))),
)
const applyAuth = (
@@ -43,18 +45,16 @@ const applyAuth = (
describe("provider package credential lowering", () => {
test("intentionally renders keys and OAuth credentials as bearer auth", async () => {
const key = await applyAuth(ProviderPackage.bearerAuthOption({ type: "key", value: "provider-key" }))
const oauth = await applyAuth(
ProviderPackage.bearerAuthOption({ type: "oauth", accessToken: "provider-token" }),
)
const oauth = await applyAuth(ProviderPackage.bearerAuthOption({ type: "oauth", accessToken: "provider-token" }))
expect(key.authorization).toBe("Bearer provider-key")
expect(oauth.authorization).toBe("Bearer provider-token")
})
test("keeps key-header credentials configurable and removes stale keys for OAuth", async () => {
expect(
ProviderPackage.apiKeyOrBearerAuthOption({ type: "key", value: "provider-key" }, "x-api-key"),
).toEqual({ apiKey: "provider-key" })
expect(ProviderPackage.apiKeyOrBearerAuthOption({ type: "key", value: "provider-key" }, "x-api-key")).toEqual({
apiKey: "provider-key",
})
const oauth = ProviderPackage.apiKeyOrBearerAuthOption(
{ type: "oauth", accessToken: "provider-token" },
"x-api-key",
@@ -255,7 +255,7 @@ describe("OpenAI Chat route", () => {
LLMClient.generate(
LLMRequest.update(request, {
model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
baseURL: "https://opencode-test.openai.azure.com/openai/",
apiKey: "azure-key",
headers: { authorization: "Bearer stale" },
}).chat("gpt-4o-mini"),
@@ -178,6 +178,16 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("passes through custom OpenAI text verbosity strings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, { providerOptions: { textVerbosity: "verbose" } }),
)
expect(prepared.body.text).toEqual({ verbosity: "verbose" })
}),
)
it.effect("omits unsupported semantic service tiers", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+11 -2
View File
@@ -94,10 +94,19 @@ it.effect("projects request settings, headers, and body overlays", () =>
headers: { "x-test": "header" },
body: { safety_setting: "strict" },
})
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
const prepared = yield* compileRequest(
LLM.request({
model: resolved,
prompt: "Hello",
providerOptions: { safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }] },
}),
)
expect(prepared.body.providerOptions).toEqual({
google: { thinkingConfig: { thinkingBudget: 1024 } },
google: {
thinkingConfig: { thinkingBudget: 1024 },
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
},
})
expect(prepared.body.headers).toEqual({ "x-test": "header" })
expect(body).toEqual({ safety_setting: "strict" })
-2
View File
@@ -17,7 +17,6 @@ import { Connection } from "@opencode-ai/schema/connection"
import { Credential } from "@opencode-ai/schema/credential"
import { FileSystem } from "@opencode-ai/schema/filesystem"
import { Integration } from "@opencode-ai/schema/integration"
import { AI } from "@opencode-ai/schema/ai"
import { LLM } from "@opencode-ai/schema/llm"
import { Permission } from "@opencode-ai/schema/permission"
import { Pty } from "@opencode-ai/schema/pty"
@@ -96,7 +95,6 @@ test("Core reuses the canonical shared schemas", async () => {
[coreIntegration.Method, Integration.Method],
[coreIntegration.Ref, Integration.Ref],
[coreLocation.Ref, Location.Ref],
[coreAI.ProviderMetadata, AI.ProviderMetadata],
[coreAI.FinishReason, LLM.FinishReason],
[coreModel.ID, Model.ID],
[coreModel.VariantID, Model.VariantID],
-8
View File
@@ -1,8 +0,0 @@
export * as AI from "./ai.js"
import { Schema } from "effect"
export const ProviderMetadata = Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown)).annotate({
identifier: "AI.ProviderMetadata",
})
export type ProviderMetadata = Schema.Schema.Type<typeof ProviderMetadata>
-1
View File
@@ -8,7 +8,6 @@ export { FileSystem } from "./filesystem.js"
export { Form } from "./form.js"
export { Integration } from "./integration.js"
export { LLM } from "./llm.js"
export { AI } from "./ai.js"
export { Location } from "./location.js"
export { Mcp } from "./mcp.js"
export { Model } from "./model.js"
+1 -1
View File
@@ -39,7 +39,7 @@ describe("SessionError", () => {
})
})
test("FinishReason is the closed browser-safe provider set", () => {
test("FinishReason is the closed normalized provider set", () => {
const reasons = ["stop", "length", "tool-calls", "content-filter", "error", "unknown"] as const
expect(reasons.map((reason) => Schema.decodeUnknownSync(LLM.FinishReason)(reason))).toEqual([...reasons])
expect(() => Schema.decodeUnknownSync(LLM.FinishReason)("other")).toThrow()
@@ -46,6 +46,7 @@ const ADD_TAB_WIDTH = 3
const MARQUEE_DELAY = 600
const MARQUEE_INTERVAL = 80
const CONTEXT_MENU_WIDTH = 16
const MIDDLE_MOUSE_BUTTON = 1
const RIGHT_MOUSE_BUTTON = 2
type TabContextMenuState = {
@@ -569,6 +570,14 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
onMouseOver={() => marquee.enter(tab.sessionID, title(), hoveredTitleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === MIDDLE_MOUSE_BUTTON) {
didDrag = false
setDragging(undefined)
tabs.close(tab.sessionID)
event.preventDefault()
event.stopPropagation()
return
}
if (event.button === RIGHT_MOUSE_BUTTON) {
didDrag = false
setDragging(undefined)
@@ -1108,6 +1117,14 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onMouseOver={() => marquee.enter(tab.sessionID, title(), hoveredTitleWidth())}
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === MIDDLE_MOUSE_BUTTON) {
didDrag = false
setDragging(undefined)
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
event.preventDefault()
event.stopPropagation()
return
}
if (event.button === RIGHT_MOUSE_BUTTON) {
didDrag = false
setDragging(undefined)
@@ -1,5 +1,6 @@
/** @jsxImportSource @opentui/solid */
import { testRender } from "@opentui/solid"
import { MouseButton } from "@opentui/core"
import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { ConfigProvider } from "../../src/config"
@@ -60,3 +61,41 @@ test("releasing a transcript selection over tab controls does not activate them"
app.renderer.destroy()
}
})
test("middle-click closes a session tab without selecting it", async () => {
const [active, setActive] = createSignal("first")
const closed: Array<string | undefined> = []
const controller = {
tabs: () => [
{ sessionID: "first", title: "First" },
{ sessionID: "second", title: "Second" },
],
current: active,
select: setActive,
close: (sessionID?: string) => closed.push(sessionID),
move() {},
status: () => EMPTY_SESSION_TAB_STATUS,
} satisfies SessionTabsController
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<SessionTabs controller={controller} animations={false} />
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 60, height: 8 },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Second"))
await app.mockMouse.click(40, 0, MouseButton.MIDDLE)
expect(closed).toEqual(["second"])
expect(active()).toBe("first")
} finally {
app.renderer.destroy()
}
})