Compare commits

..
Author SHA1 Message Date
Shoubhit Dash d5518553b5 Merge remote-tracking branch 'origin/v2' into session-diff
# Conflicts:
#	packages/server/src/handlers/session.ts
2026-09-10 16:52:26 +05:30
Shoubhit Dash 6eb2042acd Merge remote-tracking branch 'origin/v2' into session-diff
# Conflicts:
#	packages/client/src/effect/api/api.ts
#	packages/core/src/session.ts
#	packages/core/test/git.test.ts
#	packages/protocol/src/groups/session.ts
#	packages/server/src/handlers/session-error.ts
#	packages/server/src/handlers/session.ts
2026-09-08 19:30:14 +05:30
Shoubhit Dash 54504ab3a5 fix(client): synthesize idle messages live
The solid data layer mirrors every projected marker message from its event so the in-memory transcript matches the server before the next read; do the same for the idle marker on execution succeeded, failed, and non-shutdown interrupted.
2026-09-07 23:57:17 +05:30
Shoubhit Dash cc5086d127 feat(session): add turn diff route
GET /api/session/:sessionID/diff?messageID&to&context returns FileDiff.Info[] for the turn containing a user message (default: the newest one), or the contiguous range through a later user message's turn. A turn runs from the first prompt after the Session was last idle until its idle marker, so steers belong to the turn they interrupted; Sessions without markers fall back to prompt-to-next-prompt. The diff compares the range's first recorded step snapshot with its last recorded one, or with the working copy only while the Session is actively executing, resolves the snapshot repository from the Location in effect at the range (rejecting ranges that span a move), and defaults to full-file patches like vcs.diff. Shared missingMessage and failedSnapshot handler helpers replace the inlined mappings in the session handlers.
2026-09-07 22:08:19 +05:30
Shoubhit Dash b20482461c feat(session): record idle boundaries as messages
Project an idle message when a busy period ends (execution succeeded, failed, or interrupted for any reason other than shutdown, which resumes the same turn). Every step since the previous marker is one turn, including prompts steered in while the Session was busy, so turns are derivable from session_message alone without persisting events or a separate table. The marker is invisible to the model and to the TUI and web transcripts.
2026-09-07 22:00:36 +05:30
Shoubhit Dash 5b5368fe98 perf(core): batch snapshot tree diffs
Git.tree.diff ran --name-status, --numstat, and a patch once per changed file, sequentially, so a turn or revert touching N files cost 1 + 3N git processes (~50ms per file). Run the three once over the tree pair, split the patch with VcsPatch.chunksByFile, cap patch output at MAX_TOTAL_PATCH_BYTES like VCS diffs (capped files get an empty patch, stats stay exact), keep core.quotepath=false so non-ASCII paths still match their chunk, and pass --no-ext-diff. Snapshot.diff diffs first and filters ignored paths from the result instead of listing changed files twice and passing every path as a pathspec.
2026-09-07 21:53:19 +05:30
237 changed files with 3788 additions and 7313 deletions
@@ -18,6 +18,7 @@ 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
@@ -26,7 +27,6 @@ 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* OpenResponses.decodeChannelEvent(frame).pipe(
const event = yield* decodeEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
),
@@ -163,7 +163,6 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
request: create.request,
message: create.message,
base,
continuation: options.continuation,
}),
}
})
@@ -6,6 +6,7 @@ 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
@@ -14,19 +15,12 @@ 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 => {
@@ -133,26 +127,22 @@ 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)
// 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 }
const delta = previous ? incremental(request, previous) : undefined
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
return {
message: ProviderShared.encodeJson({ ...fields, input: delta, previous_response_id: previous.responseID }),
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
mode: "incremental" as const,
}
}),
observe: (create, frame) =>
Effect.gen(function* () {
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
const event = yield* decodeEvent(frame).pipe(
Effect.mapError((cause) =>
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
),
@@ -205,4 +195,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
}
}
export * as OpenResponsesContinuation from "./open-responses-continuation.js"
export const OpenResponsesContinuation = { driver } as const
+15 -78
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema, SchemaGetter } from "effect"
import { Effect, Option, Schema } from "effect"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -86,7 +86,6 @@ 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(
@@ -183,7 +182,6 @@ 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({
@@ -193,7 +191,6 @@ 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"),
@@ -226,7 +223,6 @@ 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"),
@@ -329,8 +325,9 @@ export const StreamItem = Schema.StructWithRest(
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// 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.
// https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
@@ -404,44 +401,13 @@ 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>
@@ -461,7 +427,6 @@ 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
@@ -521,16 +486,7 @@ const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadata
return separator > 0 && separator < metadata.itemId.length - 1 ? metadata.itemId : undefined
}
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 lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
return {
type: "function_call",
@@ -539,15 +495,10 @@ const lowerToolCall = (
name: part.name,
namespace: part.namespace,
arguments: ProviderShared.encodeJson(part.input),
...replayProviderMetadata(part.providerMetadata, providerMetadataKey, adapter),
}
}
const lowerReasoning = (
part: ReasoningPart,
providerMetadataKey: string,
adapter: ProviderAdapter,
): OpenResponsesReasoningInput | undefined => {
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata)) return undefined
const id = itemID(part.providerMetadata, providerMetadataKey)
@@ -560,7 +511,6 @@ const lowerReasoning = (
...(id === undefined ? {} : { id }),
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
...replayProviderMetadata(part.providerMetadata, providerMetadataKey, adapter),
}
}
@@ -709,7 +659,6 @@ 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)
@@ -730,14 +679,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey, adapter)
const reasoning = lowerReasoning(part, providerMetadataKey)
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
@@ -747,7 +695,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part, providerMetadataKey, adapter))
input.push(lowerToolCall(part, providerMetadataKey))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
@@ -1086,17 +1034,8 @@ 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) =>
outputMetadata(state, item, { reasoningEncryptedContent: item.encrypted_content ?? null })
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
// `output_item.added` (reasoning) →
@@ -1159,7 +1098,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 = outputMetadata(state, item)
const metadata = providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
@@ -1282,7 +1221,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 = outputMetadata(state, item, phase === undefined ? undefined : { phase })
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const events: LLMEvent[] = []
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
@@ -1297,11 +1236,10 @@ 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 = 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 })
const metadata = providerMetadata(state, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
? state.tools
: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name,
@@ -1555,7 +1493,6 @@ 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(),
@@ -1,72 +0,0 @@
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"
+3 -8
View File
@@ -6,7 +6,7 @@ import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderConfigurationError, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
import { ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("alibaba")
@@ -82,13 +82,8 @@ export const configure = (input: Config) => {
? hosts.get(region)
: `${workspaceID}.${region}.maas.aliyuncs.com`
if (baseURL === undefined) {
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`,
})
if (region === undefined) throw new Error("Alibaba requires region or baseURL")
if (host === undefined) throw new Error(`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` } }
@@ -4,7 +4,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("amazon-bedrock")
@@ -79,12 +79,9 @@ 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 ProviderConfigurationError({ provider: id, message: "Amazon Bedrock Mantle bearer auth requires apiKey" })
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (input.auth === "sigv4" && input.apiKey !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Amazon Bedrock Mantle SigV4 auth does not accept apiKey",
})
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
+3 -4
View File
@@ -1,6 +1,6 @@
import type { RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { 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,9 +39,8 @@ 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 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" })
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")
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 { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
@@ -36,12 +36,8 @@ 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,
@@ -65,13 +61,8 @@ 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 ProviderConfigurationError({
provider,
message: "Anthropic-compatible apiKey cannot be combined with authToken",
})
throw new Error("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 -5
View File
@@ -2,7 +2,7 @@ import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { AnthropicCompatible } from "./anthropic-compatible.js"
@@ -57,10 +57,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Anthropic apiKey cannot be combined with authToken",
})
throw new Error("Anthropic apiKey cannot be combined with authToken")
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
+2 -2
View File
@@ -3,7 +3,7 @@ import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { 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 ProviderConfigurationError({ provider: id, message: "Azure requires resourceName or baseURL" })
throw new Error("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 { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-ai-gateway")
@@ -35,11 +35,7 @@ export type Settings = ProviderPackage.Settings &
export const baseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId)
throw new ProviderConfigurationError({
provider: id,
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
})
if (!input.accountId) throw new Error("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 { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-workers-ai")
@@ -28,11 +28,7 @@ export type Settings = ProviderPackage.Settings &
export const baseURL = (input: WorkersAIURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId)
throw new ProviderConfigurationError({
provider: id,
message: "CloudflareWorkersAI.configure requires accountId unless baseURL is supplied",
})
if (!input.accountId) throw new Error("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 { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -37,8 +37,7 @@ const route = Route.make({
export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
const {
accessToken: _accessToken,
auth: _auth,
@@ -75,8 +74,7 @@ export const provider = {
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
if (settings.apiKey !== undefined) throw new Error("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 { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { 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 ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
throw new Error("Google Vertex Messages does not support API keys")
const {
accessToken: _accessToken,
auth: _auth,
@@ -107,8 +107,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
modelID,
settings,
) => {
if (settings.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
if (settings.apiKey !== undefined) throw new Error("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 { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { 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 ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
throw new Error("Google Vertex Responses does not support API keys")
const {
accessToken: _accessToken,
auth: _auth,
@@ -79,8 +79,7 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
modelID,
settings,
) => {
if (settings.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
@@ -1,10 +1,8 @@
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 }
@@ -37,18 +35,12 @@ export const host = (location: string) => {
export const requireProject = (value: string | undefined) => {
if (value) return value
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex requires a project when baseURL is not configured",
})
throw new Error("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 ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
throw new Error("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
}
@@ -76,10 +68,7 @@ const adc = (project?: string) => {
export const oauth = (input: OAuthOptions, project?: string) => {
if (input.accessToken !== undefined && input.auth !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex accessToken cannot be combined with auth",
})
throw new Error("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)
+3 -9
View File
@@ -6,7 +6,7 @@ import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderConfigurationError, ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
export interface GeminiOptionsInput extends Gemini.OptionsInput {
@@ -93,10 +93,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
const apiKey = GoogleVertexShared.apiKey(input)
const endpointModel = String(modelID).startsWith("endpoints/")
if (apiKey !== undefined && endpointModel)
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex tuned models do not support Express Mode API keys",
})
throw new Error("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 =
@@ -126,10 +123,7 @@ export const provider = {
}
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
return configure({
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
baseURL: settings.baseURL,
@@ -1,57 +0,0 @@
import type { ProviderPackage } from "../provider-package.js"
import { OrganizationRoutes } from "../protocols/organization-routes.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
export type Options = Pick<
OpenResponsesProviderOptionsInput,
"reasoningEffort" | "textVerbosity" | "parallelToolCalls" | "metadata" | "allowedTools"
>
export const id = ProviderID.make("organization-routes")
export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly provider?: string
readonly baseURL: string
readonly providerOptions?: Options
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: Options
}
export const routes = [OrganizationRoutes.route]
export const configure = (input: Config) => {
const provider = input.provider ?? "organization-routes"
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const route = OrganizationRoutes.route.with({
...rest,
provider,
endpoint: { baseURL },
auth: AuthOptions.bearer(input, []),
})
return {
id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model<Options>({ id: modelID }),
configure,
}
}
export const provider = { id, configure }
export const model: ProviderPackage.Definition<Settings, Options>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
-4
View File
@@ -41,10 +41,6 @@ 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"] } },
})
+1 -5
View File
@@ -23,7 +23,6 @@ import {
LanguageModel,
LLMEvent,
InvalidProviderOutputError,
ProviderConfigurationError,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -129,10 +128,7 @@ 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 ProviderConfigurationError({
provider: ProviderID.make(provider),
message: `Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`,
})
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return LanguageModel.make<Options, Compact>({
...mapped,
provider,
-13
View File
@@ -50,19 +50,6 @@ 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,
+11 -16
View File
@@ -3,9 +3,6 @@ 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([
@@ -325,7 +322,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(configuration("anthropic-compatible", "Anthropic-compatible providers require a baseURL"))
).toThrow("Anthropic-compatible providers require a baseURL")
})
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
@@ -340,10 +337,10 @@ describe("provider package entrypoints", () => {
baseURL: "https://messages.example.test/v1",
},
]),
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible apiKey cannot be combined with authToken"))
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
expect(() =>
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
).toThrow(configuration("anthropic", "Anthropic apiKey cannot be combined with authToken"))
).toThrow("Anthropic apiKey cannot be combined with authToken")
})
test("maps legacy OpenAI organization and project settings to headers", () => {
@@ -493,45 +490,43 @@ describe("provider package entrypoints", () => {
"gemini-3.5-flash",
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex apiKey cannot be combined with accessToken or auth"))
).toThrow("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(
configuration("google-vertex", "Google Vertex accessToken cannot be combined with auth"),
)
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
expect(() =>
Reflect.apply(GoogleVertexMessages.model, undefined, [
"claude-sonnet-4-6",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
).toThrow("Google Vertex Messages does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
).toThrow("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(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
).toThrow("Google Vertex Chat does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
).toThrow("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(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
).toThrow("Google Vertex Responses does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
).toThrow("Google Vertex Responses does not support API keys")
})
})
+1 -7
View File
@@ -76,13 +76,7 @@ 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(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "alibaba",
message: `Alibaba region ${region} requires workspaceID or baseURL`,
}),
)
expect(() => Alibaba.configure({ region })).toThrow("requires workspaceID or baseURL")
for (const config of [
{ baseURL: "https://gateway.example/prefix" },
{ region: "future-region", workspaceID: "ignored", baseURL: "https://gateway.example/prefix" },
@@ -1458,13 +1458,7 @@ 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(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "amazon-bedrock",
message: "Amazon Bedrock SigV4 auth does not accept apiKey",
}),
)
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
}).pipe(
withProcessEnv({
...noAmbientAWS,
@@ -378,11 +378,7 @@ describe("Google Vertex providers", () => {
test("rejects tuned Gemini models in express mode", () => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "google-vertex",
message: "Google Vertex tuned models do not support Express Mode API keys",
}),
"Google Vertex tuned models do not support Express Mode API keys",
)
})
})
@@ -1,78 +0,0 @@
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,11 +90,7 @@ const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
}
}
const continuationDriver = (
request: Readonly<Record<string, unknown>>,
base = baseChannelDriver,
continuation?: OpenResponsesContinuation.Shape,
) => {
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
const message = ProviderShared.encodeJson(request)
return OpenResponsesContinuation.driver({
id: "openai-responses",
@@ -102,7 +98,6 @@ const continuationDriver = (
request,
message,
base: base(message),
continuation,
})
}
@@ -926,58 +921,6 @@ 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)
}),
)
@@ -1,361 +0,0 @@
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"])
}),
)
})
+2 -110
View File
@@ -1,18 +1,11 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Stream } from "effect"
import { Effect } 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,
RequestExecutor,
WebSocketTransport,
type ChannelCheckpoint,
type WebSocketChannelDriver,
} from "../../src/route.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -20,35 +13,6 @@ 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* () {
@@ -198,78 +162,6 @@ 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,13 +19,11 @@ 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("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("alert")).toBeVisible()
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("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("alert")).toBeVisible()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
})
@@ -45,17 +43,6 @@ 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()
@@ -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.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts"])
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
await timeline.send(
partUpdated(
toolPart(
@@ -134,6 +134,7 @@ 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"])
})
+10 -5
View File
@@ -126,9 +126,15 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (command) {
clearSubmission(input, submission)
void sendCommand(session, value, command, input.adapter.controls().model.selection.trackSessionCommit).catch(
(error) => failSubmission(input, session, "command", error, restore, value.id),
)
// 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))
return
}
} finally {
@@ -320,8 +326,7 @@ async function sendCommand(
track?: ModelSelection["trackSessionCommit"],
) {
const request = await buildSubmissionRequest(session, value)
// 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 applySelection(session, value.selection, track)
await session.api.command({
sessionID: session.id,
command: command.command,
+2 -4
View File
@@ -1,6 +1,5 @@
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"
@@ -80,8 +79,8 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
const list = createMemo(() =>
available().map((m) => ({
...m,
name: isOrganizationRouteProvider(m.provider.id) ? m.name : m.name.replace("(latest)", "").trim(),
latest: !isOrganizationRouteProvider(m.provider.id) && m.name.includes("(latest)"),
name: m.name.replace("(latest)", "").trim(),
latest: m.name.includes("(latest)"),
})),
)
@@ -101,7 +100,6 @@ 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,5 +1,4 @@
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"
@@ -198,9 +197,6 @@ 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>
@@ -493,9 +489,6 @@ 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>
+14 -24
View File
@@ -1,5 +1,4 @@
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"
@@ -9,7 +8,6 @@ type ModelInfo = {
id: string
name: string
provider: {
id?: string
name: string
}
capabilities?: {
@@ -38,9 +36,7 @@ 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")
@@ -62,16 +58,14 @@ 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 (route()) tags.push(language.t("model.tag.variable"))
if (!route() && props.free) tags.push(language.t("model.tag.free"))
if (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 (route()) tags.push(language.t("model.tag.variable"))
if (!route() && props.free) tags.push(language.t("model.tag.free"))
if (props.free) tags.push(language.t("model.tag.free"))
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${props.model.name}${suffix}`
}
@@ -104,13 +98,11 @@ 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={!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 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()} />
</div>
)
}
@@ -118,17 +110,15 @@ 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={!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 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>
</div>
)
}
-1
View File
@@ -280,7 +280,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ተቋርጧል",
"provider.disconnect.toast.disconnected.description": "{{provider}} ሞዴሎች ከአሁን በኋላ አይገኙም።",
"model.tag.free": "ነጻ",
"model.tag.variable": "ተለዋዋጭ",
"model.tag.latest": "የቅርብ",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -290,7 +290,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "تم فصل {{provider}}",
"provider.disconnect.toast.disconnected.description": "لم تعد نماذج {{provider}} متاحة.",
"model.tag.free": "مجاني",
"model.tag.variable": "متغير",
"model.tag.latest": "الأحدث",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -287,7 +287,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ayrıldı",
"provider.disconnect.toast.disconnected.description": "{{provider}} modelləri artıq mövcud deyil.",
"model.tag.free": "Pulsuz",
"model.tag.variable": "Dəyişkən",
"model.tag.latest": "Ən son",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -287,7 +287,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} прекъснат",
"provider.disconnect.toast.disconnected.description": "{{provider}} модели вече не са налични.",
"model.tag.free": "безплатно",
"model.tag.variable": "Променлива",
"model.tag.latest": "Последни",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -284,7 +284,6 @@ export const dict: Record<string, string> = {
"provider.disconnect.toast.disconnected.title": "{{provider}} সংযোগ বিচ্ছিন্ন",
"provider.disconnect.toast.disconnected.description": "{{provider}} মডেল আর উপলব্ধ নেই৷",
"model.tag.free": "বিনামূল্যে",
"model.tag.variable": "পরিবর্তনশীল",
"model.tag.latest": "সর্বশেষ",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -292,7 +292,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} desconectado",
"provider.disconnect.toast.disconnected.description": "Os modelos de {{provider}} não estão mais disponíveis.",
"model.tag.free": "Grátis",
"model.tag.variable": "Variável",
"model.tag.latest": "Mais recente",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -308,7 +308,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} modeli više nisu dostupni.",
"model.tag.free": "Besplatno",
"model.tag.variable": "Promjenjiva",
"model.tag.latest": "Najnovije",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -286,7 +286,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} desconnectat",
"provider.disconnect.toast.disconnected.description": "{{provider}} models ja no estan disponibles.",
"model.tag.free": "Gratuït",
"model.tag.variable": "Variable",
"model.tag.latest": "Última",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -284,7 +284,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} odpojeno",
"provider.disconnect.toast.disconnected.description": "{{provider}} modely již nejsou k dispozici.",
"model.tag.free": "Zdarma",
"model.tag.variable": "Proměnlivá",
"model.tag.latest": "Nejnovější",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -205,7 +205,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} frakoblet",
"provider.disconnect.toast.disconnected.description": "Modeller fra {{provider}} er ikke længere tilgængelige.",
"model.tag.free": "Gratis",
"model.tag.variable": "Variabel",
"model.tag.latest": "Nyeste",
"model.provider.anthropic": "Anthropic",
-1
View File
@@ -196,7 +196,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} getrennt",
"provider.disconnect.toast.disconnected.description": "Die {{provider}}-Modelle sind nicht mehr verfügbar.",
"model.tag.free": "Kostenlos",
"model.tag.variable": "Variabel",
"model.tag.latest": "Neueste",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -289,7 +289,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ކަނެކްޓް ވެއްޖެއެވެ",
"provider.disconnect.toast.disconnected.description": "{{provider}} މޮޑެލްތައް މިހާރު ލިބެން ނެތެވެ.",
"model.tag.free": "ހިލޭ",
"model.tag.variable": "ބަދަލުވާ",
"model.tag.latest": "އެންމެފަހުގެ",
"model.provider.anthropic": "Anthropic އެވެ",
"model.provider.openai": "OpenAI އެވެ",
-1
View File
@@ -288,7 +288,6 @@ export const dict: Record<string, string> = {
"provider.disconnect.toast.disconnected.title": "{{provider}} མཐུད་ལམ་ཆད་ཡོདཔ།",
"provider.disconnect.toast.disconnected.description": "{{provider}} དཔེ་ཚད་ཚུ་ད་ལས་ཕར་འཐོབ་མི་ཚུགས།",
"model.tag.free": "རིན་མེད་སྟོང་པ",
"model.tag.variable": "འགྱུར་བཅོས་ཅན་",
"model.tag.latest": "ད༌རེས༌ནངས༌པ",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -285,7 +285,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} αποσυνδέθηκε",
"provider.disconnect.toast.disconnected.description": "{{provider}} μοντέλα δεν είναι πλέον διαθέσιμα.",
"model.tag.free": "Δωρεάν",
"model.tag.variable": "Μεταβλητή",
"model.tag.latest": "Τελευταία",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -267,7 +267,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.",
"model.tag.free": "Free",
"model.tag.variable": "Variable",
"model.tag.latest": "Latest",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -308,7 +308,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "Los modelos de {{provider}} ya no están disponibles.",
"model.tag.free": "Gratis",
"model.tag.variable": "Variable",
"model.tag.latest": "Más reciente",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -283,7 +283,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} on katkestatud",
"provider.disconnect.toast.disconnected.description": "{{provider}} mudelit pole enam saadaval.",
"model.tag.free": "Tasuta",
"model.tag.variable": "Muutuv",
"model.tag.latest": "Viimased",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -284,7 +284,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} قطع شد",
"provider.disconnect.toast.disconnected.description": "مدل های {{provider}} دیگر در دسترس نیستند.",
"model.tag.free": "رایگان",
"model.tag.variable": "متغیر",
"model.tag.latest": "آخرین",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -191,7 +191,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "Yhteys palveluntarjoajaan {{provider}} katkaistu",
"provider.disconnect.toast.disconnected.description": "{{provider}}-mallit eivät ole enää saatavilla.",
"model.tag.free": "Ilmainen",
"model.tag.variable": "Muuttuva",
"model.tag.latest": "Uusin",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -283,7 +283,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} slitið",
"provider.disconnect.toast.disconnected.description": "{{provider}} modellir eru ikki tøkir longur.",
"model.tag.free": "Ókeypis",
"model.tag.variable": "Skiftandi",
"model.tag.latest": "Nýggjasta",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -295,7 +295,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} déconnecté",
"provider.disconnect.toast.disconnected.description": "Les modèles {{provider}} ne sont plus disponibles.",
"model.tag.free": "Gratuit",
"model.tag.variable": "Variable",
"model.tag.latest": "Le plus récent",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -282,7 +282,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} מנותק",
"provider.disconnect.toast.disconnected.description": "המודלים של {{provider}} אינם זמינים עוד.",
"model.tag.free": "חינם",
"model.tag.variable": "משתנה",
"model.tag.latest": "העדכני ביותר",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -291,7 +291,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} डिस्कनेक्ट हो गया",
"provider.disconnect.toast.disconnected.description": "{{provider}} मॉडल अब उपलब्ध नहीं हैं।",
"model.tag.free": "निःशुल्क",
"model.tag.variable": "परिवर्तनशील",
"model.tag.latest": "नवीनतम",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -288,7 +288,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} isključen",
"provider.disconnect.toast.disconnected.description": "{{provider}} modeli više nisu dostupni.",
"model.tag.free": "Besplatno",
"model.tag.variable": "Promjenjiva",
"model.tag.latest": "Najnoviji",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -288,7 +288,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} leválasztva",
"provider.disconnect.toast.disconnected.description": "A {{provider}} modellek már nem kaphatók.",
"model.tag.free": "Ingyenes",
"model.tag.variable": "Változó",
"model.tag.latest": "Legújabb",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -286,7 +286,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} անջատված է",
"provider.disconnect.toast.disconnected.description": "{{provider}} մոդելներն այլևս հասանելի չեն:",
"model.tag.free": "Անվճար",
"model.tag.variable": "Փոփոխական",
"model.tag.latest": "Վերջին",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -307,7 +307,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "Model {{provider}} tidak lagi tersedia.",
"model.tag.free": "Gratis",
"model.tag.variable": "Bervariasi",
"model.tag.latest": "Terbaru",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -288,7 +288,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} aftengdur",
"provider.disconnect.toast.disconnected.description": "{{provider}} gerðir eru ekki lengur fáanlegar.",
"model.tag.free": "Ókeypis",
"model.tag.variable": "Breytilegt",
"model.tag.latest": "Nýjasta",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -193,7 +193,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} disconnesso",
"provider.disconnect.toast.disconnected.description": "I modelli {{provider}} non sono più disponibili.",
"model.tag.free": "Gratuito",
"model.tag.variable": "Variabile",
"model.tag.latest": "Più recente",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -289,7 +289,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}}が切断されました",
"provider.disconnect.toast.disconnected.description": "{{provider}}のモデルは利用できなくなりました。",
"model.tag.free": "無料",
"model.tag.variable": "変動",
"model.tag.latest": "最新",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -284,7 +284,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} გათიშულია",
"provider.disconnect.toast.disconnected.description": "{{provider}} მოდელები აღარ არის ხელმისაწვდომი.",
"model.tag.free": "უფასო",
"model.tag.variable": "ცვალებადი",
"model.tag.latest": "უახლესი",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -283,7 +283,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ត្រូវបានផ្តាច់",
"provider.disconnect.toast.disconnected.description": "ម៉ូដែល {{provider}} លែងមានទៀតហើយ។",
"model.tag.free": "ឥតគិតថ្លៃ",
"model.tag.variable": "ប្រែប្រួល",
"model.tag.latest": "ចុងក្រោយ",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -186,7 +186,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} 연결 해제됨",
"provider.disconnect.toast.disconnected.description": "{{provider}} 모델을 더 이상 사용할 수 없습니다.",
"model.tag.free": "무료",
"model.tag.variable": "변동",
"model.tag.latest": "최신",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -283,7 +283,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ຕັດການເຊື່ອມຕໍ່ແລ້ວ",
"provider.disconnect.toast.disconnected.description": "ໂມເດວ {{provider}} ບໍ່ມີແລ້ວ.",
"model.tag.free": "ຟຣີ",
"model.tag.variable": "ປ່ຽນແປງໄດ້",
"model.tag.latest": "ຫຼ້າສຸດ",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -289,7 +289,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} atjungtas",
"provider.disconnect.toast.disconnected.description": "{{provider}} modeliai nebepasiekiami.",
"model.tag.free": "Nemokama",
"model.tag.variable": "Kintama",
"model.tag.latest": "Naujausias",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -284,7 +284,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} atvienots",
"provider.disconnect.toast.disconnected.description": "{{provider}} modeļi vairs nav pieejami.",
"model.tag.free": "Bezmaksas",
"model.tag.variable": "Mainīga",
"model.tag.latest": "Jaunākais",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -285,7 +285,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} исклучен",
"provider.disconnect.toast.disconnected.description": "{{provider}} моделите веќе не се достапни.",
"model.tag.free": "Бесплатно",
"model.tag.variable": "Променлива цена",
"model.tag.latest": "Најнови",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -287,7 +287,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} салгагдсан",
"provider.disconnect.toast.disconnected.description": "{{provider}} загварууд байхгүй болсон.",
"model.tag.free": "Үнэгүй",
"model.tag.variable": "Хувьсах үнэ",
"model.tag.latest": "Хамгийн сүүлийн үеийн",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -284,7 +284,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} telah diputuskan",
"provider.disconnect.toast.disconnected.description": "Model {{provider}} tidak lagi tersedia.",
"model.tag.free": "Percuma",
"model.tag.variable": "Berubah-ubah",
"model.tag.latest": "Terkini",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -287,7 +287,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ချိတ်ဆက်မှု ပြတ်တောက်သွားသည်။",
"provider.disconnect.toast.disconnected.description": "{{provider}} မော်ဒယ်များကို မရနိုင်တော့ပါ။",
"model.tag.free": "အခမဲ့",
"model.tag.variable": "ပြောင်းလဲနိုင်သော",
"model.tag.latest": "နောက်ဆုံးထွက်",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -285,7 +285,6 @@ export const dict: Record<string, string> = {
"provider.disconnect.toast.disconnected.title": "{{provider}} जडान विच्छेद भयो",
"provider.disconnect.toast.disconnected.description": "{{provider}} मोडेलहरू अब उपलब्ध छैनन्।",
"model.tag.free": "नि:शुल्क",
"model.tag.variable": "परिवर्तनशील मूल्य",
"model.tag.latest": "पछिल्लो",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -284,7 +284,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "Verbinding met {{provider}} verbroken",
"provider.disconnect.toast.disconnected.description": "{{provider}}-modellen zijn niet langer beschikbaar.",
"model.tag.free": "Gratis",
"model.tag.variable": "Variabel",
"model.tag.latest": "Nieuwste",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -306,7 +306,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "Modeller fra {{provider}} er ikke lenger tilgjengelige.",
"model.tag.free": "Gratis",
"model.tag.variable": "Variabel",
"model.tag.latest": "Nyeste",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -290,7 +290,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} منقطع",
"provider.disconnect.toast.disconnected.description": "{{provider}} ماڈل ہن دستیاب نئیں ہن۔",
"model.tag.free": "مفت",
"model.tag.variable": "بدلدی قیمت",
"model.tag.latest": "تازہ ترین",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -292,7 +292,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "Rozłączono {{provider}}",
"provider.disconnect.toast.disconnected.description": "Modele {{provider}} nie są już dostępne.",
"model.tag.free": "Darmowy",
"model.tag.variable": "Zmienna cena",
"model.tag.latest": "Najnowszy",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -283,7 +283,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} deconectat",
"provider.disconnect.toast.disconnected.description": "Modelele {{provider}} nu mai sunt disponibile.",
"model.tag.free": "Gratuit",
"model.tag.variable": "Preț variabil",
"model.tag.latest": "Ultimul",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -306,7 +306,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} отключён",
"provider.disconnect.toast.disconnected.description": "Модели {{provider}} больше недоступны.",
"model.tag.free": "Бесплатно",
"model.tag.variable": "Цена меняется",
"model.tag.latest": "Последняя",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -283,7 +283,6 @@ export const dict: Record<string, string> = {
"provider.disconnect.toast.disconnected.title": "{{provider}} විසන්ධි විය",
"provider.disconnect.toast.disconnected.description": "{{provider}} මාදිලි තවදුරටත් නොමැත.",
"model.tag.free": "නොමිලේ",
"model.tag.variable": "විචල්‍ය",
"model.tag.latest": "නවතම",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -283,7 +283,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} odpojený",
"provider.disconnect.toast.disconnected.description": "Modely {{provider}} už nie sú dostupné.",
"model.tag.free": "Bezplatné",
"model.tag.variable": "Premenlivá cena",
"model.tag.latest": "Najnovšie",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -283,7 +283,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} prekinjen",
"provider.disconnect.toast.disconnected.description": "Modeli {{provider}} niso več na voljo.",
"model.tag.free": "Brezplačno",
"model.tag.variable": "Spremenljiva cena",
"model.tag.latest": "Zadnje",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -284,7 +284,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} u shkëput",
"provider.disconnect.toast.disconnected.description": "Modelet {{provider}} nuk janë më të disponueshme.",
"model.tag.free": "Falas",
"model.tag.variable": "Çmim i ndryshueshëm",
"model.tag.latest": "E fundit",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -284,7 +284,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} прекинут",
"provider.disconnect.toast.disconnected.description": "{{provider}} модели више нису доступни.",
"model.tag.free": "Бесплатно",
"model.tag.variable": "Променљива цена",
"model.tag.latest": "Најновије",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -285,7 +285,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} frånkopplad",
"provider.disconnect.toast.disconnected.description": "{{provider}}-modeller är inte längre tillgängliga.",
"model.tag.free": "Gratis",
"model.tag.variable": "Varierande",
"model.tag.latest": "Senaste",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -285,7 +285,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ҷудо карда шудааст",
"provider.disconnect.toast.disconnected.description": "{{provider}} моделҳо дигар дастрас нестанд.",
"model.tag.free": "Озод",
"model.tag.variable": "Тағйирёбанда",
"model.tag.latest": "Охирин",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -305,7 +305,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "โมเดล {{provider}} ไม่พร้อมใช้งานอีกต่อไป",
"model.tag.free": "ฟรี",
"model.tag.variable": "ผันแปร",
"model.tag.latest": "ล่าสุด",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -284,7 +284,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} kesildi",
"provider.disconnect.toast.disconnected.description": "{{provider}} modelleri indi ýok.",
"model.tag.free": "Mugt",
"model.tag.variable": "Üýtgeýän",
"model.tag.latest": "Iň soňky",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -312,7 +312,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} modelleri artık kullanılamıyor.",
"model.tag.free": "Ücretsiz",
"model.tag.variable": "Değişken",
"model.tag.latest": "En yeni",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -308,7 +308,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "Моделі {{provider}} більше недоступні.",
"model.tag.free": "Безкоштовно",
"model.tag.variable": "Змінна ціна",
"model.tag.latest": "Остання",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -293,7 +293,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} منقطع ہو گیا۔",
"provider.disconnect.toast.disconnected.description": "{{provider}} ماڈلز اب دستیاب نہیں ہیں۔",
"model.tag.free": "مفت",
"model.tag.variable": "متغیر قیمت",
"model.tag.latest": "تازہ ترین",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -286,7 +286,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} uzildi",
"provider.disconnect.toast.disconnected.description": "{{provider}} modellari endi mavjud emas.",
"model.tag.free": "Bepul",
"model.tag.variable": "Ozgaruvchan",
"model.tag.latest": "Oxirgi",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -291,7 +291,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} bị ngắt kết nối",
"provider.disconnect.toast.disconnected.description": "Các mô hình {{provider}} không còn khả dụng.",
"model.tag.free": "Miễn phí",
"model.tag.variable": "Giá thay đổi",
"model.tag.latest": "Mới nhất",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -327,7 +327,6 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
"model.tag.free": "免费",
"model.tag.variable": "浮动价格",
"model.tag.latest": "最新",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
-1
View File
@@ -305,7 +305,6 @@ 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",
+2 -6
View File
@@ -54,12 +54,8 @@ export function createWebPlatform(version: string) {
function getCurrentServerUrl() {
if (import.meta.env.VITE_OPENCODE_SERVER_MODE === "none") return undefined
if (import.meta.env.DEV) {
const loopback =
location.hostname === "localhost" || location.hostname === "[::1]" || location.hostname.startsWith("127.")
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? (loopback ? location.hostname : "localhost")
return `http://${host}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
}
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
}
+2 -5
View File
@@ -10,7 +10,6 @@ import { createData } from "@opencode/client/solid"
import type { ServerScope } from "@/runtime/server/scope"
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { createDesktopData } from "./data"
import { ModelState } from "./persistence"
@@ -34,7 +33,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
})
const models = createGlobalModels()
const notificationCoordinator = createNotificationCoordinator()
const settingsServer = createMemo(() => {
const list = server.list
@@ -59,7 +57,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
if (existing) return existing
const serverCtx = createRoot((dispose) => {
serverCtxDisposers.set(key, dispose)
return createServerController(conn, server.scope(key), server.projects.forServer(key), notificationCoordinator)
return createServerController(conn, server.scope(key), server.projects.forServer(key))
}, owner)
serverCtxs.set(key, serverCtx)
return serverCtx
@@ -133,7 +131,6 @@ function createServerController(
conn: ServerConnection.Any,
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
notificationCoordinator: ReturnType<typeof createNotificationCoordinator>,
) {
const language = useLanguage()
const settings = useSettings()
@@ -162,7 +159,7 @@ function createServerController(
})
const sync = createServerSyncContext(sdk, data)
createPermissionAutoApprover({ sdk, data })
const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator })
const notification = createServerNotificationState({ sdk, data, key: connKey })
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false })

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