mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-12 20:06:24 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d66ff18186 | ||
|
|
f1d00cdc07 |
@@ -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"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { DateTime } from "luxon"
|
||||
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
@@ -79,8 +80,8 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
const list = createMemo(() =>
|
||||
available().map((m) => ({
|
||||
...m,
|
||||
name: m.name.replace("(latest)", "").trim(),
|
||||
latest: m.name.includes("(latest)"),
|
||||
name: isOrganizationRouteProvider(m.provider.id) ? m.name : m.name.replace("(latest)", "").trim(),
|
||||
latest: !isOrganizationRouteProvider(m.provider.id) && m.name.includes("(latest)"),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -100,6 +101,7 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
const state = visibility().get(key)
|
||||
if (state === "hide") return false
|
||||
if (state === "show") return true
|
||||
if (isOrganizationRouteProvider(model.providerID)) return true
|
||||
if (latestSet().has(key)) return true
|
||||
const date = release().get(key)
|
||||
if (!date?.isValid) return true
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
|
||||
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal, type ModelSelection } from "@/providers/models/selection"
|
||||
@@ -197,6 +198,9 @@ const ModelList: Component<{
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
</Tooltip>
|
||||
<Show when={isOrganizationRouteProvider(item.provider.id)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.variable")}</Badge>
|
||||
</Show>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
|
||||
</Show>
|
||||
@@ -489,6 +493,9 @@ function ModelSelectorPopoverView(props: {
|
||||
onSelect={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate leading-5">{item.name}</span>
|
||||
<Show when={isOrganizationRouteProvider(item.provider.id)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.variable")}</Badge>
|
||||
</Show>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
|
||||
</Show>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Show, type Component, type JSX } from "solid-js"
|
||||
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
|
||||
@@ -8,6 +9,7 @@ type ModelInfo = {
|
||||
id: string
|
||||
name: string
|
||||
provider: {
|
||||
id?: string
|
||||
name: string
|
||||
}
|
||||
capabilities?: {
|
||||
@@ -36,7 +38,9 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
props,
|
||||
) => {
|
||||
const language = useLanguage()
|
||||
const route = () => isOrganizationRouteProvider(props.model.provider.id ?? "")
|
||||
const sourceName = (model: ModelInfo) => {
|
||||
if (route()) return model.provider.name
|
||||
const value = `${model.id} ${model.name}`.toLowerCase()
|
||||
|
||||
if (/claude|anthropic/.test(value)) return language.t("model.provider.anthropic")
|
||||
@@ -58,14 +62,16 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
const title = () => {
|
||||
const tags: Array<string> = []
|
||||
if (props.latest) tags.push(language.t("model.tag.latest"))
|
||||
if (props.free) tags.push(language.t("model.tag.free"))
|
||||
if (route()) tags.push(language.t("model.tag.variable"))
|
||||
if (!route() && props.free) tags.push(language.t("model.tag.free"))
|
||||
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
|
||||
return `${sourceName(props.model)} ${props.model.name}${suffix}`
|
||||
}
|
||||
const name = () => {
|
||||
const tags: Array<string> = []
|
||||
if (props.latest) tags.push(language.t("model.tag.latest"))
|
||||
if (props.free) tags.push(language.t("model.tag.free"))
|
||||
if (route()) tags.push(language.t("model.tag.variable"))
|
||||
if (!route() && props.free) tags.push(language.t("model.tag.free"))
|
||||
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
|
||||
return `${props.model.name}${suffix}`
|
||||
}
|
||||
@@ -98,11 +104,13 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
<div class="flex w-[180px] flex-col gap-2">
|
||||
<ModelTooltipRow name={language.t("model.tooltip.model")} value={name()} />
|
||||
<ModelTooltipRow name={language.t("model.tooltip.provider")} value={props.model.provider.name} />
|
||||
<Show when={inputs()}>
|
||||
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
|
||||
<Show when={!route()}>
|
||||
<Show when={inputs()}>
|
||||
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
|
||||
</Show>
|
||||
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
|
||||
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
|
||||
</Show>
|
||||
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
|
||||
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -110,15 +118,17 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
return (
|
||||
<div class="flex flex-col gap-1 py-1">
|
||||
<div class="text-13-medium">{title()}</div>
|
||||
<Show when={inputs()}>
|
||||
{(value) => (
|
||||
<div class="text-12-regular text-text-invert-base">
|
||||
{language.t("model.tooltip.allows", { inputs: value() })}
|
||||
</div>
|
||||
)}
|
||||
<Show when={!route()}>
|
||||
<Show when={inputs()}>
|
||||
{(value) => (
|
||||
<div class="text-12-regular text-text-invert-base">
|
||||
{language.t("model.tooltip.allows", { inputs: value() })}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="text-12-regular text-text-invert-base">{reasoning()}</div>
|
||||
<div class="text-12-regular text-text-invert-base">{context()}</div>
|
||||
</Show>
|
||||
<div class="text-12-regular text-text-invert-base">{reasoning()}</div>
|
||||
<div class="text-12-regular text-text-invert-base">{context()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -280,6 +280,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} ተቋርጧል",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} ሞዴሎች ከአሁን በኋላ አይገኙም።",
|
||||
"model.tag.free": "ነጻ",
|
||||
"model.tag.variable": "ተለዋዋጭ",
|
||||
"model.tag.latest": "የቅርብ",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -290,6 +290,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "تم فصل {{provider}}",
|
||||
"provider.disconnect.toast.disconnected.description": "لم تعد نماذج {{provider}} متاحة.",
|
||||
"model.tag.free": "مجاني",
|
||||
"model.tag.variable": "متغير",
|
||||
"model.tag.latest": "الأحدث",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -287,6 +287,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} ayrıldı",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} modelləri artıq mövcud deyil.",
|
||||
"model.tag.free": "Pulsuz",
|
||||
"model.tag.variable": "Dəyişkən",
|
||||
"model.tag.latest": "Ən son",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -287,6 +287,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} прекъснат",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} модели вече не са налични.",
|
||||
"model.tag.free": "безплатно",
|
||||
"model.tag.variable": "Променлива",
|
||||
"model.tag.latest": "Последни",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -284,6 +284,7 @@ export const dict: Record<string, string> = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} সংযোগ বিচ্ছিন্ন",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} মডেল আর উপলব্ধ নেই৷",
|
||||
"model.tag.free": "বিনামূল্যে",
|
||||
"model.tag.variable": "পরিবর্তনশীল",
|
||||
"model.tag.latest": "সর্বশেষ",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -292,6 +292,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} desconectado",
|
||||
"provider.disconnect.toast.disconnected.description": "Os modelos de {{provider}} não estão mais disponíveis.",
|
||||
"model.tag.free": "Grátis",
|
||||
"model.tag.variable": "Variável",
|
||||
"model.tag.latest": "Mais recente",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -308,6 +308,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} modeli više nisu dostupni.",
|
||||
|
||||
"model.tag.free": "Besplatno",
|
||||
"model.tag.variable": "Promjenjiva",
|
||||
"model.tag.latest": "Najnovije",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -286,6 +286,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} desconnectat",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} models ja no estan disponibles.",
|
||||
"model.tag.free": "Gratuït",
|
||||
"model.tag.variable": "Variable",
|
||||
"model.tag.latest": "Última",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -284,6 +284,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} odpojeno",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} modely již nejsou k dispozici.",
|
||||
"model.tag.free": "Zdarma",
|
||||
"model.tag.variable": "Proměnlivá",
|
||||
"model.tag.latest": "Nejnovější",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -205,6 +205,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} frakoblet",
|
||||
"provider.disconnect.toast.disconnected.description": "Modeller fra {{provider}} er ikke længere tilgængelige.",
|
||||
"model.tag.free": "Gratis",
|
||||
"model.tag.variable": "Variabel",
|
||||
"model.tag.latest": "Nyeste",
|
||||
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
|
||||
@@ -196,6 +196,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} getrennt",
|
||||
"provider.disconnect.toast.disconnected.description": "Die {{provider}}-Modelle sind nicht mehr verfügbar.",
|
||||
"model.tag.free": "Kostenlos",
|
||||
"model.tag.variable": "Variabel",
|
||||
"model.tag.latest": "Neueste",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -289,6 +289,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} ކަނެކްޓް ވެއްޖެއެވެ",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} މޮޑެލްތައް މިހާރު ލިބެން ނެތެވެ.",
|
||||
"model.tag.free": "ހިލޭ",
|
||||
"model.tag.variable": "ބަދަލުވާ",
|
||||
"model.tag.latest": "އެންމެފަހުގެ",
|
||||
"model.provider.anthropic": "Anthropic އެވެ",
|
||||
"model.provider.openai": "OpenAI އެވެ",
|
||||
|
||||
@@ -288,6 +288,7 @@ export const dict: Record<string, string> = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} མཐུད་ལམ་ཆད་ཡོདཔ།",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} དཔེ་ཚད་ཚུ་ད་ལས་ཕར་འཐོབ་མི་ཚུགས།",
|
||||
"model.tag.free": "རིན་མེད་སྟོང་པ",
|
||||
"model.tag.variable": "འགྱུར་བཅོས་ཅན་",
|
||||
"model.tag.latest": "ད༌རེས༌ནངས༌པ",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -285,6 +285,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} αποσυνδέθηκε",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} μοντέλα δεν είναι πλέον διαθέσιμα.",
|
||||
"model.tag.free": "Δωρεάν",
|
||||
"model.tag.variable": "Μεταβλητή",
|
||||
"model.tag.latest": "Τελευταία",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -267,6 +267,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.",
|
||||
|
||||
"model.tag.free": "Free",
|
||||
"model.tag.variable": "Variable",
|
||||
"model.tag.latest": "Latest",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -308,6 +308,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.description": "Los modelos de {{provider}} ya no están disponibles.",
|
||||
|
||||
"model.tag.free": "Gratis",
|
||||
"model.tag.variable": "Variable",
|
||||
"model.tag.latest": "Más reciente",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -283,6 +283,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} on katkestatud",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} mudelit pole enam saadaval.",
|
||||
"model.tag.free": "Tasuta",
|
||||
"model.tag.variable": "Muutuv",
|
||||
"model.tag.latest": "Viimased",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -284,6 +284,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} قطع شد",
|
||||
"provider.disconnect.toast.disconnected.description": "مدل های {{provider}} دیگر در دسترس نیستند.",
|
||||
"model.tag.free": "رایگان",
|
||||
"model.tag.variable": "متغیر",
|
||||
"model.tag.latest": "آخرین",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -191,6 +191,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "Yhteys palveluntarjoajaan {{provider}} katkaistu",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}}-mallit eivät ole enää saatavilla.",
|
||||
"model.tag.free": "Ilmainen",
|
||||
"model.tag.variable": "Muuttuva",
|
||||
"model.tag.latest": "Uusin",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -283,6 +283,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} slitið",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} modellir eru ikki tøkir longur.",
|
||||
"model.tag.free": "Ókeypis",
|
||||
"model.tag.variable": "Skiftandi",
|
||||
"model.tag.latest": "Nýggjasta",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -295,6 +295,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} déconnecté",
|
||||
"provider.disconnect.toast.disconnected.description": "Les modèles {{provider}} ne sont plus disponibles.",
|
||||
"model.tag.free": "Gratuit",
|
||||
"model.tag.variable": "Variable",
|
||||
"model.tag.latest": "Le plus récent",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -282,6 +282,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} מנותק",
|
||||
"provider.disconnect.toast.disconnected.description": "המודלים של {{provider}} אינם זמינים עוד.",
|
||||
"model.tag.free": "חינם",
|
||||
"model.tag.variable": "משתנה",
|
||||
"model.tag.latest": "העדכני ביותר",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -291,6 +291,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} डिस्कनेक्ट हो गया",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} मॉडल अब उपलब्ध नहीं हैं।",
|
||||
"model.tag.free": "निःशुल्क",
|
||||
"model.tag.variable": "परिवर्तनशील",
|
||||
"model.tag.latest": "नवीनतम",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -288,6 +288,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} isključen",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} modeli više nisu dostupni.",
|
||||
"model.tag.free": "Besplatno",
|
||||
"model.tag.variable": "Promjenjiva",
|
||||
"model.tag.latest": "Najnoviji",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -288,6 +288,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} leválasztva",
|
||||
"provider.disconnect.toast.disconnected.description": "A {{provider}} modellek már nem kaphatók.",
|
||||
"model.tag.free": "Ingyenes",
|
||||
"model.tag.variable": "Változó",
|
||||
"model.tag.latest": "Legújabb",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -286,6 +286,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} անջատված է",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} մոդելներն այլևս հասանելի չեն:",
|
||||
"model.tag.free": "Անվճար",
|
||||
"model.tag.variable": "Փոփոխական",
|
||||
"model.tag.latest": "Վերջին",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -307,6 +307,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.description": "Model {{provider}} tidak lagi tersedia.",
|
||||
|
||||
"model.tag.free": "Gratis",
|
||||
"model.tag.variable": "Bervariasi",
|
||||
"model.tag.latest": "Terbaru",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -288,6 +288,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} aftengdur",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} gerðir eru ekki lengur fáanlegar.",
|
||||
"model.tag.free": "Ókeypis",
|
||||
"model.tag.variable": "Breytilegt",
|
||||
"model.tag.latest": "Nýjasta",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -193,6 +193,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} disconnesso",
|
||||
"provider.disconnect.toast.disconnected.description": "I modelli {{provider}} non sono più disponibili.",
|
||||
"model.tag.free": "Gratuito",
|
||||
"model.tag.variable": "Variabile",
|
||||
"model.tag.latest": "Più recente",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -289,6 +289,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}}が切断されました",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}}のモデルは利用できなくなりました。",
|
||||
"model.tag.free": "無料",
|
||||
"model.tag.variable": "変動",
|
||||
"model.tag.latest": "最新",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -284,6 +284,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} გათიშულია",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} მოდელები აღარ არის ხელმისაწვდომი.",
|
||||
"model.tag.free": "უფასო",
|
||||
"model.tag.variable": "ცვალებადი",
|
||||
"model.tag.latest": "უახლესი",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -283,6 +283,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} ត្រូវបានផ្តាច់",
|
||||
"provider.disconnect.toast.disconnected.description": "ម៉ូដែល {{provider}} លែងមានទៀតហើយ។",
|
||||
"model.tag.free": "ឥតគិតថ្លៃ",
|
||||
"model.tag.variable": "ប្រែប្រួល",
|
||||
"model.tag.latest": "ចុងក្រោយ",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -186,6 +186,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} 연결 해제됨",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} 모델을 더 이상 사용할 수 없습니다.",
|
||||
"model.tag.free": "무료",
|
||||
"model.tag.variable": "변동",
|
||||
"model.tag.latest": "최신",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -283,6 +283,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} ຕັດການເຊື່ອມຕໍ່ແລ້ວ",
|
||||
"provider.disconnect.toast.disconnected.description": "ໂມເດວ {{provider}} ບໍ່ມີແລ້ວ.",
|
||||
"model.tag.free": "ຟຣີ",
|
||||
"model.tag.variable": "ປ່ຽນແປງໄດ້",
|
||||
"model.tag.latest": "ຫຼ້າສຸດ",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -289,6 +289,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} atjungtas",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} modeliai nebepasiekiami.",
|
||||
"model.tag.free": "Nemokama",
|
||||
"model.tag.variable": "Kintama",
|
||||
"model.tag.latest": "Naujausias",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -284,6 +284,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} atvienots",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} modeļi vairs nav pieejami.",
|
||||
"model.tag.free": "Bezmaksas",
|
||||
"model.tag.variable": "Mainīga",
|
||||
"model.tag.latest": "Jaunākais",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -285,6 +285,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} исклучен",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} моделите веќе не се достапни.",
|
||||
"model.tag.free": "Бесплатно",
|
||||
"model.tag.variable": "Променлива цена",
|
||||
"model.tag.latest": "Најнови",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -287,6 +287,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} салгагдсан",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} загварууд байхгүй болсон.",
|
||||
"model.tag.free": "Үнэгүй",
|
||||
"model.tag.variable": "Хувьсах үнэ",
|
||||
"model.tag.latest": "Хамгийн сүүлийн үеийн",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -284,6 +284,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} telah diputuskan",
|
||||
"provider.disconnect.toast.disconnected.description": "Model {{provider}} tidak lagi tersedia.",
|
||||
"model.tag.free": "Percuma",
|
||||
"model.tag.variable": "Berubah-ubah",
|
||||
"model.tag.latest": "Terkini",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -287,6 +287,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} ချိတ်ဆက်မှု ပြတ်တောက်သွားသည်။",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} မော်ဒယ်များကို မရနိုင်တော့ပါ။",
|
||||
"model.tag.free": "အခမဲ့",
|
||||
"model.tag.variable": "ပြောင်းလဲနိုင်သော",
|
||||
"model.tag.latest": "နောက်ဆုံးထွက်",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -285,6 +285,7 @@ export const dict: Record<string, string> = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} जडान विच्छेद भयो",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} मोडेलहरू अब उपलब्ध छैनन्।",
|
||||
"model.tag.free": "नि:शुल्क",
|
||||
"model.tag.variable": "परिवर्तनशील मूल्य",
|
||||
"model.tag.latest": "पछिल्लो",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -284,6 +284,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "Verbinding met {{provider}} verbroken",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}}-modellen zijn niet langer beschikbaar.",
|
||||
"model.tag.free": "Gratis",
|
||||
"model.tag.variable": "Variabel",
|
||||
"model.tag.latest": "Nieuwste",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -306,6 +306,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.description": "Modeller fra {{provider}} er ikke lenger tilgjengelige.",
|
||||
|
||||
"model.tag.free": "Gratis",
|
||||
"model.tag.variable": "Variabel",
|
||||
"model.tag.latest": "Nyeste",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -290,6 +290,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} منقطع",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} ماڈل ہن دستیاب نئیں ہن۔",
|
||||
"model.tag.free": "مفت",
|
||||
"model.tag.variable": "بدلدی قیمت",
|
||||
"model.tag.latest": "تازہ ترین",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -292,6 +292,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "Rozłączono {{provider}}",
|
||||
"provider.disconnect.toast.disconnected.description": "Modele {{provider}} nie są już dostępne.",
|
||||
"model.tag.free": "Darmowy",
|
||||
"model.tag.variable": "Zmienna cena",
|
||||
"model.tag.latest": "Najnowszy",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -283,6 +283,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} deconectat",
|
||||
"provider.disconnect.toast.disconnected.description": "Modelele {{provider}} nu mai sunt disponibile.",
|
||||
"model.tag.free": "Gratuit",
|
||||
"model.tag.variable": "Preț variabil",
|
||||
"model.tag.latest": "Ultimul",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -306,6 +306,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} отключён",
|
||||
"provider.disconnect.toast.disconnected.description": "Модели {{provider}} больше недоступны.",
|
||||
"model.tag.free": "Бесплатно",
|
||||
"model.tag.variable": "Цена меняется",
|
||||
"model.tag.latest": "Последняя",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -283,6 +283,7 @@ export const dict: Record<string, string> = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} විසන්ධි විය",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} මාදිලි තවදුරටත් නොමැත.",
|
||||
"model.tag.free": "නොමිලේ",
|
||||
"model.tag.variable": "විචල්ය",
|
||||
"model.tag.latest": "නවතම",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -283,6 +283,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} odpojený",
|
||||
"provider.disconnect.toast.disconnected.description": "Modely {{provider}} už nie sú dostupné.",
|
||||
"model.tag.free": "Bezplatné",
|
||||
"model.tag.variable": "Premenlivá cena",
|
||||
"model.tag.latest": "Najnovšie",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -283,6 +283,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} prekinjen",
|
||||
"provider.disconnect.toast.disconnected.description": "Modeli {{provider}} niso več na voljo.",
|
||||
"model.tag.free": "Brezplačno",
|
||||
"model.tag.variable": "Spremenljiva cena",
|
||||
"model.tag.latest": "Zadnje",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -284,6 +284,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} u shkëput",
|
||||
"provider.disconnect.toast.disconnected.description": "Modelet {{provider}} nuk janë më të disponueshme.",
|
||||
"model.tag.free": "Falas",
|
||||
"model.tag.variable": "Çmim i ndryshueshëm",
|
||||
"model.tag.latest": "E fundit",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -284,6 +284,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} прекинут",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} модели више нису доступни.",
|
||||
"model.tag.free": "Бесплатно",
|
||||
"model.tag.variable": "Променљива цена",
|
||||
"model.tag.latest": "Најновије",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -285,6 +285,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} frånkopplad",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}}-modeller är inte längre tillgängliga.",
|
||||
"model.tag.free": "Gratis",
|
||||
"model.tag.variable": "Varierande",
|
||||
"model.tag.latest": "Senaste",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -285,6 +285,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} ҷудо карда шудааст",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} моделҳо дигар дастрас нестанд.",
|
||||
"model.tag.free": "Озод",
|
||||
"model.tag.variable": "Тағйирёбанда",
|
||||
"model.tag.latest": "Охирин",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -305,6 +305,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.description": "โมเดล {{provider}} ไม่พร้อมใช้งานอีกต่อไป",
|
||||
|
||||
"model.tag.free": "ฟรี",
|
||||
"model.tag.variable": "ผันแปร",
|
||||
"model.tag.latest": "ล่าสุด",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -284,6 +284,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} kesildi",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} modelleri indi ýok.",
|
||||
"model.tag.free": "Mugt",
|
||||
"model.tag.variable": "Üýtgeýän",
|
||||
"model.tag.latest": "Iň soňky",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -312,6 +312,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} modelleri artık kullanılamıyor.",
|
||||
|
||||
"model.tag.free": "Ücretsiz",
|
||||
"model.tag.variable": "Değişken",
|
||||
"model.tag.latest": "En yeni",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -308,6 +308,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.description": "Моделі {{provider}} більше недоступні.",
|
||||
|
||||
"model.tag.free": "Безкоштовно",
|
||||
"model.tag.variable": "Змінна ціна",
|
||||
"model.tag.latest": "Остання",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -293,6 +293,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} منقطع ہو گیا۔",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} ماڈلز اب دستیاب نہیں ہیں۔",
|
||||
"model.tag.free": "مفت",
|
||||
"model.tag.variable": "متغیر قیمت",
|
||||
"model.tag.latest": "تازہ ترین",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -286,6 +286,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} uzildi",
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} modellari endi mavjud emas.",
|
||||
"model.tag.free": "Bepul",
|
||||
"model.tag.variable": "O‘zgaruvchan",
|
||||
"model.tag.latest": "Oxirgi",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -291,6 +291,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.title": "{{provider}} bị ngắt kết nối",
|
||||
"provider.disconnect.toast.disconnected.description": "Các mô hình {{provider}} không còn khả dụng.",
|
||||
"model.tag.free": "Miễn phí",
|
||||
"model.tag.variable": "Giá thay đổi",
|
||||
"model.tag.latest": "Mới nhất",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -327,6 +327,7 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。",
|
||||
|
||||
"model.tag.free": "免费",
|
||||
"model.tag.variable": "浮动价格",
|
||||
"model.tag.latest": "最新",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -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)
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user