Compare commits

...
80 changed files with 1068 additions and 35 deletions
+46 -11
View File
@@ -86,6 +86,7 @@ export const OpenResponsesReasoningItem = Schema.Struct({
id: Schema.optionalKey(Schema.String),
summary: Schema.Array(OpenResponsesReasoningSummaryText),
encrypted_content: optionalNull(Schema.String),
provider_metadata: Schema.optional(JsonObject),
})
const OpenResponsesWebSearchCall = Schema.StructWithRest(
@@ -182,6 +183,7 @@ export const InputItem = Schema.Union([
content: Schema.Array(OpenResponsesOutputText),
phase: Schema.optionalKey(MessagePhase),
status: Schema.optional(Schema.String),
provider_metadata: Schema.optional(JsonObject),
}),
OpenResponsesReasoningItem,
Schema.Struct({
@@ -191,6 +193,7 @@ export const InputItem = Schema.Union([
name: Schema.String,
namespace: Schema.optional(Schema.String),
arguments: Schema.String,
provider_metadata: Schema.optional(JsonObject),
}),
Schema.Struct({
type: Schema.tag("function_call_output"),
@@ -223,6 +226,7 @@ type OpenResponsesReasoningInput = {
id?: string
summary: Array<{ type: "summary_text"; text: string }>
encrypted_content?: string | null
provider_metadata?: Record<string, unknown>
}
export const Tool = Schema.Struct({
type: Schema.tag("function"),
@@ -436,6 +440,8 @@ export const decodeChannelEvent = (frame: string) =>
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>
@@ -455,6 +461,7 @@ export interface ParserState {
readonly id: string
readonly name: string
readonly providerMetadataKey: string
readonly preserveProviderMetadata: boolean
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
@@ -514,7 +521,16 @@ const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadata
return separator > 0 && separator < metadata.itemId.length - 1 ? metadata.itemId : undefined
}
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const replayProviderMetadata = (metadata: ProviderMetadata | undefined, key: string, adapter: ProviderAdapter) => {
const value = metadata?.[key]?.providerMetadata
return adapter.preserveProviderMetadata && ProviderShared.isRecord(value) ? { provider_metadata: value } : {}
}
const lowerToolCall = (
part: ToolCallPart,
providerMetadataKey: string,
adapter: ProviderAdapter,
): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
return {
type: "function_call",
@@ -523,10 +539,15 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
name: part.name,
namespace: part.namespace,
arguments: ProviderShared.encodeJson(part.input),
...replayProviderMetadata(part.providerMetadata, providerMetadataKey, adapter),
}
}
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const lowerReasoning = (
part: ReasoningPart,
providerMetadataKey: string,
adapter: ProviderAdapter,
): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata)) return undefined
const id = itemID(part.providerMetadata, providerMetadataKey)
@@ -539,6 +560,7 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
...(id === undefined ? {} : { id }),
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
...replayProviderMetadata(part.providerMetadata, providerMetadataKey, adapter),
}
}
@@ -687,6 +709,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
status: "completed",
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
...(group.phase === undefined ? {} : { phase: group.phase }),
...replayProviderMetadata(group.parts.at(-1)?.providerMetadata, providerMetadataKey, adapter),
})),
)
content.splice(0, content.length)
@@ -707,13 +730,14 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
}
if (part.type === "reasoning") {
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey)
const reasoning = lowerReasoning(part, providerMetadataKey, adapter)
if (!reasoning) continue
const existing = reasoning.id === undefined ? undefined : reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
if (typeof reasoning.encrypted_content === "string")
existing.encrypted_content = reasoning.encrypted_content
if (reasoning.provider_metadata !== undefined) existing.provider_metadata = reasoning.provider_metadata
continue
}
if (reasoning.id !== undefined) reasoningItems[reasoning.id] = reasoning
@@ -723,7 +747,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
if (part.type === "tool-call") {
flushText()
if (part.providerExecuted === true) continue
input.push(lowerToolCall(part, providerMetadataKey))
input.push(lowerToolCall(part, providerMetadataKey, adapter))
continue
}
if (part.type === "tool-result" && part.providerExecuted === true) {
@@ -1062,8 +1086,17 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
}
const outputMetadata = (state: ParserState, item: OutputItem, extra?: Record<string, unknown>) =>
providerMetadata(state, {
itemId: item.id,
...extra,
...(state.preserveProviderMetadata && ProviderShared.isRecord(item.provider_metadata)
? { providerMetadata: item.provider_metadata }
: {}),
})
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
outputMetadata(state, item, { reasoningEncryptedContent: item.encrypted_content ?? null })
// Responses APIs normally stream reasoning items in this order:
// `output_item.added` (reasoning) →
@@ -1126,7 +1159,7 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
}
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
const metadata = outputMetadata(state, item)
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
@@ -1249,7 +1282,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
content.push(decoded.type === "output_text" ? decoded.text : decoded.refusal)
}
const text = content.length > 0 ? content.join("") : undefined
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const metadata = outputMetadata(state, item, phase === undefined ? undefined : { phase })
const events: LLMEvent[] = []
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
@@ -1264,10 +1297,11 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
? state.tools
const metadata = outputMetadata(state, item)
const pending = state.tools[item.id]
const registered = pending !== undefined
const tools = pending
? ToolStream.start(state.tools, item.id, { ...pending, providerMetadata: metadata })
: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name,
@@ -1521,6 +1555,7 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
id: adapter.id,
name: adapter.name,
providerMetadataKey: metadataKey(request.model),
preserveProviderMetadata: adapter.preserveProviderMetadata ?? false,
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
@@ -0,0 +1,72 @@
import { Effect, Schema } from "effect"
import { Route } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import type { LLMRequest } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { ProviderShared } from "./shared.js"
const ADAPTER = "organization-routes"
const adapter = {
id: ADAPTER,
name: "Organization routes",
preserveProviderMetadata: true,
} satisfies OpenResponses.ProviderAdapter
const Body = Schema.Struct({
...OpenResponses.coreFields,
store: Schema.Literal(false),
provider_options: Schema.Struct({
"openai-responses": Schema.Struct({ include: Schema.Array(Schema.String) }),
}),
stream: Schema.Literal(true),
})
const fromRequest = Effect.fn("OrganizationRoutes.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithAdapter(request, adapter)
// A route can select another provider on each call. Keep full history and only
// portable options; native caching and stored IDs would exclude translated
// targets. Scope encrypted reasoning to native Responses targets so their
// stateless continuations remain replayable without blocking other protocols.
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))({
model: body.model,
input: body.input,
instructions: body.instructions,
tools: body.tools,
tool_choice: body.tool_choice,
stream: true as const,
store: false as const,
provider_options: { "openai-responses": { include: ["reasoning.encrypted_content"] } },
max_output_tokens:
body.max_output_tokens !== undefined && body.max_output_tokens > 0 ? body.max_output_tokens : undefined,
temperature: body.temperature,
top_p: body.top_p,
parallel_tool_calls: body.parallel_tool_calls,
metadata: body.metadata,
reasoning: body.reasoning?.effort === undefined ? undefined : { effort: body.reasoning.effort },
text: body.text,
})
})
export const protocol = Protocol.make({
...OpenResponses.protocol,
id: ADAPTER,
body: { schema: Body, from: fromRequest },
stream: {
...OpenResponses.protocol.stream,
initial: (request: LLMRequest) => OpenResponses.initial(request, adapter),
},
})
export const route = Route.make({
id: ADAPTER,
providerMetadataKey: ADAPTER,
protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: HttpTransport.sseJson.with<typeof Body.Type>(),
defaults: { providerOptions: { store: false, include: [] } },
})
export * as OrganizationRoutes from "./organization-routes.js"
@@ -0,0 +1,57 @@
import type { ProviderPackage } from "../provider-package.js"
import { OrganizationRoutes } from "../protocols/organization-routes.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
export type Options = Pick<
OpenResponsesProviderOptionsInput,
"reasoningEffort" | "textVerbosity" | "parallelToolCalls" | "metadata" | "allowedTools"
>
export const id = ProviderID.make("organization-routes")
export type Config = RouteDefaultsInput &
ProviderAuthOption<"optional"> & {
readonly provider?: string
readonly baseURL: string
readonly providerOptions?: Options
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: Options
}
export const routes = [OrganizationRoutes.route]
export const configure = (input: Config) => {
const provider = input.provider ?? "organization-routes"
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const route = OrganizationRoutes.route.with({
...rest,
provider,
endpoint: { baseURL },
auth: AuthOptions.bearer(input, []),
})
return {
id: ProviderID.make(provider),
model: (modelID: string | ModelID) => route.model<Options>({ id: modelID }),
configure,
}
}
export const provider = { id, configure }
export const model: ProviderPackage.Definition<Settings, Options>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
}).model(modelID)
@@ -0,0 +1,361 @@
import { describe, expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { configure } from "../../src/providers/organization-routes.js"
import { provider } from "../../src/providers/openai-compatible-responses.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const model = configure({
apiKey: "session-test",
baseURL: "https://console.example.test/inference/route/openai/v1",
provider: "opencode-routes-org_test",
headers: { "x-opencode-org-id": "org_test" },
}).model("route/coding")
const fixtures = [
{
protocol: "openai-responses",
content: [
{
type: "reasoning",
id: "rs_native",
summary: [{ type: "summary_text", text: "Need a lookup" }],
encrypted_content: "opaque-openai-reasoning",
},
{ type: "message", id: "msg_native", role: "assistant", content: [{ type: "output_text", text: "Checking." }] },
{
type: "function_call",
id: "fc_native_lookup",
call_id: "call_lookup",
name: "lookup",
arguments: '{"query":"weather"}',
},
{ type: "function_call", id: "fc_native_clock", call_id: "call_clock", name: "clock", arguments: "{}" },
],
},
{
protocol: "google",
content: [
{ text: "Need a lookup", thought: true },
{ text: "Checking." },
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "opaque-google-signature" },
{ functionCall: { name: "clock", args: {} } },
],
},
{
protocol: "anthropic-messages",
content: [
{ type: "thinking", thinking: "Need a lookup", signature: "opaque-anthropic-signature" },
{ type: "text", text: "Checking." },
{ type: "tool_use", id: "call_lookup", name: "lookup", input: { query: "weather" } },
{ type: "tool_use", id: "call_clock", name: "clock", input: {} },
],
},
{
protocol: "openai-chat",
content: [
{
role: "assistant",
reasoning_content: "Need a lookup",
content: "Checking.",
tool_calls: [
{ id: "call_lookup", type: "function", function: { name: "lookup", arguments: '{"query":"weather"}' } },
{ id: "call_clock", type: "function", function: { name: "clock", arguments: "{}" } },
],
},
],
},
]
const finalText = sseEvents(
{
type: "response.output_item.done",
item: { type: "message", id: "msg_final", content: [{ type: "output_text", text: "Sunny." }] },
},
{ type: "response.completed", response: { id: "resp_final" } },
)
describe("organization routes", () => {
it.effect("uses the Responses route endpoint with stateless portable options", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model,
system: "Help with code.",
prompt: "Hello",
promptCacheKey: "session-cache",
providerOptions: {
store: true,
include: ["reasoning.encrypted_content"],
previousResponseId: "resp_old",
reasoningEffort: "low",
reasoningSummary: "auto",
serviceTier: "priority",
},
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(input.request.url).toBe("https://console.example.test/inference/route/openai/v1/responses")
expect(input.request.headers.authorization).toBe("Bearer session-test")
expect(input.request.headers["x-opencode-org-id"]).toBe("org_test")
expect(yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(input.text)).toEqual({
model: "route/coding",
input: [{ role: "user", content: [{ type: "input_text", text: "Hello" }] }],
instructions: "Help with code.",
stream: true,
store: false,
provider_options: { "openai-responses": { include: ["reasoning.encrypted_content"] } },
reasoning: { effort: "low" },
})
return input.respond(finalText, { headers: { "content-type": "text/event-stream" } })
}),
),
),
)
expect(response.text).toBe("Sunny.")
}),
)
it.effect("omits an unknown output limit", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello", generation: { maxTokens: 0 } }))
expect(prepared.body.max_output_tokens).toBeUndefined()
}),
)
for (const fixture of fixtures) {
it.effect(`preserves ${fixture.protocol} continuation metadata through a streamed parallel tool loop`, () =>
Effect.gen(function* () {
const metadata = {
protocol: fixture.protocol,
model: "native-model",
connection_id: "conn_native",
endpoint_id: "endpoint_native",
content: fixture.content,
group_id: "resp_tools",
}
const marker = { group_id: "resp_tools" }
const items = [
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Need a lookup" }],
provider_metadata: metadata,
},
{
type: "message",
id: "msg_1",
role: "assistant",
content: [{ type: "output_text", text: "Checking." }],
provider_metadata: marker,
},
{
type: "function_call",
id: "fc_lookup",
call_id: "call_lookup",
name: "lookup",
arguments: '{"query":"weather"}',
provider_metadata: marker,
},
{
type: "function_call",
id: "fc_clock",
call_id: "call_clock",
name: "clock",
arguments: "{}",
provider_metadata: marker,
},
]
const first = yield* LLMClient.generate(LLM.request({ model, prompt: "Check weather and time." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "reasoning", id: "rs_1", summary: [] },
},
{
type: "response.reasoning_summary_text.delta",
item_id: "rs_1",
summary_index: 0,
delta: "Need a lookup",
},
{
type: "response.output_item.added",
output_index: 1,
item: { type: "message", id: "msg_1", role: "assistant", content: [] },
},
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Checking." },
{
type: "response.output_item.added",
output_index: 2,
item: {
type: "function_call",
id: "fc_lookup",
call_id: "call_lookup",
name: "lookup",
arguments: "",
},
},
{ type: "response.function_call_arguments.delta", item_id: "fc_lookup", delta: '{"query":"weather"}' },
{
type: "response.output_item.added",
output_index: 3,
item: { type: "function_call", id: "fc_clock", call_id: "call_clock", name: "clock", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_clock", delta: "{}" },
...items.map((item, output_index) => ({ type: "response.output_item.done", output_index, item })),
{ type: "response.completed", response: { id: "resp_tools", output: items } },
),
),
),
)
expect(first.toolCalls).toHaveLength(2)
expect(first.events.filter(LLMEvent.is.toolCall)).toHaveLength(2)
expect(
first.message.content.map((part) => part.providerMetadata?.["opencode-routes-org_test"]?.providerMetadata),
).toEqual([metadata, marker, marker, marker])
const second = yield* LLMClient.generate(
LLM.request({
model,
providerOptions: { previousResponseId: "resp_tools" },
messages: [
Message.user("Check weather and time."),
first.message,
Message.tool({ id: "call_lookup", name: "lookup", result: { weather: "sunny" } }),
Message.tool({ id: "call_clock", name: "clock", result: { time: "noon" } }),
],
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const body = yield* Schema.decodeUnknownEffect(
Schema.fromJsonString(
Schema.Struct({
input: Schema.Array(Schema.Record(Schema.String, Schema.Unknown)),
store: Schema.Boolean,
include: Schema.optional(Schema.Array(Schema.String)),
previous_response_id: Schema.optional(Schema.String),
}),
),
)(input.text)
expect(body.store).toBe(false)
expect(body.include).toBeUndefined()
expect(body.previous_response_id).toBeUndefined()
expect(body.input.map((item) => item.type ?? item.role)).toEqual([
"user",
"reasoning",
"message",
"function_call",
"function_call",
"function_call_output",
"function_call_output",
])
expect(body.input.slice(1, 5).map((item) => item.provider_metadata)).toEqual([
metadata,
marker,
marker,
marker,
])
expect(body.input.slice(3, 5).map((item) => item.call_id)).toEqual(["call_lookup", "call_clock"])
return input.respond(finalText, { headers: { "content-type": "text/event-stream" } })
}),
),
),
)
expect(second.text).toBe("Sunny.")
}),
)
}
it.effect("preserves opaque state on an empty reasoning item", () =>
Effect.gen(function* () {
const metadata = {
protocol: "anthropic-messages",
content: [{ type: "redacted_thinking", data: "opaque" }],
group_id: "resp_empty",
}
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_empty", summary: [], provider_metadata: metadata },
},
{ type: "response.completed", response: { id: "resp_empty" } },
),
),
),
)
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
expect(replay.body.input).toEqual([
{ type: "reasoning", id: "rs_empty", summary: [], encrypted_content: null, provider_metadata: metadata },
])
}),
)
it.effect("keeps opaque route metadata out of other Responses providers", () =>
Effect.gen(function* () {
const other = provider
.configure({
apiKey: "test-key",
baseURL: "https://other.example.test/v1",
provider: "opencode-routes-org_test",
})
.model("other")
const response = yield* LLMClient.generate(LLM.request({ model: other, prompt: "Hello" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
content: [{ type: "output_text", text: "Hi" }],
provider_metadata: { group_id: "resp_1", secret: "opaque" },
},
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content[0]?.providerMetadata).toEqual({ "opencode-routes-org_test": { itemId: "msg_1" } })
const replay = yield* compileRequest(
LLM.request({
model: other,
messages: [
Message.assistant({
type: "text",
text: "Hi",
providerMetadata: {
"opencode-routes-org_test": { itemId: "msg_1", providerMetadata: { secret: "opaque" } },
},
}),
],
}),
)
expect(replay.body.input).toEqual([
{
type: "message",
id: "msg_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: "Hi" }],
},
])
expect(replay.body.include).toEqual(["reasoning.encrypted_content"])
}),
)
})
+4 -2
View File
@@ -1,5 +1,6 @@
import { type Accessor, createMemo } from "solid-js"
import { DateTime } from "luxon"
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import { createSimpleContext } from "@opencode/ui/context"
import { useProviders } from "@/providers/catalog/providers"
@@ -79,8 +80,8 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
const list = createMemo(() =>
available().map((m) => ({
...m,
name: m.name.replace("(latest)", "").trim(),
latest: m.name.includes("(latest)"),
name: isOrganizationRouteProvider(m.provider.id) ? m.name : m.name.replace("(latest)", "").trim(),
latest: !isOrganizationRouteProvider(m.provider.id) && m.name.includes("(latest)"),
})),
)
@@ -100,6 +101,7 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
const state = visibility().get(key)
if (state === "hide") return false
if (state === "show") return true
if (isOrganizationRouteProvider(model.providerID)) return true
if (latestSet().has(key)) return true
const date = release().get(key)
if (!date?.isValid) return true
@@ -1,4 +1,5 @@
import { Popover } from "@kobalte/core/popover"
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal, type ModelSelection } from "@/providers/models/selection"
@@ -197,6 +198,9 @@ const ModelList: Component<{
>
<span class="min-w-0 truncate">{item.name}</span>
</Tooltip>
<Show when={isOrganizationRouteProvider(item.provider.id)}>
<Badge class="shrink-0">{language.t("model.tag.variable")}</Badge>
</Show>
<Show when={isFree(item.provider.id, item.cost)}>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
</Show>
@@ -489,6 +493,9 @@ function ModelSelectorPopoverView(props: {
onSelect={() => selectModel(item)}
>
<span class="min-w-0 truncate leading-5">{item.name}</span>
<Show when={isOrganizationRouteProvider(item.provider.id)}>
<Badge class="shrink-0">{language.t("model.tag.variable")}</Badge>
</Show>
<Show when={isFree(item.provider.id, item.cost)}>
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
</Show>
+24 -14
View File
@@ -1,4 +1,5 @@
import { Show, type Component, type JSX } from "solid-js"
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
import { useLanguage } from "@/runtime/i18n/language"
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
@@ -8,6 +9,7 @@ type ModelInfo = {
id: string
name: string
provider: {
id?: string
name: string
}
capabilities?: {
@@ -36,7 +38,9 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
props,
) => {
const language = useLanguage()
const route = () => isOrganizationRouteProvider(props.model.provider.id ?? "")
const sourceName = (model: ModelInfo) => {
if (route()) return model.provider.name
const value = `${model.id} ${model.name}`.toLowerCase()
if (/claude|anthropic/.test(value)) return language.t("model.provider.anthropic")
@@ -58,14 +62,16 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
const title = () => {
const tags: Array<string> = []
if (props.latest) tags.push(language.t("model.tag.latest"))
if (props.free) tags.push(language.t("model.tag.free"))
if (route()) tags.push(language.t("model.tag.variable"))
if (!route() && props.free) tags.push(language.t("model.tag.free"))
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${sourceName(props.model)} ${props.model.name}${suffix}`
}
const name = () => {
const tags: Array<string> = []
if (props.latest) tags.push(language.t("model.tag.latest"))
if (props.free) tags.push(language.t("model.tag.free"))
if (route()) tags.push(language.t("model.tag.variable"))
if (!route() && props.free) tags.push(language.t("model.tag.free"))
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
return `${props.model.name}${suffix}`
}
@@ -98,11 +104,13 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
<div class="flex w-[180px] flex-col gap-2">
<ModelTooltipRow name={language.t("model.tooltip.model")} value={name()} />
<ModelTooltipRow name={language.t("model.tooltip.provider")} value={props.model.provider.name} />
<Show when={inputs()}>
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
<Show when={!route()}>
<Show when={inputs()}>
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
</Show>
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
</Show>
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
</div>
)
}
@@ -110,15 +118,17 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
return (
<div class="flex flex-col gap-1 py-1">
<div class="text-13-medium">{title()}</div>
<Show when={inputs()}>
{(value) => (
<div class="text-12-regular text-text-invert-base">
{language.t("model.tooltip.allows", { inputs: value() })}
</div>
)}
<Show when={!route()}>
<Show when={inputs()}>
{(value) => (
<div class="text-12-regular text-text-invert-base">
{language.t("model.tooltip.allows", { inputs: value() })}
</div>
)}
</Show>
<div class="text-12-regular text-text-invert-base">{reasoning()}</div>
<div class="text-12-regular text-text-invert-base">{context()}</div>
</Show>
<div class="text-12-regular text-text-invert-base">{reasoning()}</div>
<div class="text-12-regular text-text-invert-base">{context()}</div>
</div>
)
}
+1
View File
@@ -280,6 +280,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ተቋርጧል",
"provider.disconnect.toast.disconnected.description": "{{provider}} ሞዴሎች ከአሁን በኋላ አይገኙም።",
"model.tag.free": "ነጻ",
"model.tag.variable": "ተለዋዋጭ",
"model.tag.latest": "የቅርብ",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -290,6 +290,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "تم فصل {{provider}}",
"provider.disconnect.toast.disconnected.description": "لم تعد نماذج {{provider}} متاحة.",
"model.tag.free": "مجاني",
"model.tag.variable": "متغير",
"model.tag.latest": "الأحدث",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -287,6 +287,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ayrıldı",
"provider.disconnect.toast.disconnected.description": "{{provider}} modelləri artıq mövcud deyil.",
"model.tag.free": "Pulsuz",
"model.tag.variable": "Dəyişkən",
"model.tag.latest": "Ən son",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -287,6 +287,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} прекъснат",
"provider.disconnect.toast.disconnected.description": "{{provider}} модели вече не са налични.",
"model.tag.free": "безплатно",
"model.tag.variable": "Променлива",
"model.tag.latest": "Последни",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict: Record<string, string> = {
"provider.disconnect.toast.disconnected.title": "{{provider}} সংযোগ বিচ্ছিন্ন",
"provider.disconnect.toast.disconnected.description": "{{provider}} মডেল আর উপলব্ধ নেই৷",
"model.tag.free": "বিনামূল্যে",
"model.tag.variable": "পরিবর্তনশীল",
"model.tag.latest": "সর্বশেষ",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -292,6 +292,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} desconectado",
"provider.disconnect.toast.disconnected.description": "Os modelos de {{provider}} não estão mais disponíveis.",
"model.tag.free": "Grátis",
"model.tag.variable": "Variável",
"model.tag.latest": "Mais recente",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -308,6 +308,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} modeli više nisu dostupni.",
"model.tag.free": "Besplatno",
"model.tag.variable": "Promjenjiva",
"model.tag.latest": "Najnovije",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -286,6 +286,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} desconnectat",
"provider.disconnect.toast.disconnected.description": "{{provider}} models ja no estan disponibles.",
"model.tag.free": "Gratuït",
"model.tag.variable": "Variable",
"model.tag.latest": "Última",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} odpojeno",
"provider.disconnect.toast.disconnected.description": "{{provider}} modely již nejsou k dispozici.",
"model.tag.free": "Zdarma",
"model.tag.variable": "Proměnlivá",
"model.tag.latest": "Nejnovější",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -205,6 +205,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} frakoblet",
"provider.disconnect.toast.disconnected.description": "Modeller fra {{provider}} er ikke længere tilgængelige.",
"model.tag.free": "Gratis",
"model.tag.variable": "Variabel",
"model.tag.latest": "Nyeste",
"model.provider.anthropic": "Anthropic",
+1
View File
@@ -196,6 +196,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} getrennt",
"provider.disconnect.toast.disconnected.description": "Die {{provider}}-Modelle sind nicht mehr verfügbar.",
"model.tag.free": "Kostenlos",
"model.tag.variable": "Variabel",
"model.tag.latest": "Neueste",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -289,6 +289,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ކަނެކްޓް ވެއްޖެއެވެ",
"provider.disconnect.toast.disconnected.description": "{{provider}} މޮޑެލްތައް މިހާރު ލިބެން ނެތެވެ.",
"model.tag.free": "ހިލޭ",
"model.tag.variable": "ބަދަލުވާ",
"model.tag.latest": "އެންމެފަހުގެ",
"model.provider.anthropic": "Anthropic އެވެ",
"model.provider.openai": "OpenAI އެވެ",
+1
View File
@@ -288,6 +288,7 @@ export const dict: Record<string, string> = {
"provider.disconnect.toast.disconnected.title": "{{provider}} མཐུད་ལམ་ཆད་ཡོདཔ།",
"provider.disconnect.toast.disconnected.description": "{{provider}} དཔེ་ཚད་ཚུ་ད་ལས་ཕར་འཐོབ་མི་ཚུགས།",
"model.tag.free": "རིན་མེད་སྟོང་པ",
"model.tag.variable": "འགྱུར་བཅོས་ཅན་",
"model.tag.latest": "ད༌རེས༌ནངས༌པ",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -285,6 +285,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} αποσυνδέθηκε",
"provider.disconnect.toast.disconnected.description": "{{provider}} μοντέλα δεν είναι πλέον διαθέσιμα.",
"model.tag.free": "Δωρεάν",
"model.tag.variable": "Μεταβλητή",
"model.tag.latest": "Τελευταία",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -267,6 +267,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.",
"model.tag.free": "Free",
"model.tag.variable": "Variable",
"model.tag.latest": "Latest",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -308,6 +308,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "Los modelos de {{provider}} ya no están disponibles.",
"model.tag.free": "Gratis",
"model.tag.variable": "Variable",
"model.tag.latest": "Más reciente",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} on katkestatud",
"provider.disconnect.toast.disconnected.description": "{{provider}} mudelit pole enam saadaval.",
"model.tag.free": "Tasuta",
"model.tag.variable": "Muutuv",
"model.tag.latest": "Viimased",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} قطع شد",
"provider.disconnect.toast.disconnected.description": "مدل های {{provider}} دیگر در دسترس نیستند.",
"model.tag.free": "رایگان",
"model.tag.variable": "متغیر",
"model.tag.latest": "آخرین",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -191,6 +191,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "Yhteys palveluntarjoajaan {{provider}} katkaistu",
"provider.disconnect.toast.disconnected.description": "{{provider}}-mallit eivät ole enää saatavilla.",
"model.tag.free": "Ilmainen",
"model.tag.variable": "Muuttuva",
"model.tag.latest": "Uusin",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} slitið",
"provider.disconnect.toast.disconnected.description": "{{provider}} modellir eru ikki tøkir longur.",
"model.tag.free": "Ókeypis",
"model.tag.variable": "Skiftandi",
"model.tag.latest": "Nýggjasta",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -295,6 +295,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} déconnecté",
"provider.disconnect.toast.disconnected.description": "Les modèles {{provider}} ne sont plus disponibles.",
"model.tag.free": "Gratuit",
"model.tag.variable": "Variable",
"model.tag.latest": "Le plus récent",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -282,6 +282,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} מנותק",
"provider.disconnect.toast.disconnected.description": "המודלים של {{provider}} אינם זמינים עוד.",
"model.tag.free": "חינם",
"model.tag.variable": "משתנה",
"model.tag.latest": "העדכני ביותר",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -291,6 +291,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} डिस्कनेक्ट हो गया",
"provider.disconnect.toast.disconnected.description": "{{provider}} मॉडल अब उपलब्ध नहीं हैं।",
"model.tag.free": "निःशुल्क",
"model.tag.variable": "परिवर्तनशील",
"model.tag.latest": "नवीनतम",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -288,6 +288,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} isključen",
"provider.disconnect.toast.disconnected.description": "{{provider}} modeli više nisu dostupni.",
"model.tag.free": "Besplatno",
"model.tag.variable": "Promjenjiva",
"model.tag.latest": "Najnoviji",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -288,6 +288,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} leválasztva",
"provider.disconnect.toast.disconnected.description": "A {{provider}} modellek már nem kaphatók.",
"model.tag.free": "Ingyenes",
"model.tag.variable": "Változó",
"model.tag.latest": "Legújabb",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -286,6 +286,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} անջատված է",
"provider.disconnect.toast.disconnected.description": "{{provider}} մոդելներն այլևս հասանելի չեն:",
"model.tag.free": "Անվճար",
"model.tag.variable": "Փոփոխական",
"model.tag.latest": "Վերջին",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -307,6 +307,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "Model {{provider}} tidak lagi tersedia.",
"model.tag.free": "Gratis",
"model.tag.variable": "Bervariasi",
"model.tag.latest": "Terbaru",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -288,6 +288,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} aftengdur",
"provider.disconnect.toast.disconnected.description": "{{provider}} gerðir eru ekki lengur fáanlegar.",
"model.tag.free": "Ókeypis",
"model.tag.variable": "Breytilegt",
"model.tag.latest": "Nýjasta",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -193,6 +193,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} disconnesso",
"provider.disconnect.toast.disconnected.description": "I modelli {{provider}} non sono più disponibili.",
"model.tag.free": "Gratuito",
"model.tag.variable": "Variabile",
"model.tag.latest": "Più recente",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -289,6 +289,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}}が切断されました",
"provider.disconnect.toast.disconnected.description": "{{provider}}のモデルは利用できなくなりました。",
"model.tag.free": "無料",
"model.tag.variable": "変動",
"model.tag.latest": "最新",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} გათიშულია",
"provider.disconnect.toast.disconnected.description": "{{provider}} მოდელები აღარ არის ხელმისაწვდომი.",
"model.tag.free": "უფასო",
"model.tag.variable": "ცვალებადი",
"model.tag.latest": "უახლესი",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ត្រូវបានផ្តាច់",
"provider.disconnect.toast.disconnected.description": "ម៉ូដែល {{provider}} លែងមានទៀតហើយ។",
"model.tag.free": "ឥតគិតថ្លៃ",
"model.tag.variable": "ប្រែប្រួល",
"model.tag.latest": "ចុងក្រោយ",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -186,6 +186,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} 연결 해제됨",
"provider.disconnect.toast.disconnected.description": "{{provider}} 모델을 더 이상 사용할 수 없습니다.",
"model.tag.free": "무료",
"model.tag.variable": "변동",
"model.tag.latest": "최신",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ຕັດການເຊື່ອມຕໍ່ແລ້ວ",
"provider.disconnect.toast.disconnected.description": "ໂມເດວ {{provider}} ບໍ່ມີແລ້ວ.",
"model.tag.free": "ຟຣີ",
"model.tag.variable": "ປ່ຽນແປງໄດ້",
"model.tag.latest": "ຫຼ້າສຸດ",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -289,6 +289,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} atjungtas",
"provider.disconnect.toast.disconnected.description": "{{provider}} modeliai nebepasiekiami.",
"model.tag.free": "Nemokama",
"model.tag.variable": "Kintama",
"model.tag.latest": "Naujausias",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} atvienots",
"provider.disconnect.toast.disconnected.description": "{{provider}} modeļi vairs nav pieejami.",
"model.tag.free": "Bezmaksas",
"model.tag.variable": "Mainīga",
"model.tag.latest": "Jaunākais",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -285,6 +285,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} исклучен",
"provider.disconnect.toast.disconnected.description": "{{provider}} моделите веќе не се достапни.",
"model.tag.free": "Бесплатно",
"model.tag.variable": "Променлива цена",
"model.tag.latest": "Најнови",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -287,6 +287,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} салгагдсан",
"provider.disconnect.toast.disconnected.description": "{{provider}} загварууд байхгүй болсон.",
"model.tag.free": "Үнэгүй",
"model.tag.variable": "Хувьсах үнэ",
"model.tag.latest": "Хамгийн сүүлийн үеийн",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} telah diputuskan",
"provider.disconnect.toast.disconnected.description": "Model {{provider}} tidak lagi tersedia.",
"model.tag.free": "Percuma",
"model.tag.variable": "Berubah-ubah",
"model.tag.latest": "Terkini",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -287,6 +287,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ချိတ်ဆက်မှု ပြတ်တောက်သွားသည်။",
"provider.disconnect.toast.disconnected.description": "{{provider}} မော်ဒယ်များကို မရနိုင်တော့ပါ။",
"model.tag.free": "အခမဲ့",
"model.tag.variable": "ပြောင်းလဲနိုင်သော",
"model.tag.latest": "နောက်ဆုံးထွက်",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -285,6 +285,7 @@ export const dict: Record<string, string> = {
"provider.disconnect.toast.disconnected.title": "{{provider}} जडान विच्छेद भयो",
"provider.disconnect.toast.disconnected.description": "{{provider}} मोडेलहरू अब उपलब्ध छैनन्।",
"model.tag.free": "नि:शुल्क",
"model.tag.variable": "परिवर्तनशील मूल्य",
"model.tag.latest": "पछिल्लो",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "Verbinding met {{provider}} verbroken",
"provider.disconnect.toast.disconnected.description": "{{provider}}-modellen zijn niet langer beschikbaar.",
"model.tag.free": "Gratis",
"model.tag.variable": "Variabel",
"model.tag.latest": "Nieuwste",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -306,6 +306,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "Modeller fra {{provider}} er ikke lenger tilgjengelige.",
"model.tag.free": "Gratis",
"model.tag.variable": "Variabel",
"model.tag.latest": "Nyeste",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -290,6 +290,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} منقطع",
"provider.disconnect.toast.disconnected.description": "{{provider}} ماڈل ہن دستیاب نئیں ہن۔",
"model.tag.free": "مفت",
"model.tag.variable": "بدلدی قیمت",
"model.tag.latest": "تازہ ترین",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -292,6 +292,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "Rozłączono {{provider}}",
"provider.disconnect.toast.disconnected.description": "Modele {{provider}} nie są już dostępne.",
"model.tag.free": "Darmowy",
"model.tag.variable": "Zmienna cena",
"model.tag.latest": "Najnowszy",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} deconectat",
"provider.disconnect.toast.disconnected.description": "Modelele {{provider}} nu mai sunt disponibile.",
"model.tag.free": "Gratuit",
"model.tag.variable": "Preț variabil",
"model.tag.latest": "Ultimul",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -306,6 +306,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} отключён",
"provider.disconnect.toast.disconnected.description": "Модели {{provider}} больше недоступны.",
"model.tag.free": "Бесплатно",
"model.tag.variable": "Цена меняется",
"model.tag.latest": "Последняя",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict: Record<string, string> = {
"provider.disconnect.toast.disconnected.title": "{{provider}} විසන්ධි විය",
"provider.disconnect.toast.disconnected.description": "{{provider}} මාදිලි තවදුරටත් නොමැත.",
"model.tag.free": "නොමිලේ",
"model.tag.variable": "විචල්‍ය",
"model.tag.latest": "නවතම",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} odpojený",
"provider.disconnect.toast.disconnected.description": "Modely {{provider}} už nie sú dostupné.",
"model.tag.free": "Bezplatné",
"model.tag.variable": "Premenlivá cena",
"model.tag.latest": "Najnovšie",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -283,6 +283,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} prekinjen",
"provider.disconnect.toast.disconnected.description": "Modeli {{provider}} niso več na voljo.",
"model.tag.free": "Brezplačno",
"model.tag.variable": "Spremenljiva cena",
"model.tag.latest": "Zadnje",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} u shkëput",
"provider.disconnect.toast.disconnected.description": "Modelet {{provider}} nuk janë më të disponueshme.",
"model.tag.free": "Falas",
"model.tag.variable": "Çmim i ndryshueshëm",
"model.tag.latest": "E fundit",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} прекинут",
"provider.disconnect.toast.disconnected.description": "{{provider}} модели више нису доступни.",
"model.tag.free": "Бесплатно",
"model.tag.variable": "Променљива цена",
"model.tag.latest": "Најновије",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -285,6 +285,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} frånkopplad",
"provider.disconnect.toast.disconnected.description": "{{provider}}-modeller är inte längre tillgängliga.",
"model.tag.free": "Gratis",
"model.tag.variable": "Varierande",
"model.tag.latest": "Senaste",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -285,6 +285,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} ҷудо карда шудааст",
"provider.disconnect.toast.disconnected.description": "{{provider}} моделҳо дигар дастрас нестанд.",
"model.tag.free": "Озод",
"model.tag.variable": "Тағйирёбанда",
"model.tag.latest": "Охирин",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -305,6 +305,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "โมเดล {{provider}} ไม่พร้อมใช้งานอีกต่อไป",
"model.tag.free": "ฟรี",
"model.tag.variable": "ผันแปร",
"model.tag.latest": "ล่าสุด",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -284,6 +284,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} kesildi",
"provider.disconnect.toast.disconnected.description": "{{provider}} modelleri indi ýok.",
"model.tag.free": "Mugt",
"model.tag.variable": "Üýtgeýän",
"model.tag.latest": "Iň soňky",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -312,6 +312,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} modelleri artık kullanılamıyor.",
"model.tag.free": "Ücretsiz",
"model.tag.variable": "Değişken",
"model.tag.latest": "En yeni",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -308,6 +308,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "Моделі {{provider}} більше недоступні.",
"model.tag.free": "Безкоштовно",
"model.tag.variable": "Змінна ціна",
"model.tag.latest": "Остання",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -293,6 +293,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} منقطع ہو گیا۔",
"provider.disconnect.toast.disconnected.description": "{{provider}} ماڈلز اب دستیاب نہیں ہیں۔",
"model.tag.free": "مفت",
"model.tag.variable": "متغیر قیمت",
"model.tag.latest": "تازہ ترین",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -286,6 +286,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} uzildi",
"provider.disconnect.toast.disconnected.description": "{{provider}} modellari endi mavjud emas.",
"model.tag.free": "Bepul",
"model.tag.variable": "Ozgaruvchan",
"model.tag.latest": "Oxirgi",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -291,6 +291,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} bị ngắt kết nối",
"provider.disconnect.toast.disconnected.description": "Các mô hình {{provider}} không còn khả dụng.",
"model.tag.free": "Miễn phí",
"model.tag.variable": "Giá thay đổi",
"model.tag.latest": "Mới nhất",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -327,6 +327,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
"model.tag.free": "免费",
"model.tag.variable": "浮动价格",
"model.tag.latest": "最新",
"model.provider.anthropic": "Anthropic",
"model.provider.openai": "OpenAI",
+1
View File
@@ -305,6 +305,7 @@ export const dict = {
"provider.disconnect.toast.disconnected.title": "{{provider}} 已中斷連線",
"provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
"model.tag.free": "免費",
"model.tag.variable": "浮動價格",
"model.tag.latest": "最新",
"model.provider.anthropic": "Anthropic",
@@ -62,7 +62,7 @@ mock.module("@/runtime/platform/platform", () => ({
}))
const { LocalProvider, useLocal } = await import("@/providers/models/selection")
const { ModelsProvider } = await import("@/providers/models/models")
const { ModelsProvider, useModels } = await import("@/providers/models/models")
const { Persist } = await import("@/runtime/persistence/storage")
const { createMemoryComposerState } = await import("@/composer/state")
const { createComposerModelSelection } = await import("@/composer/selection")
@@ -150,11 +150,13 @@ function fixture(input: { session?: Commit; agents?: Agent[]; config?: ConfigMod
mount(draft = false) {
active = result
let local!: ReturnType<typeof useLocal>
let models!: ReturnType<typeof useModels>
let composer: ReturnType<typeof createComposerModelSelection> | undefined
const dispose = createRoot((dispose) => {
createComponent(ModelsProvider, {
directory,
get children() {
models = useModels()
return createComponent(LocalProvider, {
get children() {
local = useLocal()
@@ -167,7 +169,7 @@ function fixture(input: { session?: Commit; agents?: Agent[]; config?: ConfigMod
return dispose
})
cleanups.push(dispose)
return { local, composer, dispose }
return { local, models, composer, dispose }
},
}
const target = Persist.serverWorkspace(ServerScope.local, directory, "model-selection")
@@ -457,3 +459,47 @@ test.each([1, -1] as const)("cycles %p from outside recents to the correct end a
local.model.cycle(direction)
expect(local.model.current()?.id).toBe(direction === 1 ? "b" : "c")
})
test("organization routes stay visible and selected by stable ID across catalog refreshes", () => {
const f = fixture()
const providerID = "opencode-routes-org_first"
const ref = { providerID, modelID: "route_1" }
f.set("providers", [{ ...f.state.providers[0], id: providerID, name: "First / Routes" }])
f.set("models", [{ ...f.state.models[0], providerID, id: ref.modelID, modelID: "coding", name: "Coding (latest)" }])
const { local, models } = f.mount()
expect(models.visible(ref)).toBe(true)
expect(local.model.list()).toHaveLength(1)
expect(local.model.list()[0]).toMatchObject({ id: "route_1", name: "Coding (latest)", latest: false })
local.model.set(ref, { recent: true })
expect(local.model.current()).toMatchObject({ id: "route_1", api: { id: "coding" } })
f.set("models", [{ ...f.state.models[0], modelID: "coding-renamed", name: "Renamed route" }])
expect(local.model.current()).toMatchObject({ id: "route_1", name: "Renamed route", api: { id: "coding-renamed" } })
expect(f.preferences.recent()).toEqual([ref])
models.setVisibility(ref, false)
expect(models.visible(ref)).toBe(false)
f.set("models", [{ ...f.state.models[0], name: "Updated route" }])
expect(models.visible(ref)).toBe(false)
f.set("models", [])
expect(local.model.list()).toEqual([])
expect(local.model.current()).toBeUndefined()
})
test("organization switches replace route catalogs without reusing visibility or recent references", () => {
const f = fixture()
const firstID = "opencode-routes-org_first"
const secondID = "opencode-routes-org_second"
const ref = { providerID: firstID, modelID: "route_1" }
f.set("providers", [{ ...f.state.providers[0], id: firstID, name: "First / Routes" }])
f.set("models", [{ ...f.state.models[0], providerID: firstID, id: ref.modelID, modelID: "coding" }])
const { local, models } = f.mount()
local.model.set(ref, { recent: true })
models.setVisibility(ref, false)
f.set("providers", [{ ...f.state.providers[0], id: secondID, name: "Second / Routes" }])
f.set("models", [{ ...f.state.models[0], providerID: secondID }])
expect(models.find(ref)).toBeUndefined()
expect(models.visible({ providerID: secondID, modelID: "route_1" })).toBe(true)
expect(local.model.list()).toHaveLength(1)
expect(local.model.list()[0].provider.id).toBe(secondID)
expect(local.model.current()?.provider.id).not.toBe(firstID)
expect(f.preferences.recent()).toEqual([ref])
})
@@ -0,0 +1,64 @@
import { expect, mock, test } from "bun:test"
import { createRequire } from "node:module"
import { createComponent } from "solid-js"
import { render } from "solid-js/web"
import { dict } from "@/runtime/i18n/en"
const require = createRequire(import.meta.url)
const solid = createRequire(require.resolve("vite-plugin-solid"))
const { transformSync } = solid("@babel/core")
Bun.plugin({
name: "model-tooltip-solid",
setup(build) {
build.onLoad({ filter: /[\\/]models[\\/]tooltip\.tsx$/ }, async (args) => ({
contents: transformSync(await Bun.file(args.path).text(), {
filename: args.path,
presets: [solid.resolve("babel-preset-solid"), solid.resolve("@babel/preset-typescript")],
}).code,
loader: "js",
}))
},
})
mock.module("@/runtime/i18n/language", () => ({
useLanguage: () => ({ t: (key: keyof typeof dict) => dict[key], intl: () => "en-US" }),
}))
const { ModelTooltip } = await import("@/providers/models/tooltip")
const model = {
id: "route_1",
name: "Claude coding",
provider: { id: "opencode-routes-org_first", name: "First / Routes" },
capabilities: { reasoning: false, input: { text: true, image: true, audio: false, video: false, pdf: false } },
limit: { context: 200_000 },
}
test.each([false, true])("route tooltip keeps its organization identity and variable price (v2=%p)", (v2) => {
const element = document.createElement("div")
const dispose = render(() => createComponent(ModelTooltip, { model, free: true, v2 }), element)
expect(element.textContent).toContain("First / Routes")
expect(element.textContent).toContain("Claude coding (Variable)")
expect(element.textContent).not.toContain("Anthropic")
expect(element.textContent).not.toContain("Free")
expect(element.textContent).not.toContain("200,000")
expect(element.textContent).not.toContain(dict["model.tooltip.reasoning.none"])
dispose()
})
test("ordinary model tooltips retain free pricing and capability details", () => {
const element = document.createElement("div")
const dispose = render(
() =>
createComponent(ModelTooltip, {
model: { ...model, provider: { id: "opencode", name: "OpenCode Zen" } },
free: true,
v2: true,
}),
element,
)
expect(element.textContent).toContain("Claude coding (Free)")
expect(element.textContent).toContain("OpenCode Zen")
expect(element.textContent).toContain("200,000")
expect(element.textContent).toContain(dict["model.tooltip.reasoning.none"])
expect(element.textContent).not.toContain("Variable")
dispose()
})
@@ -0,0 +1,23 @@
import { expect, test } from "bun:test"
import { fileURLToPath } from "node:url"
test("model tooltip presentation", async () => {
// Isolate the language context substitute and Solid compiler from other tests.
const child = Bun.spawn(
[
process.execPath,
"test",
"--conditions=browser",
"--preload",
"./happydom.ts",
"./test-browser/fixtures/model-tooltip.ts",
],
{ cwd: fileURLToPath(new URL("..", import.meta.url)), stdout: "pipe", stderr: "pipe" },
)
const [status, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
expect(status, stdout + stderr).toBe(0)
}, 30_000)
+1
View File
@@ -69,6 +69,7 @@ const builtins = new Map<string, () => Promise<unknown>>([
["@opencode/ai/providers/openai/chat", () => import("@opencode/ai/providers/openai/chat")],
["@opencode/ai/providers/openai/responses", () => import("@opencode/ai/providers/openai/responses")],
["@opencode/ai/providers/openai-compatible", () => import("@opencode/ai/providers/openai-compatible")],
["@opencode/ai/providers/organization-routes", () => import("@opencode/ai/providers/organization-routes")],
["@opencode/ai/providers/openrouter", () => import("@opencode/ai/providers/openrouter")],
["@opencode/ai/providers/togetherai", () => import("@opencode/ai/providers/togetherai")],
["@opencode/ai/providers/xai", () => import("@opencode/ai/providers/xai")],
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { LLM } from "@opencode/ai"
import { LLM, Message } from "@opencode/ai"
import { LLMClient, RequestExecutor } from "@opencode/ai/route"
import { Money } from "@opencode/schema/money"
import { Effect, Layer, Stream } from "effect"
@@ -538,6 +538,213 @@ describe("OpencodePlugin", () => {
),
)
it.effect("refreshes organization routes and replaces the previous organization's catalog", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { advertised: false, disabled: false, alias: "coding", name: "Coding", requests: 0 }
const inference: Array<{ body: unknown; authorization: string | null; orgID: string | null }> = []
const server = Bun.serve({
port: 0,
fetch: async (request) => {
if (new URL(request.url).pathname === "/route/openai/v1/responses") {
inference.push({
body: await request.json(),
authorization: request.headers.get("authorization"),
orgID: request.headers.get("x-opencode-org-id"),
})
const events = [
{
type: "response.output_item.done",
item: {
type: "message",
id: "msg_route",
role: "assistant",
content: [{ type: "output_text", text: "Hello" }],
provider_metadata: { protocol: "anthropic-messages", connection_id: "conn_first" },
},
},
{ type: "response.completed", response: { id: "resp_route" } },
]
return new Response(events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""), {
headers: { "content-type": "text/event-stream" },
})
}
state.requests++
const orgID = request.headers.get("x-org-id")
return Response.json({
providers: state.advertised
? {
[`opencode-routes-${orgID}`]: {
name: `${orgID} / Routes`,
package: "@opencode/ai/providers/organization-routes",
settings: {
baseURL: `${new URL(request.url).origin}/route/openai/v1`,
provider: `opencode-routes-${orgID}`,
},
headers: { "x-opencode-org-id": orgID },
models: {
route_1: {
name: state.name,
modelID: state.alias,
capabilities: { tools: true, input: ["text"], output: ["text"] },
cost: [],
limit: { context: 0, output: 0 },
disabled: state.disabled,
},
},
},
}
: {},
})
},
})
return { server, state, inference }
}),
({ server, state, inference }) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const firstID = Provider.ID.make("opencode-routes-org_first")
const secondID = Provider.ID.make("opencode-routes-org_second")
const routeID = Model.ID.make("route_1")
yield* catalog.transform((editor) => {
editor.model.update(Provider.ID.openai, Model.ID.make("coding"), (model) => {
model.name = "Upstream coding model"
model.cost = cost(10)
})
})
const first = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "first-key",
metadata: { server: server.url.origin, orgID: "org_first" },
}),
})
yield* addPlugin()
yield* drain
expect(yield* catalog.provider.get(firstID)).toBeUndefined()
state.advertised = true
yield* TestClock.adjust("10 minutes")
yield* drain
expect(yield* catalog.provider.get(firstID)).toMatchObject({
id: firstID,
name: "org_first / Routes",
integrationID: "opencode",
})
const route = required(yield* catalog.model.get(firstID, routeID))
expect(route).toMatchObject({
id: "route_1",
modelID: "coding",
name: "Coding",
providerID: firstID,
package: "@opencode/ai/providers/organization-routes",
cost: [],
limit: { context: 0, output: 0 },
headers: { "x-opencode-org-id": "org_first" },
})
expect(route.canonical).toBeUndefined()
expect((yield* catalog.model.available()).some((model) => model.providerID === firstID)).toBe(true)
const firstModel = yield* ModelResolver.resolveModel(route, undefined, first.value)
expect(String(firstModel.provider)).toBe(String(firstID))
expect(firstModel.route.providerMetadataKey).toBe(firstID)
const reply = yield* LLMClient.generate(LLM.request({ model: firstModel, prompt: "Hello" })).pipe(
Effect.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer))),
)
expect(inference[0]).toMatchObject({
authorization: "Bearer first-key",
orgID: "org_first",
body: { model: "coding", stream: true, store: false },
})
expect(reply.message.content).toMatchObject([
{
type: "text",
providerMetadata: { [firstID]: { providerMetadata: { protocol: "anthropic-messages" } } },
},
])
state.alias = "coding-renamed"
state.name = "Renamed route"
yield* TestClock.adjust("10 minutes")
yield* drain
expect(yield* catalog.model.get(firstID, routeID)).toMatchObject({
id: "route_1",
modelID: "coding-renamed",
name: "Renamed route",
})
expect(yield* catalog.model.get(firstID, Model.ID.make("coding"))).toBeUndefined()
state.disabled = true
yield* TestClock.adjust("10 minutes")
yield* drain
expect((yield* catalog.model.available()).some((model) => model.providerID === firstID)).toBe(false)
state.advertised = false
yield* TestClock.adjust("10 minutes")
yield* drain
expect(yield* catalog.model.get(firstID, routeID)).toBeUndefined()
expect(yield* catalog.provider.get(firstID)).toBeUndefined()
state.advertised = true
state.disabled = false
yield* TestClock.adjust("10 minutes")
yield* drain
expect(yield* catalog.model.get(firstID, routeID)).toBeDefined()
const requests = state.requests
const second = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "second-key",
metadata: { server: server.url.origin, orgID: "org_second" },
}),
})
yield* eventually(catalog.model.get(secondID, routeID), (model) => model !== undefined)
expect(state.requests).toBe(requests + 1)
expect(yield* catalog.provider.get(firstID)).toBeUndefined()
expect(yield* catalog.model.get(firstID, routeID)).toBeUndefined()
expect(yield* catalog.model.get(secondID, routeID)).toMatchObject({
providerID: secondID,
headers: { "x-opencode-org-id": "org_second" },
})
const secondModel = yield* ModelResolver.resolveModel(
required(yield* catalog.model.get(secondID, routeID)),
undefined,
second.value,
)
expect(String(secondModel.provider)).toBe(String(secondID))
expect(secondModel.route.providerMetadataKey).toBe(secondID)
yield* LLMClient.generate(
LLM.request({ model: secondModel, messages: [reply.message, Message.user("Continue")] }),
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer))))
expect(inference[1]).toMatchObject({
authorization: "Bearer second-key",
orgID: "org_second",
body: {
model: "coding-renamed",
input: [
{ role: "assistant", content: [{ type: "output_text", text: "Hello" }] },
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
],
},
})
expect(JSON.stringify(inference[1].body)).not.toContain("conn_first")
yield* credentials.remove(first.id)
yield* credentials.remove(second.id)
yield* eventually(catalog.provider.get(secondID), (provider) => provider === undefined)
expect(yield* catalog.model.get(secondID, routeID)).toBeUndefined()
expect(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("coding"))).toMatchObject({
name: "Upstream coding model",
cost: cost(10),
})
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
it.effect("refreshes hosted search with Console config and skips unchanged snapshots", () =>
Effect.acquireUseRelease(
Effect.sync(() => {
+1
View File
@@ -19,6 +19,7 @@ describe("Provider", () => {
"@opencode/ai/providers/google-vertex/messages",
"@opencode/ai/providers/groq",
"@opencode/ai/providers/mistral",
"@opencode/ai/providers/organization-routes",
"@opencode/ai/providers/togetherai",
]
@@ -1151,3 +1151,62 @@ Recent work
])
})
})
test("organization route history keeps opaque continuation state on its stable model identity", () => {
const route = Model.Ref.make({
id: Model.ID.make("route_coding"),
providerID: Provider.ID.make("opencode-routes-org_test"),
})
const metadata = {
protocol: "google",
model: "native-model",
group_id: "resp_1",
content: [{ functionCall: { name: "lookup", args: {} }, thoughtSignature: "opaque-signature" }],
}
const marker = { group_id: "resp_1" }
const history = [
SessionMessage.Assistant.make({
id: id("route-tool"),
type: "assistant",
agent: build,
model: route,
content: [
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "",
state: { itemId: "rs_1", providerMetadata: metadata },
}),
SessionMessage.AssistantText.make({
type: "text",
text: "Checking.",
state: { itemId: "msg_1", providerMetadata: marker },
}),
SessionMessage.AssistantTool.make({
type: "tool",
id: "call_1",
name: "lookup",
providerState: { itemId: "fc_1", providerMetadata: marker },
state: SessionMessage.ToolStateCompleted.make({
status: "completed",
input: {},
content: [{ type: "text", text: "Done" }],
}),
time: { created, completed: created },
}),
],
time: { created, completed: created },
}),
]
const replay = toLLMMessages(history, route)
expect(
replay[0]?.content.map((part) =>
part.type === "compaction" ? undefined : part.providerMetadata?.[route.providerID]?.providerMetadata,
),
).toEqual([metadata, marker, marker])
expect(replay[1]?.role).toBe("tool")
const other = toLLMMessages(history, Model.Ref.make({ ...route, id: Model.ID.make("route_other") }))
expect(other[0]?.content.map((part) => (part.type === "compaction" ? undefined : part.providerMetadata))).toEqual([
undefined,
undefined,
])
})
+6 -4
View File
@@ -1,4 +1,5 @@
import { createMemo, createSignal } from "solid-js"
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
import { useLocal } from "../context/local"
import { DialogSelect } from "../ui/dialog-select"
import { useDialog } from "../ui/dialog"
@@ -46,7 +47,7 @@ export function DialogModel(props: { providerID?: string }) {
releaseDate: model.time.released,
description: provider?.name ?? model.providerID,
category,
footer: free(model) ? "Free" : undefined,
footer: modelPriceLabel(model),
onSelect: () => {
onSelect(model.providerID, model.id)
},
@@ -79,7 +80,7 @@ export function DialogModel(props: { providerID?: string }) {
releaseDate: model.time.released,
description: favorite ? "(Favorite)" : undefined,
category: connected() ? (provider?.name ?? model.providerID) : undefined,
footer: free(model) ? "Free" : undefined,
footer: modelPriceLabel(model),
onSelect() {
onSelect(model.providerID, model.id)
},
@@ -211,6 +212,7 @@ export function sortModelOptions<
})
}
function free(model: { cost: Array<{ input: number }> }) {
return model.cost.length > 0 && model.cost.every((cost) => cost.input === 0)
export function modelPriceLabel(model: { providerID: string; cost: Array<{ input: number }> }) {
if (isOrganizationRouteProvider(model.providerID)) return "Variable"
return model.cost.length > 0 && model.cost.every((cost) => cost.input === 0) ? "Free" : undefined
}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { go } from "fuzzysort"
import { prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
import { modelPriceLabel, prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
describe("prioritizeFavorites", () => {
test("uses the favorite order captured when the dialog opened", () => {
@@ -82,3 +82,22 @@ describe("sortModelOptions", () => {
expect(sorted.map((model) => model.title)).toEqual(["Claude Opus 4", "Claude Sonnet 4"])
})
})
describe("organization route options", () => {
test("shows variable pricing even when an empty or zero price is supplied", () => {
expect(modelPriceLabel({ providerID: "opencode-routes-org_first", cost: [] })).toBe("Variable")
expect(modelPriceLabel({ providerID: "opencode-routes-org_first", cost: [{ input: 0 }] })).toBe("Variable")
expect(modelPriceLabel({ providerID: "opencode", cost: [{ input: 0 }] })).toBe("Free")
expect(modelPriceLabel({ providerID: "opencode", cost: [] })).toBeUndefined()
expect(modelPriceLabel({ providerID: "anthropic", cost: [{ input: 3 }] })).toBeUndefined()
})
test("keeps favorites isolated when organizations have the same route key", () => {
const first = { value: { providerID: "opencode-routes-org_first", modelID: "route_1" } }
const second = { value: { providerID: "opencode-routes-org_second", modelID: "route_1" } }
expect(prioritizeFavorites([second, first], new Set(["opencode-routes-org_first/route_1"]))).toEqual([
first,
second,
])
})
})
+4
View File
@@ -0,0 +1,4 @@
// Console reserves this provider namespace for organization-scoped routing catalogs.
export function isOrganizationRouteProvider(providerID: string) {
return providerID.startsWith("opencode-routes-") && providerID.length > "opencode-routes-".length
}