Compare commits

...
44 Commits
Author SHA1 Message Date
Adam d66ff18186 fix(routing): retain refresh interval and localize pricing 2026-09-11 06:29:41 -05:00
Adam f1d00cdc07 feat(routing): add organization routes to model selectors 2026-09-11 05:44:53 -05:00
Brendan Allan 6bb4b35399 fix(app): preserve loopback server host (#44296) 2026-09-11 06:41:38 +00:00
Brendan Allan 0ce1383030 fix(app): preserve file search results while loading (#43832) 2026-09-11 06:13:32 +00:00
Brendan AllanandBrendonovich 010cd6131e fix(app): merge adjacent file-changing tools (#44977)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-09-11 05:58:02 +00:00
Brendan Allan c2348f8f69 fix(app): coordinate notifications across windows (#44613) 2026-09-11 05:28:13 +00:00
Aiden Cline 71317ec7c9 fix(core): report provider initialization failures (#48433) 2026-09-11 00:26:55 -05:00
opencode-agent[bot]andBrendonovich 4261fe749d fix(desktop): repair SSH server updates (#48442)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-11 13:24:38 +08:00
Aiden Cline 9dd7149e75 feat(core): append comment guidance to Anthropic system prompt (#48444) 2026-09-11 00:05:05 -05:00
Brendan Allan 0c0a431c9f fix(ui): make comment cancel action ghost (#46742) 2026-09-11 13:03:49 +08:00
Brendan Allan 3368e049d2 fix(app): refine home row hover actions (#47270) 2026-09-11 04:39:26 +00:00
Aiden Cline f0b5da1c11 feat(ai): type provider configuration errors (#48440) 2026-09-10 23:29:55 -05:00
Aiden Cline 15c525dcfb fix(merman): support & node groups in flowchart statements (#48425) 2026-09-10 22:24:59 -05:00
James Long 4d12e01824 refactor(tui): project production subgroups through tree engine (#48399) 2026-09-10 23:15:58 -04:00
James Long 70afbac80c docs(www): add core CLI theme concepts (#48426) 2026-09-10 23:13:53 -04:00
James Long 1c723c56fa feat(tui): add recursive grouping tree (#48394) 2026-09-10 21:40:23 -04:00
Aiden Cline 181428a2f3 fix(codemode): use Bun's wording for the missing atob/btoa argument (#48381) 2026-09-10 20:08:36 -05:00
Dax Raad 9b1891fb7e docs(cli): expand provider connections 2026-09-10 20:43:17 -04:00
Dax Raad d4bf78b348 docs: add websearch guides 2026-09-10 20:30:58 -04:00
Dax Raad d4ceffe787 docs: refresh v2 guides 2026-09-10 20:12:08 -04:00
Aiden Cline 872e38055e fix(ai): normalize flat Responses stream errors (#48376) 2026-09-10 18:23:32 -05:00
James Long 0c1dfa9186 refactor(tui): extract shared session rendering primitives (#48393) 2026-09-10 18:07:14 -04:00
Dax Raad 8f4d706647 feat(cli): add command docs and simplify session list 2026-09-10 16:27:12 -04:00
Aiden Cline 929374cdfd feat(core): parse JSON text results from MCP tools without an output schema (#48357) 2026-09-10 15:20:13 -05:00
opencode-agent[bot] cfa5ba700e fix(stats): canonicalize DeepSeek Flash usage (#48373) 2026-09-10 13:58:51 -05:00
Shoubhit Dash 45a2ed9a97 feat(core): per-session permission rules (#48351) 2026-09-10 22:30:53 +05:30
opencode-agent[bot]andnexxeln f6333546f8 fix(tui): disambiguate plugin actions (#48354)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-10 22:10:01 +05:30
opencode-agent[bot]andnexxeln 9e153ce7b3 fix(core): raise instruction entry limit (#48350)
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
2026-09-10 20:59:38 +05:30
Simon Klee eb357f17cf fix(tui): strip NUL characters before clipboard writes (#48337) 2026-09-10 15:47:03 +02:00
Shoubhit Dash 573d76933f fix(ai): make xAI Responses websockets work and enable them (#48318) 2026-09-10 17:27:43 +05:30
usrnk1andBrendonovich bb8194395a feat(desktop): respect follow-up behavior for slash commands (#48169)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-10 19:24:23 +08:00
opencode-agent[bot]andHona 2e8ed86658 fix(app): move compaction status below summary (#48262)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-10 18:27:08 +08:00
Brendan Allan 2695607fbc fix(app): stabilize optimistic prompt position (#46723) 2026-09-10 17:49:55 +08:00
Aiden Cline f3ef84556a feat(codemode): add atob, btoa, and crypto.randomUUID (#48290) 2026-09-10 01:21:41 -05:00
Aiden Cline 08ff21179c fix(codemode): hoist switch-case functions and memoize var names (#48287) 2026-09-10 00:42:08 -05:00
Aiden Cline 98a36fb1a4 feat(codemode): hoist var to function scope (#48284) 2026-09-10 00:27:58 -05:00
Aiden Cline e22cd0a585 refactor(codemode): collapse repeated loop, promise, and Object shapes (#48283) 2026-09-10 00:02:58 -05:00
Aiden Cline 8475783700 fix(tui): ignore parked synthetic inbox for tab busy (#48273) 2026-09-09 23:31:16 -05:00
Aiden Cline 1452aadc87 feat(codemode): add substr, well-formed, and Date string methods (#48275) 2026-09-09 23:29:34 -05:00
Aiden Cline 1417976257 fix(core): run compaction hooks before appending the summary prompt (#48276) 2026-09-09 22:54:00 -05:00
Aiden Cline 5ec7dd968c fix(codemode): coerce enumeration sources like JS ToObject (#48257) 2026-09-09 22:24:55 -05:00
Aiden ClineandAljosha Friemann 43fb543e3b fix(core): request summarized adaptive thinking (#48271)
Co-authored-by: Aljosha Friemann <1730315+afriemann@users.noreply.github.com>
2026-09-09 22:09:57 -05:00
Aiden Cline ac7f3c5ece fix(core): route Mantle GPT-OSS models through Chat (#48251) 2026-09-09 21:27:15 -05:00
Aiden Cline 3edbc88225 feat(plugin): add session compaction and generate hooks (#48212) 2026-09-09 21:03:27 -05:00
265 changed files with 8862 additions and 2753 deletions
@@ -18,7 +18,6 @@ const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Sche
])
const decodeMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(WebSocketResponseCreate))
const encodeMessage = Schema.encodeSync(Schema.fromJsonString(WebSocketResponseCreate))
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
export interface Options {
readonly id: string
@@ -27,6 +26,7 @@ export interface Options {
readonly enabled?: (url: string) => boolean
readonly url?: (url: string) => string
readonly headers?: (headers: Headers.Headers) => Headers.Headers
readonly continuation?: OpenResponsesContinuation.Shape
}
export interface Prepared {
@@ -60,7 +60,7 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
}),
observe: (_create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
),
@@ -163,6 +163,7 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
request: create.request,
message: create.message,
base,
continuation: options.continuation,
}),
}
})
@@ -6,7 +6,6 @@ import { OpenResponses } from "./open-responses.js"
const PROTOCOL = "open-responses.websocket.v1"
const VERSION = 1
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
interface CheckpointValue {
readonly version: typeof VERSION
@@ -15,12 +14,19 @@ interface CheckpointValue {
readonly output: ReadonlyArray<unknown>
}
/**
* Fields to send next to `previous_response_id` on an incremental step, or undefined to send the step in full.
* Whether omitted fields carry over from the continued response is provider behavior the route must know.
*/
export type Shape = (request: Readonly<Record<string, unknown>>) => Readonly<Record<string, unknown>> | undefined
export interface DriverInput {
readonly id: string
readonly name: string
readonly request: Readonly<Record<string, unknown>>
readonly message: string
readonly base: WebSocketChannelDriver
readonly continuation?: Shape
}
const checkpointValue = (checkpoint: ChannelCheckpoint | undefined): CheckpointValue | undefined => {
@@ -127,22 +133,26 @@ const rejected = (
export const driver = (input: DriverInput): WebSocketChannelDriver => {
const { previous_response_id: _previousResponseID, ...request } = input.request
const shape = input.continuation ?? ((fields: Readonly<Record<string, unknown>>) => fields)
let output: OpenResponses.StreamItem[] = []
return {
create: (checkpoint) =>
Effect.sync(() => {
output = []
const previous = checkpointValue(checkpoint)
const delta = previous ? incremental(request, previous) : undefined
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
// Ask the route first: diffing the whole history is wasted when it declines the continuation.
const fields = previous ? shape(request) : undefined
const delta = previous && fields ? incremental(request, previous) : undefined
if (!previous || !fields || !delta)
return { message: ProviderShared.encodeJson(request), mode: "full" as const }
return {
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
message: ProviderShared.encodeJson({ ...fields, input: delta, previous_response_id: previous.responseID }),
mode: "incremental" as const,
}
}),
observe: (create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
),
@@ -195,4 +205,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
}
}
export const OpenResponsesContinuation = { driver } as const
export * as OpenResponsesContinuation from "./open-responses-continuation.js"
+78 -15
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -86,6 +86,7 @@ export const OpenResponsesReasoningItem = Schema.Struct({
id: Schema.optionalKey(Schema.String),
summary: Schema.Array(OpenResponsesReasoningSummaryText),
encrypted_content: optionalNull(Schema.String),
provider_metadata: Schema.optional(JsonObject),
})
const OpenResponsesWebSearchCall = Schema.StructWithRest(
@@ -182,6 +183,7 @@ export const InputItem = Schema.Union([
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
status: Schema.optional(Schema.String),
provider_metadata: Schema.optional(JsonObject),
}),
OpenResponsesReasoningItem,
Schema.Struct({
@@ -191,6 +193,7 @@ export const InputItem = Schema.Union([
name: Schema.String,
namespace: Schema.optional(Schema.String),
arguments: Schema.String,
provider_metadata: Schema.optional(JsonObject),
}),
Schema.Struct({
type: Schema.tag("function_call_output"),
@@ -223,6 +226,7 @@ type OpenResponsesReasoningInput = {
id?: string
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
provider_metadata?: Record<string, unknown>
}
export const Tool = Schema.Struct({
type: Schema.tag("function"),
@@ -325,9 +329,8 @@ export const StreamItem = Schema.StructWithRest(
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// The Responses schema puts streaming error details at the top level and
// response failures under `response.error`. WebSocket failures use an
// event-level `error` envelope, so accept all three shapes here.
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
@@ -401,13 +404,44 @@ export const Event = Schema.StructWithRest(
headers: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
).pipe(
Schema.decode({
decode: SchemaGetter.transform((event) => {
if (event.type !== "error" || event.error != null) return event
const { code, message, param, ...rest } = event
if (code === undefined && message === undefined && param === undefined) return event
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
return { ...rest, error: { code, message, param } }
}),
encode: SchemaGetter.passthrough(),
}),
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
const decodeEventValue = Schema.decodeUnknownEffect(Event)
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
/**
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
*/
export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
? { ...value, type: "error" }
: value,
),
),
)
export interface ProviderAdapter {
readonly id: string
readonly name: string
/** Replay opaque gateway continuation state only for adapters that own this extension. */
readonly preserveProviderMetadata?: boolean
readonly nativeTool?: (
native: NonNullable<ToolDefinition["native"]>,
) => Effect.Effect<{ readonly type: string }, AIError>
@@ -427,6 +461,7 @@ export interface ParserState {
readonly id: string
readonly name: string
readonly providerMetadataKey: string
readonly preserveProviderMetadata: boolean
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
@@ -486,7 +521,16 @@ const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadata
return separator > 0 && separator < metadata.itemId.length - 1 ? metadata.itemId : undefined
}
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const replayProviderMetadata = (metadata: ProviderMetadata | undefined, key: string, adapter: ProviderAdapter) => {
const value = metadata?.[key]?.providerMetadata
return adapter.preserveProviderMetadata && ProviderShared.isRecord(value) ? { provider_metadata: value } : {}
}
const lowerToolCall = (
part: ToolCallPart,
providerMetadataKey: string,
adapter: ProviderAdapter,
): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
return {
type: "function_call",
@@ -495,10 +539,15 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
name: part.name,
namespace: part.namespace,
arguments: ProviderShared.encodeJson(part.input),
...replayProviderMetadata(part.providerMetadata, providerMetadataKey, adapter),
}
}
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const lowerReasoning = (
part: ReasoningPart,
providerMetadataKey: string,
adapter: ProviderAdapter,
): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata)) return undefined
const id = itemID(part.providerMetadata, providerMetadataKey)
@@ -511,6 +560,7 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
...(id === undefined ? {} : { id }),
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
...replayProviderMetadata(part.providerMetadata, providerMetadataKey, adapter),
}
}
@@ -659,6 +709,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
status: "completed",
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
...replayProviderMetadata(group.parts.at(-1)?.providerMetadata, providerMetadataKey, adapter),
})),
)
content.splice(0, content.length)
@@ -679,13 +730,14 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey)
const reasoning = lowerReasoning(part, providerMetadataKey, adapter)
if (!reasoning) continue
const existing = reasoning.id === undefined ? undefined : reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
if (typeof reasoning.encrypted_content === "string")
existing.encrypted_content = reasoning.encrypted_content
if (reasoning.provider_metadata !== undefined) existing.provider_metadata = reasoning.provider_metadata
continue
}
if (reasoning.id !== undefined) reasoningItems[reasoning.id] = reasoning
@@ -695,7 +747,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part, providerMetadataKey))
input.push(lowerToolCall(part, providerMetadataKey, adapter))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
@@ -1034,8 +1086,17 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const outputMetadata = (state: ParserState, item: OutputItem, extra?: Record<string, unknown>) =>
providerMetadata(state, {
itemId: item.id,
...extra,
...(state.preserveProviderMetadata && ProviderShared.isRecord(item.provider_metadata)
? { providerMetadata: item.provider_metadata }
: {}),
})
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
outputMetadata(state, item, { reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
// `output_item.added` (reasoning) →
@@ -1098,7 +1159,7 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
}
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
const metadata = outputMetadata(state, item)
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
@@ -1221,7 +1282,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
content.push(decoded.type === "output_text" ? decoded.text : decoded.refusal)
}
const text = content.length > 0 ? content.join("") : undefined
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const metadata = outputMetadata(state, item, phase === undefined ? undefined : { phase })
const events: LLMEvent[] = []
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
@@ -1236,10 +1297,11 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
? state.tools
const metadata = outputMetadata(state, item)
const pending = state.tools[item.id]
const registered = pending !== undefined
const tools = pending
? ToolStream.start(state.tools, item.id, { ...pending, providerMetadata: metadata })
: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name,
@@ -1493,6 +1555,7 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
id: adapter.id,
name: adapter.name,
providerMetadataKey: metadataKey(request.model),
preserveProviderMetadata: adapter.preserveProviderMetadata ?? false,
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
@@ -0,0 +1,72 @@
import { Effect, Schema } from "effect"
import { Route } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import type { LLMRequest } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { ProviderShared } from "./shared.js"
const ADAPTER = "organization-routes"
const adapter = {
id: ADAPTER,
name: "Organization routes",
preserveProviderMetadata: true,
} satisfies OpenResponses.ProviderAdapter
const Body = Schema.Struct({
...OpenResponses.coreFields,
store: Schema.Literal(false),
provider_options: Schema.Struct({
"openai-responses": Schema.Struct({ include: Schema.Array(Schema.String) }),
}),
stream: Schema.Literal(true),
})
const fromRequest = Effect.fn("OrganizationRoutes.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithAdapter(request, adapter)
// A route can select another provider on each call. Keep full history and only
// portable options; native caching and stored IDs would exclude translated
// targets. Scope encrypted reasoning to native Responses targets so their
// stateless continuations remain replayable without blocking other protocols.
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))({
model: body.model,
input: body.input,
instructions: body.instructions,
tools: body.tools,
tool_choice: body.tool_choice,
stream: true as const,
store: false as const,
provider_options: { "openai-responses": { include: ["reasoning.encrypted_content"] } },
max_output_tokens:
body.max_output_tokens !== undefined && body.max_output_tokens > 0 ? body.max_output_tokens : undefined,
temperature: body.temperature,
top_p: body.top_p,
parallel_tool_calls: body.parallel_tool_calls,
metadata: body.metadata,
reasoning: body.reasoning?.effort === undefined ? undefined : { effort: body.reasoning.effort },
text: body.text,
})
})
export const protocol = Protocol.make({
...OpenResponses.protocol,
id: ADAPTER,
body: { schema: Body, from: fromRequest },
stream: {
...OpenResponses.protocol.stream,
initial: (request: LLMRequest) => OpenResponses.initial(request, adapter),
},
})
export const route = Route.make({
id: ADAPTER,
providerMetadataKey: ADAPTER,
protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: HttpTransport.sseJson.with<typeof Body.Type>(),
defaults: { providerOptions: { store: false, include: [] } },
})
export * as OrganizationRoutes from "./organization-routes.js"
+8 -3
View File
@@ -6,7 +6,7 @@ import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("alibaba")
@@ -82,8 +82,13 @@ export const configure = (input: Config) => {
? hosts.get(region)
: `${workspaceID}.${region}.maas.aliyuncs.com`
if (baseURL === undefined) {
if (region === undefined) throw new Error("Alibaba requires region or baseURL")
if (host === undefined) throw new Error(`Alibaba region ${region} requires workspaceID or baseURL`)
if (region === undefined)
throw new ProviderConfigurationError({ provider: id, message: "Alibaba requires region or baseURL" })
if (host === undefined)
throw new ProviderConfigurationError({
provider: id,
message: `Alibaba region ${region} requires workspaceID or baseURL`,
})
}
const opts = { ...rest, auth: AuthOptions.bearer(input, ["DASHSCOPE_API_KEY", "ALIBABA_API_KEY"]) }
const common = { ...opts, endpoint: { baseURL: baseURL ?? `https://${host}/compatible-mode/v1` } }
@@ -1,9 +1,10 @@
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenAIResponses } from "../protocols/openai-responses.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("amazon-bedrock")
@@ -37,11 +38,10 @@ const responsesRoute = Route.make({
id: "bedrock-mantle-responses",
provider: id,
providerMetadataKey: "mantle",
protocol: OpenAIResponses.protocol,
endpoint: OpenAIResponses.route.endpoint,
auth: OpenAIResponses.route.auth,
transport: OpenAIResponses.httpTransport,
defaults: OpenAIResponses.route.defaults,
protocol: OpenResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: OpenResponses.httpTransport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
const chatRoute = OpenAIChat.route.with({
@@ -79,9 +79,12 @@ const defaults = (input: Config) => {
export const configure = (input: Config = {}) => {
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock Mantle bearer auth requires apiKey" })
if (input.auth === "sigv4" && input.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
throw new ProviderConfigurationError({
provider: id,
message: "Amazon Bedrock Mantle SigV4 auth does not accept apiKey",
})
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
+4 -3
View File
@@ -1,6 +1,6 @@
import type { RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import * as BedrockConverse from "../protocols/bedrock-converse.js"
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
@@ -39,8 +39,9 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
const configuredRoute = (input: Config) => {
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock bearer auth requires apiKey" })
if (auth === "sigv4" && apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock SigV4 auth does not accept apiKey" })
const resolvedRegion = BedrockAuth.resolveRegion(input)
return BedrockConverse.route.with({
...rest,
@@ -3,7 +3,7 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
@@ -36,8 +36,12 @@ const auth = (input: ProviderAuthOption<"optional">) => {
}
export const configure = (input: Config) => {
if (!input.baseURL) throw new Error("Anthropic-compatible providers require a baseURL")
const provider = input.provider ?? "anthropic-compatible"
if (!input.baseURL)
throw new ProviderConfigurationError({
provider: ProviderID.make(provider),
message: "Anthropic-compatible providers require a baseURL",
})
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const route = AnthropicMessages.route.with({
...rest,
@@ -61,8 +65,13 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
modelID,
settings,
) => {
// Read before the exclusivity check narrows a conflicting settings object to `never`.
const provider = ProviderID.make(settings.provider ?? id)
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
throw new ProviderConfigurationError({
provider,
message: "Anthropic-compatible apiKey cannot be combined with authToken",
})
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
+5 -2
View File
@@ -2,7 +2,7 @@ import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { AnthropicCompatible } from "./anthropic-compatible.js"
@@ -57,7 +57,10 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic apiKey cannot be combined with authToken")
throw new ProviderConfigurationError({
provider: id,
message: "Anthropic apiKey cannot be combined with authToken",
})
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
+2 -2
View File
@@ -3,7 +3,7 @@ import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { ProviderShared } from "../protocols/shared.js"
@@ -163,7 +163,7 @@ const config = (settings: Settings): Config => {
}
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
throw new Error("Azure requires resourceName or baseURL")
throw new ProviderConfigurationError({ provider: id, message: "Azure requires resourceName or baseURL" })
}
export const responsesModel: ProviderPackage.Definition<
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-ai-gateway")
@@ -35,7 +35,11 @@ export type Settings = ProviderPackage.Settings &
export const baseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
if (!input.accountId)
throw new ProviderConfigurationError({
provider: id,
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
})
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
}
@@ -3,7 +3,7 @@ import { OpenAIChat } from "../protocols/openai-chat.js"
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-workers-ai")
@@ -28,7 +28,11 @@ export type Settings = ProviderPackage.Settings &
export const baseURL = (input: WorkersAIURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
if (!input.accountId)
throw new ProviderConfigurationError({
provider: id,
message: "CloudflareWorkersAI.configure requires accountId unless baseURL is supplied",
})
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
}
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -37,7 +37,8 @@ const route = Route.make({
export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
if ("apiKey" in input && input.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
const {
accessToken: _accessToken,
auth: _auth,
@@ -74,7 +75,8 @@ export const provider = {
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
if (settings.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
@@ -67,7 +67,7 @@ export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new Error("Google Vertex Messages does not support API keys")
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
const {
accessToken: _accessToken,
auth: _auth,
@@ -107,7 +107,8 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
if (settings.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
@@ -39,7 +39,7 @@ export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new Error("Google Vertex Responses does not support API keys")
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
const {
accessToken: _accessToken,
auth: _auth,
@@ -79,7 +79,8 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
modelID,
settings,
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
if (settings.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
@@ -1,8 +1,10 @@
import type { AnyAuthClient } from "google-auth-library"
import { Effect, Redacted } from "effect"
import { Auth, MissingCredentialError } from "../route/auth.js"
import { ProviderConfigurationError, ProviderID } from "../schema/index.js"
const SCOPE = "https://www.googleapis.com/auth/cloud-platform"
const id = ProviderID.make("google-vertex")
export type OAuthOptions =
| { readonly accessToken?: string; readonly auth?: never }
@@ -35,12 +37,18 @@ export const host = (location: string) => {
export const requireProject = (value: string | undefined) => {
if (value) return value
throw new Error("Google Vertex requires a project when baseURL is not configured")
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex requires a project when baseURL is not configured",
})
}
export const apiKey = (input: ApiKeyOptions) => {
if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined))
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
if (input.accessToken !== undefined || input.auth !== undefined) return undefined
return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY
}
@@ -68,7 +76,10 @@ const adc = (project?: string) => {
export const oauth = (input: OAuthOptions, project?: string) => {
if (input.accessToken !== undefined && input.auth !== undefined)
throw new Error("Google Vertex accessToken cannot be combined with auth")
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex accessToken cannot be combined with auth",
})
if (input.auth) return input.auth
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
return adc(project)
+9 -3
View File
@@ -6,7 +6,7 @@ import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
export interface GeminiOptionsInput extends Gemini.OptionsInput {
@@ -93,7 +93,10 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
const apiKey = GoogleVertexShared.apiKey(input)
const endpointModel = String(modelID).startsWith("endpoints/")
if (apiKey !== undefined && endpointModel)
throw new Error("Google Vertex tuned models do not support Express Mode API keys")
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex tuned models do not support Express Mode API keys",
})
const location = GoogleVertexShared.location(inputLocation, "us-central1")
const project = GoogleVertexShared.project(inputProject)
const endpoint =
@@ -123,7 +126,10 @@ export const provider = {
}
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
return configure({
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
baseURL: settings.baseURL,
@@ -0,0 +1,57 @@
import type { ProviderPackage } from "../provider-package.js"
import { OrganizationRoutes } from "../protocols/organization-routes.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
export type Options = Pick<
OpenResponsesProviderOptionsInput,
"reasoningEffort" | "textVerbosity" | "parallelToolCalls" | "metadata" | "allowedTools"
>
export const id = ProviderID.make("organization-routes")
export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly provider?: string
readonly baseURL: string
readonly providerOptions?: Options
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: Options
}
export const routes = [OrganizationRoutes.route]
export const configure = (input: Config) => {
const provider = input.provider ?? "organization-routes"
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const route = OrganizationRoutes.route.with({
...rest,
provider,
endpoint: { baseURL },
auth: AuthOptions.bearer(input, []),
})
return {
id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model<Options>({ id: modelID }),
configure,
}
}
export const provider = { id, configure }
export const model: ProviderPackage.Definition<Settings, Options>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
+4
View File
@@ -41,6 +41,10 @@ const responsesRoute = Route.make({
id: "openai-responses",
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
// xAI continues a chain only from stored responses: with `store: false` (the route default) `previous_response_id`
// fails with "Response with id=… not found", so those steps are sent in full over the reused connection. It also
// rejects `instructions` next to `previous_response_id` and keeps the instructions of the response it continues.
continuation: ({ instructions: _instructions, ...request }) => (request.store === false ? undefined : request),
}),
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
+5 -1
View File
@@ -23,6 +23,7 @@ import {
LanguageModel,
LLMEvent,
InvalidProviderOutputError,
ProviderConfigurationError,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -128,7 +129,10 @@ const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
throw new ProviderConfigurationError({
provider: ProviderID.make(provider),
message: `Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`,
})
return LanguageModel.make<Options, Compact>({
...mapped,
provider,
+13
View File
@@ -50,6 +50,19 @@ export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOpe
route: Schema.optional(RouteID),
}) {}
/**
* Provider settings that are missing, conflicting, or unsupported, such as
* Azure without `resourceName` or `baseURL`. Thrown synchronously while a
* provider facade or package entrypoint configures a model, before any
* request exists, so it is not an `AIError` reason.
*/
export class ProviderConfigurationError extends Schema.TaggedError<ProviderConfigurationError>(
"AI.Error.ProviderConfiguration",
)("ProviderConfiguration", {
provider: ProviderID,
message: Schema.String,
}) {}
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
...ReasonFields,
route: RouteID,
+16 -11
View File
@@ -3,6 +3,9 @@ import { model } from "@opencode/ai/providers/openai"
import { LLM } from "../src/index.js"
import { Endpoint } from "../src/route/endpoint.js"
const configuration = (provider: string, message: string) =>
expect.objectContaining({ _tag: "ProviderConfiguration", provider, message })
describe("provider package entrypoints", () => {
test("semantic API aliases expose the same contract", async () => {
const modules = await Promise.all([
@@ -322,7 +325,7 @@ describe("provider package entrypoints", () => {
const AnthropicCompatible = await import("@opencode/ai/providers/anthropic-compatible")
expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
).toThrow("Anthropic-compatible providers require a baseURL")
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible providers require a baseURL"))
})
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
@@ -337,10 +340,10 @@ describe("provider package entrypoints", () => {
baseURL: "https://messages.example.test/v1",
},
]),
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible apiKey cannot be combined with authToken"))
expect(() =>
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
).toThrow("Anthropic apiKey cannot be combined with authToken")
).toThrow(configuration("anthropic", "Anthropic apiKey cannot be combined with authToken"))
})
test("maps legacy OpenAI organization and project settings to headers", () => {
@@ -490,43 +493,45 @@ describe("provider package entrypoints", () => {
"gemini-3.5-flash",
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
).toThrow(configuration("google-vertex", "Google Vertex apiKey cannot be combined with accessToken or auth"))
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
{ accessToken: "token", auth: {}, project: "vertex-project" },
])
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
expect(() => configured.model("gemini-3.5-flash")).toThrow(
configuration("google-vertex", "Google Vertex accessToken cannot be combined with auth"),
)
expect(() =>
Reflect.apply(GoogleVertexMessages.model, undefined, [
"claude-sonnet-4-6",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Messages does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
expect(() =>
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Messages does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
expect(() =>
Reflect.apply(GoogleVertexChat.model, undefined, [
"deepseek-ai/deepseek-v3.2-maas",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Chat does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
expect(() =>
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Chat does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
expect(() =>
Reflect.apply(GoogleVertexResponses.model, undefined, [
"xai/grok-4.20-reasoning",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Responses does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
expect(() =>
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Responses does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
})
})
+7 -1
View File
@@ -76,7 +76,13 @@ it.effect("Alibaba owns regional shared and workspace-specific endpoints", () =>
test("Alibaba requires explicit placement and supports complete base URL overrides", () => {
for (const region of ["eu-central-1", "ap-northeast-1", "future-region"])
expect(() => Alibaba.configure({ region })).toThrow("requires workspaceID or baseURL")
expect(() => Alibaba.configure({ region })).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "alibaba",
message: `Alibaba region ${region} requires workspaceID or baseURL`,
}),
)
for (const config of [
{ baseURL: "https://gateway.example/prefix" },
{ region: "future-region", workspaceID: "ignored", baseURL: "https://gateway.example/prefix" },
@@ -1458,7 +1458,13 @@ describe("Bedrock Converse route", () => {
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
}
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "amazon-bedrock",
message: "Amazon Bedrock SigV4 auth does not accept apiKey",
}),
)
}).pipe(
withProcessEnv({
...noAmbientAWS,
@@ -4,7 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message } from "../../src/index.js"
import { AmazonBedrockMantle } from "../../src/providers.js"
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { compileRequest, LLMClient } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { withProcessEnv } from "../lib/env.js"
@@ -25,7 +25,7 @@ describe("Amazon Bedrock Mantle provider", () => {
expect(provider.model).toBe(provider.responses)
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
expect(model).toBe(AmazonBedrockMantle.responsesModel)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport)
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
const responses = yield* compileRequest(
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
@@ -38,7 +38,7 @@ describe("Amazon Bedrock Mantle provider", () => {
})
expect(responses).toMatchObject({
route: "bedrock-mantle-responses",
protocol: "openai-responses",
protocol: "open-responses",
body: { model: "openai.gpt-oss-120b", store: false },
})
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
@@ -178,7 +178,7 @@ describe("Amazon Bedrock Mantle provider", () => {
const recorded = recordedTests({
prefix: "bedrock-mantle",
provider: "amazon-bedrock",
protocol: "openai-responses",
protocol: "open-responses",
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
metadata: { model: "openai.gpt-oss-120b" },
})
@@ -378,7 +378,11 @@ describe("Google Vertex providers", () => {
test("rejects tuned Gemini models in express mode", () => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
"Google Vertex tuned models do not support Express Mode API keys",
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "google-vertex",
message: "Google Vertex tuned models do not support Express Mode API keys",
}),
)
})
})
@@ -0,0 +1,78 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMClient } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { Meta } from "../../src/providers/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
const frame = {
type: "error",
sequence_number: 4,
code: "server_shutting_down",
message: "Server is shutting down. Please retry your request.",
param: null,
}
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
const event = yield* decode(JSON.stringify(frame))
expect(event).toEqual({
type: "error",
sequence_number: 4,
error: { code: frame.code, message: frame.message, param: null },
})
for (const unchanged of [
event,
{ type: "error" },
{
type: "response.failed",
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
},
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
]) {
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
}
}
}),
)
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
}),
)
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
Effect.gen(function* () {
const raw = `{
"type": "error",
"sequence_number": 4,
"code": "server_shutting_down",
"message": "Server is shutting down. Please retry your request.",
"param": null,
"diagnostic": "retain-original-frame"
}`
for (const model of [
Meta.configure({ apiKey: "fixture" }).responses("muse-spark-1.3"),
configure({ apiKey: "fixture", provider: "gateway", baseURL: "https://responses.example.test/v1" }).model(
"example-model",
),
]) {
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
Effect.provide(fixedResponse(sseEvents(raw.replaceAll("\n", "\ndata: ")))),
Effect.flip,
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toBe("server_shutting_down: Server is shutting down. Please retry your request.")
expect(error.reason.body).toBe(raw)
expect(error.reason.http?.status).toBe(200)
}
}),
)
@@ -90,7 +90,11 @@ const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
}
}
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
const continuationDriver = (
request: Readonly<Record<string, unknown>>,
base = baseChannelDriver,
continuation?: OpenResponsesContinuation.Shape,
) => {
const message = ProviderShared.encodeJson(request)
return OpenResponsesContinuation.driver({
id: "openai-responses",
@@ -98,6 +102,7 @@ const continuationDriver = (request: Readonly<Record<string, unknown>>, base = b
request,
message,
base: base(message),
continuation,
})
}
@@ -921,6 +926,58 @@ describe("OpenAI Responses route", () => {
type: "provider-failure",
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
})
// A retryable failure stays one: the runner retries it, and the transport has already dropped the
// checkpoint, so that retry is a full send. xAI reports every rejection this way.
const internal = ProviderShared.encodeJson({
type: "error",
error: { type: "api_error", message: "gRPC error: Response with id=resp_1 not found" },
})
expect(yield* second.observe(yield* second.create(saved), internal)).toMatchObject({
type: "provider-failure",
error: { reason: { _tag: "ProviderInternal" } },
})
}),
)
it.effect("shapes the incremental send with the route continuation", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "grok-4.6",
store: true,
instructions: "You are terse.",
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
}
const secondRequest = {
...firstRequest,
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
}
const saved = checkpoint(
yield* continuationDriver(firstRequest).observe(
yield* continuationDriver(firstRequest).create(undefined),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const trimmed = yield* continuationDriver(
secondRequest,
baseChannelDriver,
({ instructions: _, ...rest }) => rest,
).create(saved)
expect(trimmed.mode).toBe("incremental")
expect(JSON.parse(trimmed.message)).toEqual({
type: "response.create",
model: "grok-4.6",
store: true,
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
})
// Declining the continuation sends the step in full and never sends a previous_response_id.
const declined = yield* continuationDriver(secondRequest, baseChannelDriver, () => undefined).create(saved)
expect(declined.mode).toBe("full")
expect(JSON.parse(declined.message)).toEqual(secondRequest)
}),
)
@@ -0,0 +1,361 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { configure } from "../../src/providers/organization-routes.js"
import { provider } from "../../src/providers/openai-compatible-responses.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const model = configure({
apiKey: "session-test",
baseURL: "https://console.example.test/inference/route/openai/v1",
provider: "opencode-routes-org_test",
headers: { "x-opencode-org-id": "org_test" },
}).model("route/coding")
const fixtures = [
{
protocol: "openai-responses",
content: [
{
type: "reasoning",
id: "rs_native",
summary: [{ type: "summary_text", text: "Need a lookup" }],
encrypted_content: "opaque-openai-reasoning",
},
{ type: "message", id: "msg_native", role: "assistant", content: [{ type: "output_text", text: "Checking." }] },
{
type: "function_call",
id: "fc_native_lookup",
call_id: "call_lookup",
name: "lookup",
arguments: '{"query":"weather"}',
},
{ type: "function_call", id: "fc_native_clock", call_id: "call_clock", name: "clock", arguments: "{}" },
],
},
{
protocol: "google",
content: [
{ text: "Need a lookup", thought: true },
{ text: "Checking." },
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "opaque-google-signature" },
{ functionCall: { name: "clock", args: {} } },
],
},
{
protocol: "anthropic-messages",
content: [
{ type: "thinking", thinking: "Need a lookup", signature: "opaque-anthropic-signature" },
{ type: "text", text: "Checking." },
{ type: "tool_use", id: "call_lookup", name: "lookup", input: { query: "weather" } },
{ type: "tool_use", id: "call_clock", name: "clock", input: {} },
],
},
{
protocol: "openai-chat",
content: [
{
role: "assistant",
reasoning_content: "Need a lookup",
content: "Checking.",
tool_calls: [
{ id: "call_lookup", type: "function", function: { name: "lookup", arguments: '{"query":"weather"}' } },
{ id: "call_clock", type: "function", function: { name: "clock", arguments: "{}" } },
],
},
],
},
]
const finalText = sseEvents(
{
type: "response.output_item.done",
item: { type: "message", id: "msg_final", content: [{ type: "output_text", text: "Sunny." }] },
},
{ type: "response.completed", response: { id: "resp_final" } },
)
describe("organization routes", () => {
it.effect("uses the Responses route endpoint with stateless portable options", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model,
system: "Help with code.",
prompt: "Hello",
promptCacheKey: "session-cache",
providerOptions: {
store: true,
include: ["reasoning.encrypted_content"],
previousResponseId: "resp_old",
reasoningEffort: "low",
reasoningSummary: "auto",
serviceTier: "priority",
},
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(input.request.url).toBe("https://console.example.test/inference/route/openai/v1/responses")
expect(input.request.headers.authorization).toBe("Bearer session-test")
expect(input.request.headers["x-opencode-org-id"]).toBe("org_test")
expect(yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(input.text)).toEqual({
model: "route/coding",
input: [{ role: "user", content: [{ type: "input_text", text: "Hello" }] }],
instructions: "Help with code.",
stream: true,
store: false,
provider_options: { "openai-responses": { include: ["reasoning.encrypted_content"] } },
reasoning: { effort: "low" },
})
return input.respond(finalText, { headers: { "content-type": "text/event-stream" } })
}),
),
),
)
expect(response.text).toBe("Sunny.")
}),
)
it.effect("omits an unknown output limit", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello", generation: { maxTokens: 0 } }))
expect(prepared.body.max_output_tokens).toBeUndefined()
}),
)
for (const fixture of fixtures) {
it.effect(`preserves ${fixture.protocol} continuation metadata through a streamed parallel tool loop`, () =>
Effect.gen(function* () {
const metadata = {
protocol: fixture.protocol,
model: "native-model",
connection_id: "conn_native",
endpoint_id: "endpoint_native",
content: fixture.content,
group_id: "resp_tools",
}
const marker = { group_id: "resp_tools" }
const items = [
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Need a lookup" }],
provider_metadata: metadata,
},
{
type: "message",
id: "msg_1",
role: "assistant",
content: [{ type: "output_text", text: "Checking." }],
provider_metadata: marker,
},
{
type: "function_call",
id: "fc_lookup",
call_id: "call_lookup",
name: "lookup",
arguments: '{"query":"weather"}',
provider_metadata: marker,
},
{
type: "function_call",
id: "fc_clock",
call_id: "call_clock",
name: "clock",
arguments: "{}",
provider_metadata: marker,
},
]
const first = yield* LLMClient.generate(LLM.request({ model, prompt: "Check weather and time." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "reasoning", id: "rs_1", summary: [] },
},
{
type: "response.reasoning_summary_text.delta",
item_id: "rs_1",
summary_index: 0,
delta: "Need a lookup",
},
{
type: "response.output_item.added",
output_index: 1,
item: { type: "message", id: "msg_1", role: "assistant", content: [] },
},
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Checking." },
{
type: "response.output_item.added",
output_index: 2,
item: {
type: "function_call",
id: "fc_lookup",
call_id: "call_lookup",
name: "lookup",
arguments: "",
},
},
{ type: "response.function_call_arguments.delta", item_id: "fc_lookup", delta: '{"query":"weather"}' },
{
type: "response.output_item.added",
output_index: 3,
item: { type: "function_call", id: "fc_clock", call_id: "call_clock", name: "clock", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_clock", delta: "{}" },
...items.map((item, output_index) => ({ type: "response.output_item.done", output_index, item })),
{ type: "response.completed", response: { id: "resp_tools", output: items } },
),
),
),
)
expect(first.toolCalls).toHaveLength(2)
expect(first.events.filter(LLMEvent.is.toolCall)).toHaveLength(2)
expect(
first.message.content.map((part) => part.providerMetadata?.["opencode-routes-org_test"]?.providerMetadata),
).toEqual([metadata, marker, marker, marker])
const second = yield* LLMClient.generate(
LLM.request({
model,
providerOptions: { previousResponseId: "resp_tools" },
messages: [
Message.user("Check weather and time."),
first.message,
Message.tool({ id: "call_lookup", name: "lookup", result: { weather: "sunny" } }),
Message.tool({ id: "call_clock", name: "clock", result: { time: "noon" } }),
],
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const body = yield* Schema.decodeUnknownEffect(
Schema.fromJsonString(
Schema.Struct({
input: Schema.Array(Schema.Record(Schema.String, Schema.Unknown)),
store: Schema.Boolean,
include: Schema.optional(Schema.Array(Schema.String)),
previous_response_id: Schema.optional(Schema.String),
}),
),
)(input.text)
expect(body.store).toBe(false)
expect(body.include).toBeUndefined()
expect(body.previous_response_id).toBeUndefined()
expect(body.input.map((item) => item.type ?? item.role)).toEqual([
"user",
"reasoning",
"message",
"function_call",
"function_call",
"function_call_output",
"function_call_output",
])
expect(body.input.slice(1, 5).map((item) => item.provider_metadata)).toEqual([
metadata,
marker,
marker,
marker,
])
expect(body.input.slice(3, 5).map((item) => item.call_id)).toEqual(["call_lookup", "call_clock"])
return input.respond(finalText, { headers: { "content-type": "text/event-stream" } })
}),
),
),
)
expect(second.text).toBe("Sunny.")
}),
)
}
it.effect("preserves opaque state on an empty reasoning item", () =>
Effect.gen(function* () {
const metadata = {
protocol: "anthropic-messages",
content: [{ type: "redacted_thinking", data: "opaque" }],
group_id: "resp_empty",
}
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_empty", summary: [], provider_metadata: metadata },
},
{ type: "response.completed", response: { id: "resp_empty" } },
),
),
),
)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.input).toEqual([
{ type: "reasoning", id: "rs_empty", summary: [], encrypted_content: null, provider_metadata: metadata },
])
}),
)
it.effect("keeps opaque route metadata out of other Responses providers", () =>
Effect.gen(function* () {
const other = provider
.configure({
apiKey: "test-key",
baseURL: "https://other.example.test/v1",
provider: "opencode-routes-org_test",
})
.model("other")
const response = yield* LLMClient.generate(LLM.request({ model: other, prompt: "Hello" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Hi" }],
provider_metadata: { group_id: "resp_1", secret: "opaque" },
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content[0]?.providerMetadata).toEqual({ "opencode-routes-org_test": { itemId: "msg_1" } })
const replay = yield* compileRequest(
LLM.request({
model: other,
messages: [
Message.assistant({
type: "text",
text: "Hi",
providerMetadata: {
"opencode-routes-org_test": { itemId: "msg_1", providerMetadata: { secret: "opaque" } },
},
}),
],
}),
)
expect(replay.body.input).toEqual([
{
type: "message",
id: "msg_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Hi" }],
},
])
expect(replay.body.include).toEqual(["reasoning.encrypted_content"])
}),
)
})
+110 -2
View File
@@ -1,11 +1,18 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Effect, Layer, Stream } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { XAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import * as ProviderShared from "../../src/protocols/shared.js"
import { XAIResponses } from "../../src/protocols/xai-responses.js"
import { LLMClient } from "../../src/route.js"
import {
LLMClient,
RequestExecutor,
WebSocketTransport,
type ChannelCheckpoint,
type WebSocketChannelDriver,
} from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -13,6 +20,35 @@ import { sseEvents } from "../lib/sse.js"
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
/** Runs a request through the WebSocket transport and hands back its channel driver; the HTTP fallback answers. */
const channelDriver = (request: ReturnType<typeof LLM.request>) =>
Effect.gen(function* () {
let driver: WebSocketChannelDriver | undefined
yield* LLMClient.generate(request, {
webSocket: {
execute: (exchange) =>
Effect.sync(() => {
driver = exchange.driver
return { frames: exchange.fallback(), complete: Effect.void }
}),
},
}).pipe(Effect.provide(fixedResponse(sseEvents({ type: "response.completed", response: { id: "http" } }))))
if (!driver) throw new Error("Expected a WebSocket channel driver")
return driver
})
const completed = (driver: WebSocketChannelDriver, id: string) =>
Effect.gen(function* () {
const create = yield* driver.create(undefined)
yield* driver.observe(create, ProviderShared.encodeJson({ type: "response.created", response: { id } }))
const observation = yield* driver.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id } }),
)
if (observation.type !== "completed" || !observation.checkpoint) throw new Error("Expected a checkpoint")
return observation.checkpoint
})
describe("xAI Responses route", () => {
it.effect("composes the Open Responses baseline with xAI extensions", () =>
Effect.gen(function* () {
@@ -162,6 +198,78 @@ describe("xAI Responses route", () => {
}),
)
it.effect("classifies xAI's untyped WebSocket error envelope", () =>
Effect.gen(function* () {
// xAI answers a rejected response.create with an error envelope that carries no event type.
const envelope = ProviderShared.encodeJson({
error: {
message:
'Request validation error: {"code":"400","error":"Argument not supported: instructions and previous_response_id together"}',
type: "api_error",
},
})
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(envelope), close: Effect.void }),
})
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), { webSocket }).pipe(
Effect.provide(
LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
),
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toContain("Argument not supported: instructions and previous_response_id together")
expect(error.reason.body).toBe(envelope)
}),
)
it.effect("continues stored responses without instructions and sends unstored steps in full", () =>
Effect.gen(function* () {
const step = (store: boolean, ...prompts: string[]) =>
LLM.request({
model,
system: "You are terse.",
messages: prompts.map((prompt) => Message.user(prompt)),
providerOptions: { store },
})
const send = (store: boolean, checkpoint: ChannelCheckpoint) =>
channelDriver(step(store, "First", "Second")).pipe(Effect.flatMap((driver) => driver.create(checkpoint)))
const stored = yield* send(true, yield* completed(yield* channelDriver(step(true, "First")), "resp_1"))
expect(stored.mode).toBe("incremental")
expect(JSON.parse(stored.message)).toEqual({
type: "response.create",
model: "grok-4.6",
store: true,
include: ["reasoning.encrypted_content"],
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
})
// The connection cache only serves stored responses, so the default store: false never chains.
const unstored = yield* send(false, yield* completed(yield* channelDriver(step(false, "First")), "resp_1"))
expect(unstored.mode).toBe("full")
expect(JSON.parse(unstored.message)).toMatchObject({
instructions: "You are terse.",
store: false,
input: [
{ role: "user", content: [{ type: "input_text", text: "First" }] },
{ role: "user", content: [{ type: "input_text", text: "Second" }] },
],
})
expect(JSON.parse(unstored.message).previous_response_id).toBeUndefined()
}),
)
it.effect("parses xAI hosted tool items", () =>
Effect.gen(function* () {
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
@@ -19,11 +19,13 @@ story("cancelling a version mismatch permits reconnecting again", async ({ mount
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("alert")).toBeVisible()
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
await expect(dialog.getByRole("alert")).toBeVisible()
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
})
@@ -43,6 +45,17 @@ story("adding a server keeps all SSH challenges in the original connection dialo
await expect(dialog).toHaveCount(0)
})
story("adding an incompatible server advances to a dedicated update step", async ({ mount, page }) => {
await mount("app-dialog-ssh--incompatible-host")
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("alert")).toHaveCount(0)
await expect(dialog.getByRole("button", { name: "Update and reconnect", exact: true })).toBeVisible()
})
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
@@ -0,0 +1,74 @@
import { expect, test } from "@playwright/test"
import { sessionID, setupTimeline, userMessage } from "../performance/timeline-stability/fixture"
test("keeps a submitted prompt in place while its optimistic rows are measured", async ({ page }) => {
await setupTimeline(page, { messages: [userMessage()], seedHistory: true })
const release = Promise.withResolvers<void>()
await page.route(`**/api/session/${sessionID}/prompt`, async (route) => {
if (route.request().method() !== "POST") return route.fallback()
await release.promise
return route.fallback()
})
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
await expect(editor).toBeEditable()
await editor.fill("Observe optimistic prompt spacing.")
await expect
.poll(() =>
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
const root = element.parentElement!
return root.scrollHeight - root.clientHeight - root.scrollTop
}),
)
.toBe(0)
const observation = await page.evaluateHandle(() => {
const frames: { prompt?: number; working: boolean }[] = []
let frame = 0
const sample = () => {
const prompt = [...document.querySelectorAll<HTMLElement>('[data-timeline-row="UserMessage"]')].find((row) =>
row.textContent?.includes("Observe optimistic prompt spacing."),
)
frames.push({
...(prompt ? { prompt: prompt.getBoundingClientRect().y } : {}),
working: !!document.querySelector('[data-component="session-working"]'),
})
frame = requestAnimationFrame(sample)
}
frame = requestAnimationFrame(sample)
return {
stop: () => {
cancelAnimationFrame(frame)
return frames
},
}
})
const requested = page.waitForRequest(
(request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
)
try {
await editor.press("Enter")
await requested
const prompt = page
.locator('[data-timeline-row="UserMessage"]')
.filter({ hasText: "Observe optimistic prompt spacing." })
await expect(prompt).toBeInViewport()
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
await expect
.poll(() =>
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
const root = element.parentElement!
return root.scrollHeight - root.clientHeight - root.scrollTop
}),
)
.toBe(0)
const frames = await observation.evaluate((value) => value.stop())
expect(frames.some((frame) => frame.working && frame.prompt === undefined)).toBe(false)
const positions = frames.flatMap((frame) => (frame.prompt === undefined ? [] : [frame.prompt]))
expect(positions.length).toBeGreaterThan(0)
expect(new Set(positions).size).toBe(1)
} finally {
release.resolve()
await observation.dispose()
}
})
@@ -122,7 +122,15 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
)
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
await expect(compaction).toContainText("Streamed implementation details.")
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
const running = compaction.getByRole("status").getByLabel("Compacting", { exact: true })
await expect(running).toBeVisible()
await expect
.poll(async () => {
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
const status = await running.boundingBox()
return !!summary && !!status && status.y >= summary.y + summary.height
})
.toBe(true)
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
await timeline.send(
@@ -115,7 +115,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
})
const group = page.locator('[data-component="collapsed-tool-group"]')
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts"])
await timeline.send(
partUpdated(
toolPart(
@@ -134,7 +134,6 @@ test("combines follow-up patches into one three-file stack inside Used", async (
"true",
)
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
})
+16 -14
View File
@@ -88,8 +88,12 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (value.mode === "normal" && !command) {
session.handoff?.set(handoffMessage(value))
const optimisticBusy = !input.adapter.working()
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
if (optimisticBusy && input.adapter.kind === "new-session")
session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
if (optimisticBusy && input.adapter.kind === "active-session")
session.data.session.setStatus(session.id, "running")
}).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
@@ -122,15 +126,9 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
// Commands always steer: the server applies a command's configured
// agent and model immediately at admission, so queueing one would
// reconfigure the turn it is supposed to wait behind.
void sendCommand(
session,
{ ...value, delivery: "steer" },
command,
input.adapter.controls().model.selection.trackSessionCommit,
).catch((error) => failSubmission(input, session, "command", error, restore, value.id))
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
return
}
} finally {
@@ -322,7 +320,8 @@ async function sendCommand(
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
await applySelection(session, value.selection, track)
// Like queued prompts, queued commands must not apply the composer's selection to active work.
if (value.delivery === "steer") await applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
@@ -359,7 +358,8 @@ async function applySelection(
async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
track?: ModelSelection["trackSessionCommit"],
track: ModelSelection["trackSessionCommit"] | undefined,
onAdmit: () => void,
) {
const request = await buildSubmissionRequest(session, value)
// Switching agent or model reconfigures the session immediately, and with it
@@ -389,7 +389,9 @@ async function sendPrompt(
},
},
}
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
onAdmit()
await sending
}
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
+4 -2
View File
@@ -1,5 +1,6 @@
import { type Accessor, createMemo } from "solid-js"
import { DateTime } from "luxon"
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import { createSimpleContext } from "@opencode/ui/context"
import { useProviders } from "@/providers/catalog/providers"
@@ -79,8 +80,8 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
const list = createMemo(() =>
available().map((m) => ({
...m,
name: m.name.replace("(latest)", "").trim(),
latest: m.name.includes("(latest)"),
name: isOrganizationRouteProvider(m.provider.id) ? m.name : m.name.replace("(latest)", "").trim(),
latest: !isOrganizationRouteProvider(m.provider.id) && m.name.includes("(latest)"),
})),
)
@@ -100,6 +101,7 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
const state = visibility().get(key)
if (state === "hide") return false
if (state === "show") return true
if (isOrganizationRouteProvider(model.providerID)) return true
if (latestSet().has(key)) return true
const date = release().get(key)
if (!date?.isValid) return true
@@ -1,4 +1,5 @@
import { Popover } from "@kobalte/core/popover"
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
@@ -197,6 +198,9 @@ const ModelList: Component<{
>
<span class="min-w-0 truncate">{item.name}</span>
</Tooltip>
<Show when={isOrganizationRouteProvider(item.provider.id)}>
<Badge class="shrink-0">{language.t("model.tag.variable")}</Badge>
</Show>
<Show when={isFree(item.provider.id, item.cost)}>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
</Show>
@@ -489,6 +493,9 @@ function ModelSelectorPopoverView(props: {
onSelect={() => selectModel(item)}
>
<span class="min-w-0 truncate leading-5">{item.name}</span>
<Show when={isOrganizationRouteProvider(item.provider.id)}>
<Badge class="shrink-0">{language.t("model.tag.variable")}</Badge>
</Show>
<Show when={isFree(item.provider.id, item.cost)}>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
</Show>
+24 -14
View File
@@ -1,4 +1,5 @@
import { Show, type Component, type JSX } from "solid-js"
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
import { useLanguage } from "@/runtime/i18n/language"
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
@@ -8,6 +9,7 @@ type ModelInfo = {
id: string
name: string
provider: {
id?: string
name: string
}
capabilities?: {
@@ -36,7 +38,9 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
props,
) => {
const language = useLanguage()
const route = () => isOrganizationRouteProvider(props.model.provider.id ?? "")
const sourceName = (model: ModelInfo) => {
if (route()) return model.provider.name
const value = `${model.id} ${model.name}`.toLowerCase()
if (/claude|anthropic/.test(value)) return language.t("model.provider.anthropic")
@@ -58,14 +62,16 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
const title = () => {
const tags: Array<string> = []
if (props.latest) tags.push(language.t("model.tag.latest"))
if (props.free) tags.push(language.t("model.tag.free"))
if (route()) tags.push(language.t("model.tag.variable"))
if (!route() && props.free) tags.push(language.t("model.tag.free"))
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${sourceName(props.model)} ${props.model.name}${suffix}`
}
const name = () => {
const tags: Array<string> = []
if (props.latest) tags.push(language.t("model.tag.latest"))
if (props.free) tags.push(language.t("model.tag.free"))
if (route()) tags.push(language.t("model.tag.variable"))
if (!route() && props.free) tags.push(language.t("model.tag.free"))
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${props.model.name}${suffix}`
}
@@ -98,11 +104,13 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
<div class="flex w-[180px] flex-col gap-2">
<ModelTooltipRow name={language.t("model.tooltip.model")} value={name()} />
<ModelTooltipRow name={language.t("model.tooltip.provider")} value={props.model.provider.name} />
<Show when={inputs()}>
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
<Show when={!route()}>
<Show when={inputs()}>
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
</Show>
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
</Show>
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
</div>
)
}
@@ -110,15 +118,17 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
return (
<div class="flex flex-col gap-1 py-1">
<div class="text-13-medium">{title()}</div>
<Show when={inputs()}>
{(value) => (
<div class="text-12-regular text-text-invert-base">
{language.t("model.tooltip.allows", { inputs: value() })}
</div>
)}
<Show when={!route()}>
<Show when={inputs()}>
{(value) => (
<div class="text-12-regular text-text-invert-base">
{language.t("model.tooltip.allows", { inputs: value() })}
</div>
)}
</Show>
<div class="text-12-regular text-text-invert-base">{reasoning()}</div>
<div class="text-12-regular text-text-invert-base">{context()}</div>
</Show>
<div class="text-12-regular text-text-invert-base">{reasoning()}</div>
<div class="text-12-regular text-text-invert-base">{context()}</div>
</div>
)
}
+1
View File
@@ -280,6 +280,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ተቋርጧል",
"provider.disconnect.toast.disconnected.description": "{{provider}} ሞዴሎች ከአሁን በኋላ አይገኙም።",
"model.tag.free": "ነጻ",
"model.tag.variable": "ተለዋዋጭ",
"model.tag.latest": "የቅርብ",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -290,6 +290,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "تم فصل {{provider}}",
"provider.disconnect.toast.disconnected.description": "لم تعد نماذج {{provider}} متاحة.",
"model.tag.free": "مجاني",
"model.tag.variable": "متغير",
"model.tag.latest": "الأحدث",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -287,6 +287,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ayrıldı",
"provider.disconnect.toast.disconnected.description": "{{provider}} modelləri artıq mövcud deyil.",
"model.tag.free": "Pulsuz",
"model.tag.variable": "Dəyişkən",
"model.tag.latest": "Ən son",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -287,6 +287,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} прекъснат",
"provider.disconnect.toast.disconnected.description": "{{provider}} модели вече не са налични.",
"model.tag.free": "безплатно",
"model.tag.variable": "Променлива",
"model.tag.latest": "Последни",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict: Record<string, string> = {
"provider.disconnect.toast.disconnected.title": "{{provider}} সংযোগ বিচ্ছিন্ন",
"provider.disconnect.toast.disconnected.description": "{{provider}} মডেল আর উপলব্ধ নেই৷",
"model.tag.free": "বিনামূল্যে",
"model.tag.variable": "পরিবর্তনশীল",
"model.tag.latest": "সর্বশেষ",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -292,6 +292,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} desconectado",
"provider.disconnect.toast.disconnected.description": "Os modelos de {{provider}} não estão mais disponíveis.",
"model.tag.free": "Grátis",
"model.tag.variable": "Variável",
"model.tag.latest": "Mais recente",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -308,6 +308,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} modeli više nisu dostupni.",
"model.tag.free": "Besplatno",
"model.tag.variable": "Promjenjiva",
"model.tag.latest": "Najnovije",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -286,6 +286,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} desconnectat",
"provider.disconnect.toast.disconnected.description": "{{provider}} models ja no estan disponibles.",
"model.tag.free": "Gratuït",
"model.tag.variable": "Variable",
"model.tag.latest": "Última",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} odpojeno",
"provider.disconnect.toast.disconnected.description": "{{provider}} modely již nejsou k dispozici.",
"model.tag.free": "Zdarma",
"model.tag.variable": "Proměnlivá",
"model.tag.latest": "Nejnovější",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -205,6 +205,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} frakoblet",
"provider.disconnect.toast.disconnected.description": "Modeller fra {{provider}} er ikke længere tilgængelige.",
"model.tag.free": "Gratis",
"model.tag.variable": "Variabel",
"model.tag.latest": "Nyeste",
"model.provider.anthropic": "Anthropic",
+1
View File
@@ -196,6 +196,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} getrennt",
"provider.disconnect.toast.disconnected.description": "Die {{provider}}-Modelle sind nicht mehr verfügbar.",
"model.tag.free": "Kostenlos",
"model.tag.variable": "Variabel",
"model.tag.latest": "Neueste",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -289,6 +289,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ކަނެކްޓް ވެއްޖެއެވެ",
"provider.disconnect.toast.disconnected.description": "{{provider}} މޮޑެލްތައް މިހާރު ލިބެން ނެތެވެ.",
"model.tag.free": "ހިލޭ",
"model.tag.variable": "ބަދަލުވާ",
"model.tag.latest": "އެންމެފަހުގެ",
"model.provider.anthropic": "Anthropic އެވެ",
"model.provider.openai": "OpenAI އެވެ",
+1
View File
@@ -288,6 +288,7 @@ export const dict: Record<string, string> = {
"provider.disconnect.toast.disconnected.title": "{{provider}} མཐུད་ལམ་ཆད་ཡོདཔ།",
"provider.disconnect.toast.disconnected.description": "{{provider}} དཔེ་ཚད་ཚུ་ད་ལས་ཕར་འཐོབ་མི་ཚུགས།",
"model.tag.free": "རིན་མེད་སྟོང་པ",
"model.tag.variable": "འགྱུར་བཅོས་ཅན་",
"model.tag.latest": "ད༌རེས༌ནངས༌པ",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -285,6 +285,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} αποσυνδέθηκε",
"provider.disconnect.toast.disconnected.description": "{{provider}} μοντέλα δεν είναι πλέον διαθέσιμα.",
"model.tag.free": "Δωρεάν",
"model.tag.variable": "Μεταβλητή",
"model.tag.latest": "Τελευταία",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -267,6 +267,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.",
"model.tag.free": "Free",
"model.tag.variable": "Variable",
"model.tag.latest": "Latest",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -308,6 +308,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "Los modelos de {{provider}} ya no están disponibles.",
"model.tag.free": "Gratis",
"model.tag.variable": "Variable",
"model.tag.latest": "Más reciente",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} on katkestatud",
"provider.disconnect.toast.disconnected.description": "{{provider}} mudelit pole enam saadaval.",
"model.tag.free": "Tasuta",
"model.tag.variable": "Muutuv",
"model.tag.latest": "Viimased",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} قطع شد",
"provider.disconnect.toast.disconnected.description": "مدل های {{provider}} دیگر در دسترس نیستند.",
"model.tag.free": "رایگان",
"model.tag.variable": "متغیر",
"model.tag.latest": "آخرین",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -191,6 +191,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "Yhteys palveluntarjoajaan {{provider}} katkaistu",
"provider.disconnect.toast.disconnected.description": "{{provider}}-mallit eivät ole enää saatavilla.",
"model.tag.free": "Ilmainen",
"model.tag.variable": "Muuttuva",
"model.tag.latest": "Uusin",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} slitið",
"provider.disconnect.toast.disconnected.description": "{{provider}} modellir eru ikki tøkir longur.",
"model.tag.free": "Ókeypis",
"model.tag.variable": "Skiftandi",
"model.tag.latest": "Nýggjasta",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -295,6 +295,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} déconnecté",
"provider.disconnect.toast.disconnected.description": "Les modèles {{provider}} ne sont plus disponibles.",
"model.tag.free": "Gratuit",
"model.tag.variable": "Variable",
"model.tag.latest": "Le plus récent",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -282,6 +282,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} מנותק",
"provider.disconnect.toast.disconnected.description": "המודלים של {{provider}} אינם זמינים עוד.",
"model.tag.free": "חינם",
"model.tag.variable": "משתנה",
"model.tag.latest": "העדכני ביותר",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -291,6 +291,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} डिस्कनेक्ट हो गया",
"provider.disconnect.toast.disconnected.description": "{{provider}} मॉडल अब उपलब्ध नहीं हैं।",
"model.tag.free": "निःशुल्क",
"model.tag.variable": "परिवर्तनशील",
"model.tag.latest": "नवीनतम",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -288,6 +288,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} isključen",
"provider.disconnect.toast.disconnected.description": "{{provider}} modeli više nisu dostupni.",
"model.tag.free": "Besplatno",
"model.tag.variable": "Promjenjiva",
"model.tag.latest": "Najnoviji",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -288,6 +288,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} leválasztva",
"provider.disconnect.toast.disconnected.description": "A {{provider}} modellek már nem kaphatók.",
"model.tag.free": "Ingyenes",
"model.tag.variable": "Változó",
"model.tag.latest": "Legújabb",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -286,6 +286,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} անջատված է",
"provider.disconnect.toast.disconnected.description": "{{provider}} մոդելներն այլևս հասանելի չեն:",
"model.tag.free": "Անվճար",
"model.tag.variable": "Փոփոխական",
"model.tag.latest": "Վերջին",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -307,6 +307,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "Model {{provider}} tidak lagi tersedia.",
"model.tag.free": "Gratis",
"model.tag.variable": "Bervariasi",
"model.tag.latest": "Terbaru",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -288,6 +288,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} aftengdur",
"provider.disconnect.toast.disconnected.description": "{{provider}} gerðir eru ekki lengur fáanlegar.",
"model.tag.free": "Ókeypis",
"model.tag.variable": "Breytilegt",
"model.tag.latest": "Nýjasta",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -193,6 +193,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} disconnesso",
"provider.disconnect.toast.disconnected.description": "I modelli {{provider}} non sono più disponibili.",
"model.tag.free": "Gratuito",
"model.tag.variable": "Variabile",
"model.tag.latest": "Più recente",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -289,6 +289,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}}が切断されました",
"provider.disconnect.toast.disconnected.description": "{{provider}}のモデルは利用できなくなりました。",
"model.tag.free": "無料",
"model.tag.variable": "変動",
"model.tag.latest": "最新",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} გათიშულია",
"provider.disconnect.toast.disconnected.description": "{{provider}} მოდელები აღარ არის ხელმისაწვდომი.",
"model.tag.free": "უფასო",
"model.tag.variable": "ცვალებადი",
"model.tag.latest": "უახლესი",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ត្រូវបានផ្តាច់",
"provider.disconnect.toast.disconnected.description": "ម៉ូដែល {{provider}} លែងមានទៀតហើយ។",
"model.tag.free": "ឥតគិតថ្លៃ",
"model.tag.variable": "ប្រែប្រួល",
"model.tag.latest": "ចុងក្រោយ",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -186,6 +186,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} 연결 해제됨",
"provider.disconnect.toast.disconnected.description": "{{provider}} 모델을 더 이상 사용할 수 없습니다.",
"model.tag.free": "무료",
"model.tag.variable": "변동",
"model.tag.latest": "최신",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ຕັດການເຊື່ອມຕໍ່ແລ້ວ",
"provider.disconnect.toast.disconnected.description": "ໂມເດວ {{provider}} ບໍ່ມີແລ້ວ.",
"model.tag.free": "ຟຣີ",
"model.tag.variable": "ປ່ຽນແປງໄດ້",
"model.tag.latest": "ຫຼ້າສຸດ",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -289,6 +289,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} atjungtas",
"provider.disconnect.toast.disconnected.description": "{{provider}} modeliai nebepasiekiami.",
"model.tag.free": "Nemokama",
"model.tag.variable": "Kintama",
"model.tag.latest": "Naujausias",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} atvienots",
"provider.disconnect.toast.disconnected.description": "{{provider}} modeļi vairs nav pieejami.",
"model.tag.free": "Bezmaksas",
"model.tag.variable": "Mainīga",
"model.tag.latest": "Jaunākais",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -285,6 +285,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} исклучен",
"provider.disconnect.toast.disconnected.description": "{{provider}} моделите веќе не се достапни.",
"model.tag.free": "Бесплатно",
"model.tag.variable": "Променлива цена",
"model.tag.latest": "Најнови",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -287,6 +287,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} салгагдсан",
"provider.disconnect.toast.disconnected.description": "{{provider}} загварууд байхгүй болсон.",
"model.tag.free": "Үнэгүй",
"model.tag.variable": "Хувьсах үнэ",
"model.tag.latest": "Хамгийн сүүлийн үеийн",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} telah diputuskan",
"provider.disconnect.toast.disconnected.description": "Model {{provider}} tidak lagi tersedia.",
"model.tag.free": "Percuma",
"model.tag.variable": "Berubah-ubah",
"model.tag.latest": "Terkini",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -287,6 +287,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ချိတ်ဆက်မှု ပြတ်တောက်သွားသည်။",
"provider.disconnect.toast.disconnected.description": "{{provider}} မော်ဒယ်များကို မရနိုင်တော့ပါ။",
"model.tag.free": "အခမဲ့",
"model.tag.variable": "ပြောင်းလဲနိုင်သော",
"model.tag.latest": "နောက်ဆုံးထွက်",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -285,6 +285,7 @@ export const dict: Record<string, string> = {
"provider.disconnect.toast.disconnected.title": "{{provider}} जडान विच्छेद भयो",
"provider.disconnect.toast.disconnected.description": "{{provider}} मोडेलहरू अब उपलब्ध छैनन्।",
"model.tag.free": "नि:शुल्क",
"model.tag.variable": "परिवर्तनशील मूल्य",
"model.tag.latest": "पछिल्लो",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "Verbinding met {{provider}} verbroken",
"provider.disconnect.toast.disconnected.description": "{{provider}}-modellen zijn niet langer beschikbaar.",
"model.tag.free": "Gratis",
"model.tag.variable": "Variabel",
"model.tag.latest": "Nieuwste",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -306,6 +306,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "Modeller fra {{provider}} er ikke lenger tilgjengelige.",
"model.tag.free": "Gratis",
"model.tag.variable": "Variabel",
"model.tag.latest": "Nyeste",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -290,6 +290,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} منقطع",
"provider.disconnect.toast.disconnected.description": "{{provider}} ماڈل ہن دستیاب نئیں ہن۔",
"model.tag.free": "مفت",
"model.tag.variable": "بدلدی قیمت",
"model.tag.latest": "تازہ ترین",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -292,6 +292,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "Rozłączono {{provider}}",
"provider.disconnect.toast.disconnected.description": "Modele {{provider}} nie są już dostępne.",
"model.tag.free": "Darmowy",
"model.tag.variable": "Zmienna cena",
"model.tag.latest": "Najnowszy",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} deconectat",
"provider.disconnect.toast.disconnected.description": "Modelele {{provider}} nu mai sunt disponibile.",
"model.tag.free": "Gratuit",
"model.tag.variable": "Preț variabil",
"model.tag.latest": "Ultimul",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -306,6 +306,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} отключён",
"provider.disconnect.toast.disconnected.description": "Модели {{provider}} больше недоступны.",
"model.tag.free": "Бесплатно",
"model.tag.variable": "Цена меняется",
"model.tag.latest": "Последняя",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict: Record<string, string> = {
"provider.disconnect.toast.disconnected.title": "{{provider}} විසන්ධි විය",
"provider.disconnect.toast.disconnected.description": "{{provider}} මාදිලි තවදුරටත් නොමැත.",
"model.tag.free": "නොමිලේ",
"model.tag.variable": "විචල්‍ය",
"model.tag.latest": "නවතම",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} odpojený",
"provider.disconnect.toast.disconnected.description": "Modely {{provider}} už nie sú dostupné.",
"model.tag.free": "Bezplatné",
"model.tag.variable": "Premenlivá cena",
"model.tag.latest": "Najnovšie",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} prekinjen",
"provider.disconnect.toast.disconnected.description": "Modeli {{provider}} niso več na voljo.",
"model.tag.free": "Brezplačno",
"model.tag.variable": "Spremenljiva cena",
"model.tag.latest": "Zadnje",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} u shkëput",
"provider.disconnect.toast.disconnected.description": "Modelet {{provider}} nuk janë më të disponueshme.",
"model.tag.free": "Falas",
"model.tag.variable": "Çmim i ndryshueshëm",
"model.tag.latest": "E fundit",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} прекинут",
"provider.disconnect.toast.disconnected.description": "{{provider}} модели више нису доступни.",
"model.tag.free": "Бесплатно",
"model.tag.variable": "Променљива цена",
"model.tag.latest": "Најновије",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -285,6 +285,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} frånkopplad",
"provider.disconnect.toast.disconnected.description": "{{provider}}-modeller är inte längre tillgängliga.",
"model.tag.free": "Gratis",
"model.tag.variable": "Varierande",
"model.tag.latest": "Senaste",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -285,6 +285,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ҷудо карда шудааст",
"provider.disconnect.toast.disconnected.description": "{{provider}} моделҳо дигар дастрас нестанд.",
"model.tag.free": "Озод",
"model.tag.variable": "Тағйирёбанда",
"model.tag.latest": "Охирин",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -305,6 +305,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "โมเดล {{provider}} ไม่พร้อมใช้งานอีกต่อไป",
"model.tag.free": "ฟรี",
"model.tag.variable": "ผันแปร",
"model.tag.latest": "ล่าสุด",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} kesildi",
"provider.disconnect.toast.disconnected.description": "{{provider}} modelleri indi ýok.",
"model.tag.free": "Mugt",
"model.tag.variable": "Üýtgeýän",
"model.tag.latest": "Iň soňky",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -312,6 +312,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} modelleri artık kullanılamıyor.",
"model.tag.free": "Ücretsiz",
"model.tag.variable": "Değişken",
"model.tag.latest": "En yeni",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -308,6 +308,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "Моделі {{provider}} більше недоступні.",
"model.tag.free": "Безкоштовно",
"model.tag.variable": "Змінна ціна",
"model.tag.latest": "Остання",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -293,6 +293,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} منقطع ہو گیا۔",
"provider.disconnect.toast.disconnected.description": "{{provider}} ماڈلز اب دستیاب نہیں ہیں۔",
"model.tag.free": "مفت",
"model.tag.variable": "متغیر قیمت",
"model.tag.latest": "تازہ ترین",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -286,6 +286,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} uzildi",
"provider.disconnect.toast.disconnected.description": "{{provider}} modellari endi mavjud emas.",
"model.tag.free": "Bepul",
"model.tag.variable": "Ozgaruvchan",
"model.tag.latest": "Oxirgi",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -291,6 +291,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} bị ngắt kết nối",
"provider.disconnect.toast.disconnected.description": "Các mô hình {{provider}} không còn khả dụng.",
"model.tag.free": "Miễn phí",
"model.tag.variable": "Giá thay đổi",
"model.tag.latest": "Mới nhất",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -327,6 +327,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
"model.tag.free": "免费",
"model.tag.variable": "浮动价格",
"model.tag.latest": "最新",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",

Some files were not shown because too many files have changed in this diff Show More