mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-11 19:36:25 +00:00
Compare commits
44
Commits
beta
...
route-models
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d66ff18186 | ||
|
|
f1d00cdc07 | ||
|
|
6bb4b35399 | ||
|
|
0ce1383030 | ||
|
|
010cd6131e | ||
|
|
c2348f8f69 | ||
|
|
71317ec7c9 | ||
|
|
4261fe749d | ||
|
|
9dd7149e75 | ||
|
|
0c0a431c9f | ||
|
|
3368e049d2 | ||
|
|
f0b5da1c11 | ||
|
|
15c525dcfb | ||
|
|
4d12e01824 | ||
|
|
70afbac80c | ||
|
|
1c723c56fa | ||
|
|
181428a2f3 | ||
|
|
9b1891fb7e | ||
|
|
d4bf78b348 | ||
|
|
d4ceffe787 | ||
|
|
872e38055e | ||
|
|
0c1dfa9186 | ||
|
|
8f4d706647 | ||
|
|
929374cdfd | ||
|
|
cfa5ba700e | ||
|
|
45a2ed9a97 | ||
|
|
f6333546f8 | ||
|
|
9e153ce7b3 | ||
|
|
eb357f17cf | ||
|
|
573d76933f | ||
|
|
bb8194395a | ||
|
|
2e8ed86658 | ||
|
|
2695607fbc | ||
|
|
f3ef84556a | ||
|
|
08ff21179c | ||
|
|
98a36fb1a4 | ||
|
|
e22cd0a585 | ||
|
|
8475783700 | ||
|
|
1452aadc87 | ||
|
|
1417976257 | ||
|
|
5ec7dd968c | ||
|
|
43fb543e3b | ||
|
|
ac7f3c5ece | ||
|
|
3edbc88225 |
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"] } },
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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"])
|
||||
})
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 އެވެ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "O‘zgaruvchan",
|
||||
"model.tag.latest": "Oxirgi",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user