From f1d00cdc0735f2fb19e480072747098cb6b472b5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 11 Sep 2026 05:44:47 -0500 Subject: [PATCH 1/2] feat(routing): add organization routes to model selectors --- packages/ai/src/protocols/open-responses.ts | 57 ++- .../ai/src/protocols/organization-routes.ts | 72 ++++ .../ai/src/providers/organization-routes.ts | 57 +++ .../test/provider/organization-routes.test.ts | 361 ++++++++++++++++++ packages/app/src/providers/models/models.tsx | 6 +- .../src/providers/models/select-dialog.tsx | 7 + packages/app/src/providers/models/tooltip.tsx | 38 +- packages/app/src/runtime/i18n/en.ts | 1 + .../test-browser/fixtures/model-selection.ts | 50 ++- .../test-browser/fixtures/model-tooltip.ts | 64 ++++ .../app/test-browser/model-tooltip.test.ts | 23 ++ packages/core/src/plugin/provider/opencode.ts | 2 +- packages/core/src/provider.ts | 1 + .../test/plugin/provider-opencode.test.ts | 217 ++++++++++- packages/core/test/provider.test.ts | 1 + .../core/test/session-runner-message.test.ts | 59 +++ packages/tui/src/component/dialog-model.tsx | 10 +- .../test/cli/cmd/tui/model-options.test.ts | 21 +- packages/util/src/organization-routes.ts | 4 + 19 files changed, 1011 insertions(+), 40 deletions(-) create mode 100644 packages/ai/src/protocols/organization-routes.ts create mode 100644 packages/ai/src/providers/organization-routes.ts create mode 100644 packages/ai/test/provider/organization-routes.test.ts create mode 100644 packages/app/test-browser/fixtures/model-tooltip.ts create mode 100644 packages/app/test-browser/model-tooltip.test.ts create mode 100644 packages/util/src/organization-routes.ts diff --git a/packages/ai/src/protocols/open-responses.ts b/packages/ai/src/protocols/open-responses.ts index c1ff98ba2de1..351fd8ff0ab5 100644 --- a/packages/ai/src/protocols/open-responses.ts +++ b/packages/ai/src/protocols/open-responses.ts @@ -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 } 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, ) => 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 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) => + 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(), lifecycle: Lifecycle.initial(), diff --git a/packages/ai/src/protocols/organization-routes.ts b/packages/ai/src/protocols/organization-routes.ts new file mode 100644 index 000000000000..c8fe48c7dc47 --- /dev/null +++ b/packages/ai/src/protocols/organization-routes.ts @@ -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(), + defaults: { providerOptions: { store: false, include: [] } }, +}) + +export * as OrganizationRoutes from "./organization-routes.js" diff --git a/packages/ai/src/providers/organization-routes.ts b/packages/ai/src/providers/organization-routes.ts new file mode 100644 index 000000000000..ffd0f20ec868 --- /dev/null +++ b/packages/ai/src/providers/organization-routes.ts @@ -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({ id: modelID }), + configure, + } +} + +export const provider = { id, configure } + +export const model: ProviderPackage.Definition["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) diff --git a/packages/ai/test/provider/organization-routes.test.ts b/packages/ai/test/provider/organization-routes.test.ts new file mode 100644 index 000000000000..c5a4ecd9fb9a --- /dev/null +++ b/packages/ai/test/provider/organization-routes.test.ts @@ -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"]) + }), + ) +}) diff --git a/packages/app/src/providers/models/models.tsx b/packages/app/src/providers/models/models.tsx index efe954d72aea..ee1f36df94f5 100644 --- a/packages/app/src/providers/models/models.tsx +++ b/packages/app/src/providers/models/models.tsx @@ -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) => { 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) => { 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 diff --git a/packages/app/src/providers/models/select-dialog.tsx b/packages/app/src/providers/models/select-dialog.tsx index 0152ca0c8fac..303d4a45f9f5 100644 --- a/packages/app/src/providers/models/select-dialog.tsx +++ b/packages/app/src/providers/models/select-dialog.tsx @@ -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<{ > {item.name} + + {language.t("model.tag.variable")} + {language.t("model.tag.free")} @@ -489,6 +493,9 @@ function ModelSelectorPopoverView(props: { onSelect={() => selectModel(item)} > {item.name} + + {language.t("model.tag.variable")} + {language.t("model.tag.free")} diff --git a/packages/app/src/providers/models/tooltip.tsx b/packages/app/src/providers/models/tooltip.tsx index aed03b0dfec1..4a66e79508d5 100644 --- a/packages/app/src/providers/models/tooltip.tsx +++ b/packages/app/src/providers/models/tooltip.tsx @@ -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 = [] 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 = [] 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?
- - {(value) => } + + + {(value) => } + + + - -
) } @@ -110,15 +118,17 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free? return (
{title()}
- - {(value) => ( -
- {language.t("model.tooltip.allows", { inputs: value() })} -
- )} + + + {(value) => ( +
+ {language.t("model.tooltip.allows", { inputs: value() })} +
+ )} +
+
{reasoning()}
+
{context()}
-
{reasoning()}
-
{context()}
) } diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 12966f6b37cc..a7d5925a078b 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/test-browser/fixtures/model-selection.ts b/packages/app/test-browser/fixtures/model-selection.ts index 9d1102bf5142..cfeb906218af 100644 --- a/packages/app/test-browser/fixtures/model-selection.ts +++ b/packages/app/test-browser/fixtures/model-selection.ts @@ -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 + let models!: ReturnType let composer: ReturnType | 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]) +}) diff --git a/packages/app/test-browser/fixtures/model-tooltip.ts b/packages/app/test-browser/fixtures/model-tooltip.ts new file mode 100644 index 000000000000..4ed6a37a9311 --- /dev/null +++ b/packages/app/test-browser/fixtures/model-tooltip.ts @@ -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() +}) diff --git a/packages/app/test-browser/model-tooltip.test.ts b/packages/app/test-browser/model-tooltip.test.ts new file mode 100644 index 000000000000..0ba40fa8037d --- /dev/null +++ b/packages/app/test-browser/model-tooltip.test.ts @@ -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) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index a37c1bc532fd..df6d0325317b 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -295,7 +295,7 @@ export const OpencodePlugin = define (Equal.equals(snapshot, next) ? Effect.void : apply(next)))), diff --git a/packages/core/src/provider.ts b/packages/core/src/provider.ts index ac46e0f7d807..9357bebf1ca4 100644 --- a/packages/core/src/provider.ts +++ b/packages/core/src/provider.ts @@ -69,6 +69,7 @@ const builtins = new Map Promise>([ ["@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")], diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 1bcb8f8990be..f0e067c34e7b 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -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("1 minute") + 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("1 minute") + 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("1 minute") + yield* drain + expect((yield* catalog.model.available()).some((model) => model.providerID === firstID)).toBe(false) + + state.advertised = false + yield* TestClock.adjust("1 minute") + 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("1 minute") + 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(() => { @@ -577,26 +784,26 @@ describe("OpencodePlugin", () => { expect(yield* websearch.default()).toBeUndefined() state.advertised = true - yield* TestClock.adjust("9 minutes") + yield* TestClock.adjust("50 seconds") yield* drain expect(state.requests).toBe(1) expect(rebuilds).toEqual(initial) expect(yield* websearch.default()).toBeUndefined() - yield* TestClock.adjust("1 minute") + yield* TestClock.adjust("10 seconds") yield* drain expect(state.requests).toBe(2) expect(rebuilds).toEqual({ catalog: initial.catalog + 1, websearch: initial.websearch + 1 }) expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" }) - yield* TestClock.adjust("10 minutes") + yield* TestClock.adjust("1 minute") yield* drain expect(state.requests).toBe(3) expect(rebuilds).toEqual({ catalog: initial.catalog + 1, websearch: initial.websearch + 1 }) expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" }) state.advertised = false - yield* TestClock.adjust("10 minutes") + yield* TestClock.adjust("1 minute") yield* drain expect(state.requests).toBe(4) expect(rebuilds).toEqual({ catalog: initial.catalog + 2, websearch: initial.websearch + 2 }) diff --git a/packages/core/test/provider.test.ts b/packages/core/test/provider.test.ts index 1dfb50edc086..a1ef2c148102 100644 --- a/packages/core/test/provider.test.ts +++ b/packages/core/test/provider.test.ts @@ -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", ] diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 2f51aa49a616..8f3ac47476af 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -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, + ]) +}) diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index a181fddd8ccc..39d7d8aa8334 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -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 } diff --git a/packages/tui/test/cli/cmd/tui/model-options.test.ts b/packages/tui/test/cli/cmd/tui/model-options.test.ts index 27d6b9b55bc6..1c34a05b7d4d 100644 --- a/packages/tui/test/cli/cmd/tui/model-options.test.ts +++ b/packages/tui/test/cli/cmd/tui/model-options.test.ts @@ -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, + ]) + }) +}) diff --git a/packages/util/src/organization-routes.ts b/packages/util/src/organization-routes.ts new file mode 100644 index 000000000000..60d5bff7d788 --- /dev/null +++ b/packages/util/src/organization-routes.ts @@ -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 +} From d66ff181864808c5931546b20e3f119c4f78b931 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 11 Sep 2026 06:29:41 -0500 Subject: [PATCH 2/2] fix(routing): retain refresh interval and localize pricing --- packages/app/src/runtime/i18n/am.ts | 1 + packages/app/src/runtime/i18n/ar.ts | 1 + packages/app/src/runtime/i18n/az.ts | 1 + packages/app/src/runtime/i18n/bg.ts | 1 + packages/app/src/runtime/i18n/bn.ts | 1 + packages/app/src/runtime/i18n/br.ts | 1 + packages/app/src/runtime/i18n/bs.ts | 1 + packages/app/src/runtime/i18n/ca.ts | 1 + packages/app/src/runtime/i18n/cs.ts | 1 + packages/app/src/runtime/i18n/da.ts | 1 + packages/app/src/runtime/i18n/de.ts | 1 + packages/app/src/runtime/i18n/dv.ts | 1 + packages/app/src/runtime/i18n/dz.ts | 1 + packages/app/src/runtime/i18n/el.ts | 1 + packages/app/src/runtime/i18n/es.ts | 1 + packages/app/src/runtime/i18n/et.ts | 1 + packages/app/src/runtime/i18n/fa.ts | 1 + packages/app/src/runtime/i18n/fi.ts | 1 + packages/app/src/runtime/i18n/fo.ts | 1 + packages/app/src/runtime/i18n/fr.ts | 1 + packages/app/src/runtime/i18n/he.ts | 1 + packages/app/src/runtime/i18n/hi.ts | 1 + packages/app/src/runtime/i18n/hr.ts | 1 + packages/app/src/runtime/i18n/hu.ts | 1 + packages/app/src/runtime/i18n/hy.ts | 1 + packages/app/src/runtime/i18n/id.ts | 1 + packages/app/src/runtime/i18n/is.ts | 1 + packages/app/src/runtime/i18n/it.ts | 1 + packages/app/src/runtime/i18n/ja.ts | 1 + packages/app/src/runtime/i18n/ka.ts | 1 + packages/app/src/runtime/i18n/km.ts | 1 + packages/app/src/runtime/i18n/ko.ts | 1 + packages/app/src/runtime/i18n/lo.ts | 1 + packages/app/src/runtime/i18n/lt.ts | 1 + packages/app/src/runtime/i18n/lv.ts | 1 + packages/app/src/runtime/i18n/mk.ts | 1 + packages/app/src/runtime/i18n/mn.ts | 1 + packages/app/src/runtime/i18n/ms.ts | 1 + packages/app/src/runtime/i18n/my.ts | 1 + packages/app/src/runtime/i18n/ne.ts | 1 + packages/app/src/runtime/i18n/nl.ts | 1 + packages/app/src/runtime/i18n/no.ts | 1 + packages/app/src/runtime/i18n/pa.ts | 1 + packages/app/src/runtime/i18n/pl.ts | 1 + packages/app/src/runtime/i18n/ro.ts | 1 + packages/app/src/runtime/i18n/ru.ts | 1 + packages/app/src/runtime/i18n/si.ts | 1 + packages/app/src/runtime/i18n/sk.ts | 1 + packages/app/src/runtime/i18n/sl.ts | 1 + packages/app/src/runtime/i18n/sq.ts | 1 + packages/app/src/runtime/i18n/sr.ts | 1 + packages/app/src/runtime/i18n/sv.ts | 1 + packages/app/src/runtime/i18n/tg.ts | 1 + packages/app/src/runtime/i18n/th.ts | 1 + packages/app/src/runtime/i18n/tk.ts | 1 + packages/app/src/runtime/i18n/tr.ts | 1 + packages/app/src/runtime/i18n/uk.ts | 1 + packages/app/src/runtime/i18n/ur.ts | 1 + packages/app/src/runtime/i18n/uz.ts | 1 + packages/app/src/runtime/i18n/vi.ts | 1 + packages/app/src/runtime/i18n/zh.ts | 1 + packages/app/src/runtime/i18n/zht.ts | 1 + packages/core/src/plugin/provider/opencode.ts | 2 +- .../core/test/plugin/provider-opencode.test.ts | 18 +++++++++--------- 64 files changed, 72 insertions(+), 10 deletions(-) diff --git a/packages/app/src/runtime/i18n/am.ts b/packages/app/src/runtime/i18n/am.ts index d1b67eaab41b..f4f40ef0925a 100644 --- a/packages/app/src/runtime/i18n/am.ts +++ b/packages/app/src/runtime/i18n/am.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/ar.ts b/packages/app/src/runtime/i18n/ar.ts index 5dc0572a840e..f6663de7ab0a 100644 --- a/packages/app/src/runtime/i18n/ar.ts +++ b/packages/app/src/runtime/i18n/ar.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/az.ts b/packages/app/src/runtime/i18n/az.ts index 79fe09efdd3c..0ff9c4cfde88 100644 --- a/packages/app/src/runtime/i18n/az.ts +++ b/packages/app/src/runtime/i18n/az.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/bg.ts b/packages/app/src/runtime/i18n/bg.ts index aa4c4c8160a5..aec95c83cb11 100644 --- a/packages/app/src/runtime/i18n/bg.ts +++ b/packages/app/src/runtime/i18n/bg.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/bn.ts b/packages/app/src/runtime/i18n/bn.ts index 17316951ed19..fd23e13985b4 100644 --- a/packages/app/src/runtime/i18n/bn.ts +++ b/packages/app/src/runtime/i18n/bn.ts @@ -284,6 +284,7 @@ export const dict: Record = { "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", diff --git a/packages/app/src/runtime/i18n/br.ts b/packages/app/src/runtime/i18n/br.ts index d25f469a8c64..054a5f499f2a 100644 --- a/packages/app/src/runtime/i18n/br.ts +++ b/packages/app/src/runtime/i18n/br.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/bs.ts b/packages/app/src/runtime/i18n/bs.ts index dc3ee600a47f..c32a22076377 100644 --- a/packages/app/src/runtime/i18n/bs.ts +++ b/packages/app/src/runtime/i18n/bs.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/ca.ts b/packages/app/src/runtime/i18n/ca.ts index 2a12ca3e945e..7791c376927f 100644 --- a/packages/app/src/runtime/i18n/ca.ts +++ b/packages/app/src/runtime/i18n/ca.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/cs.ts b/packages/app/src/runtime/i18n/cs.ts index 3adc097cbe36..7a2d172a2f60 100644 --- a/packages/app/src/runtime/i18n/cs.ts +++ b/packages/app/src/runtime/i18n/cs.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/da.ts b/packages/app/src/runtime/i18n/da.ts index 544144a577d7..66db3add2d1c 100644 --- a/packages/app/src/runtime/i18n/da.ts +++ b/packages/app/src/runtime/i18n/da.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/de.ts b/packages/app/src/runtime/i18n/de.ts index e93b9e388c4c..5737cbe7e150 100644 --- a/packages/app/src/runtime/i18n/de.ts +++ b/packages/app/src/runtime/i18n/de.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/dv.ts b/packages/app/src/runtime/i18n/dv.ts index 462f03e174c2..199e746548b4 100644 --- a/packages/app/src/runtime/i18n/dv.ts +++ b/packages/app/src/runtime/i18n/dv.ts @@ -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 އެވެ", diff --git a/packages/app/src/runtime/i18n/dz.ts b/packages/app/src/runtime/i18n/dz.ts index 1748dfbe8efa..489c4276e7e6 100644 --- a/packages/app/src/runtime/i18n/dz.ts +++ b/packages/app/src/runtime/i18n/dz.ts @@ -288,6 +288,7 @@ export const dict: Record = { "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", diff --git a/packages/app/src/runtime/i18n/el.ts b/packages/app/src/runtime/i18n/el.ts index 466ac81b941a..115785b384ca 100644 --- a/packages/app/src/runtime/i18n/el.ts +++ b/packages/app/src/runtime/i18n/el.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/es.ts b/packages/app/src/runtime/i18n/es.ts index 874788a0828a..85b9c10ca5d3 100644 --- a/packages/app/src/runtime/i18n/es.ts +++ b/packages/app/src/runtime/i18n/es.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/et.ts b/packages/app/src/runtime/i18n/et.ts index 12512be0cbae..79d723d25683 100644 --- a/packages/app/src/runtime/i18n/et.ts +++ b/packages/app/src/runtime/i18n/et.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/fa.ts b/packages/app/src/runtime/i18n/fa.ts index dfe777f91fbe..b751e8223f5a 100644 --- a/packages/app/src/runtime/i18n/fa.ts +++ b/packages/app/src/runtime/i18n/fa.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/fi.ts b/packages/app/src/runtime/i18n/fi.ts index 108391663410..7ba32d8bdf7c 100644 --- a/packages/app/src/runtime/i18n/fi.ts +++ b/packages/app/src/runtime/i18n/fi.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/fo.ts b/packages/app/src/runtime/i18n/fo.ts index 40a8bfebd108..b88c44781ece 100644 --- a/packages/app/src/runtime/i18n/fo.ts +++ b/packages/app/src/runtime/i18n/fo.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/fr.ts b/packages/app/src/runtime/i18n/fr.ts index 3487df519b10..3771716354ec 100644 --- a/packages/app/src/runtime/i18n/fr.ts +++ b/packages/app/src/runtime/i18n/fr.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/he.ts b/packages/app/src/runtime/i18n/he.ts index ef40fdb8b147..68373035fd08 100644 --- a/packages/app/src/runtime/i18n/he.ts +++ b/packages/app/src/runtime/i18n/he.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/hi.ts b/packages/app/src/runtime/i18n/hi.ts index 4e9f5e60ae64..aa26746fe48d 100644 --- a/packages/app/src/runtime/i18n/hi.ts +++ b/packages/app/src/runtime/i18n/hi.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/hr.ts b/packages/app/src/runtime/i18n/hr.ts index 8ffb9712d520..539007bb17c4 100644 --- a/packages/app/src/runtime/i18n/hr.ts +++ b/packages/app/src/runtime/i18n/hr.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/hu.ts b/packages/app/src/runtime/i18n/hu.ts index b641db2e2d3d..1a06c247339d 100644 --- a/packages/app/src/runtime/i18n/hu.ts +++ b/packages/app/src/runtime/i18n/hu.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/hy.ts b/packages/app/src/runtime/i18n/hy.ts index ef06d344b1bc..6a706a385959 100644 --- a/packages/app/src/runtime/i18n/hy.ts +++ b/packages/app/src/runtime/i18n/hy.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/id.ts b/packages/app/src/runtime/i18n/id.ts index 3bc7936a64b6..60e439c5652f 100644 --- a/packages/app/src/runtime/i18n/id.ts +++ b/packages/app/src/runtime/i18n/id.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/is.ts b/packages/app/src/runtime/i18n/is.ts index 5e6d5feaf50e..065b36b2fba3 100644 --- a/packages/app/src/runtime/i18n/is.ts +++ b/packages/app/src/runtime/i18n/is.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/it.ts b/packages/app/src/runtime/i18n/it.ts index 30ccf5f35a17..ba0928d6c2d5 100644 --- a/packages/app/src/runtime/i18n/it.ts +++ b/packages/app/src/runtime/i18n/it.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/ja.ts b/packages/app/src/runtime/i18n/ja.ts index 1e8b1c2767ce..22bc7a304d78 100644 --- a/packages/app/src/runtime/i18n/ja.ts +++ b/packages/app/src/runtime/i18n/ja.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/ka.ts b/packages/app/src/runtime/i18n/ka.ts index ce9de389f4cc..e25c963a98b3 100644 --- a/packages/app/src/runtime/i18n/ka.ts +++ b/packages/app/src/runtime/i18n/ka.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/km.ts b/packages/app/src/runtime/i18n/km.ts index 408069c04d38..f78cc1cfef8e 100644 --- a/packages/app/src/runtime/i18n/km.ts +++ b/packages/app/src/runtime/i18n/km.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/ko.ts b/packages/app/src/runtime/i18n/ko.ts index 3cafc4bc5b32..b51d5d5b3bf7 100644 --- a/packages/app/src/runtime/i18n/ko.ts +++ b/packages/app/src/runtime/i18n/ko.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/lo.ts b/packages/app/src/runtime/i18n/lo.ts index 0144da1e1b9c..9869d16c18be 100644 --- a/packages/app/src/runtime/i18n/lo.ts +++ b/packages/app/src/runtime/i18n/lo.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/lt.ts b/packages/app/src/runtime/i18n/lt.ts index a9741a2668ee..ea42b617f09f 100644 --- a/packages/app/src/runtime/i18n/lt.ts +++ b/packages/app/src/runtime/i18n/lt.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/lv.ts b/packages/app/src/runtime/i18n/lv.ts index 588a7249ccdd..6d71a2216b6a 100644 --- a/packages/app/src/runtime/i18n/lv.ts +++ b/packages/app/src/runtime/i18n/lv.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/mk.ts b/packages/app/src/runtime/i18n/mk.ts index 8e4a484cbad3..d648c925842a 100644 --- a/packages/app/src/runtime/i18n/mk.ts +++ b/packages/app/src/runtime/i18n/mk.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/mn.ts b/packages/app/src/runtime/i18n/mn.ts index eaf64a410075..d1a62f522ff9 100644 --- a/packages/app/src/runtime/i18n/mn.ts +++ b/packages/app/src/runtime/i18n/mn.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/ms.ts b/packages/app/src/runtime/i18n/ms.ts index 024819a41506..ec65e20546d6 100644 --- a/packages/app/src/runtime/i18n/ms.ts +++ b/packages/app/src/runtime/i18n/ms.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/my.ts b/packages/app/src/runtime/i18n/my.ts index 7ea2d45aa7ab..1879838adcdb 100644 --- a/packages/app/src/runtime/i18n/my.ts +++ b/packages/app/src/runtime/i18n/my.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/ne.ts b/packages/app/src/runtime/i18n/ne.ts index 122c015f23fc..4bea8d5adadb 100644 --- a/packages/app/src/runtime/i18n/ne.ts +++ b/packages/app/src/runtime/i18n/ne.ts @@ -285,6 +285,7 @@ export const dict: Record = { "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", diff --git a/packages/app/src/runtime/i18n/nl.ts b/packages/app/src/runtime/i18n/nl.ts index 16a243c56531..95ab4b9d6adb 100644 --- a/packages/app/src/runtime/i18n/nl.ts +++ b/packages/app/src/runtime/i18n/nl.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/no.ts b/packages/app/src/runtime/i18n/no.ts index 83776c638fc2..77d902722fce 100644 --- a/packages/app/src/runtime/i18n/no.ts +++ b/packages/app/src/runtime/i18n/no.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/pa.ts b/packages/app/src/runtime/i18n/pa.ts index 8e1e9eddfbda..24cae9f150eb 100644 --- a/packages/app/src/runtime/i18n/pa.ts +++ b/packages/app/src/runtime/i18n/pa.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/pl.ts b/packages/app/src/runtime/i18n/pl.ts index 62e6ae619479..5d3ee57f5051 100644 --- a/packages/app/src/runtime/i18n/pl.ts +++ b/packages/app/src/runtime/i18n/pl.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/ro.ts b/packages/app/src/runtime/i18n/ro.ts index 64b21287d870..0fb6a6de1ee4 100644 --- a/packages/app/src/runtime/i18n/ro.ts +++ b/packages/app/src/runtime/i18n/ro.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/ru.ts b/packages/app/src/runtime/i18n/ru.ts index a667bc126cd3..65b116419c7a 100644 --- a/packages/app/src/runtime/i18n/ru.ts +++ b/packages/app/src/runtime/i18n/ru.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/si.ts b/packages/app/src/runtime/i18n/si.ts index aba8428b4d4a..c65873eea2f3 100644 --- a/packages/app/src/runtime/i18n/si.ts +++ b/packages/app/src/runtime/i18n/si.ts @@ -283,6 +283,7 @@ export const dict: Record = { "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", diff --git a/packages/app/src/runtime/i18n/sk.ts b/packages/app/src/runtime/i18n/sk.ts index 601e47e2911c..c4b682337097 100644 --- a/packages/app/src/runtime/i18n/sk.ts +++ b/packages/app/src/runtime/i18n/sk.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/sl.ts b/packages/app/src/runtime/i18n/sl.ts index 1977f19ba744..890d07b4cd89 100644 --- a/packages/app/src/runtime/i18n/sl.ts +++ b/packages/app/src/runtime/i18n/sl.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/sq.ts b/packages/app/src/runtime/i18n/sq.ts index 7ffa5871dd7e..d08d1a64ebd3 100644 --- a/packages/app/src/runtime/i18n/sq.ts +++ b/packages/app/src/runtime/i18n/sq.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/sr.ts b/packages/app/src/runtime/i18n/sr.ts index 89ad4bc48b95..7c6e2891cf9e 100644 --- a/packages/app/src/runtime/i18n/sr.ts +++ b/packages/app/src/runtime/i18n/sr.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/sv.ts b/packages/app/src/runtime/i18n/sv.ts index b2ad5bafda8b..20c15a3e9d3d 100644 --- a/packages/app/src/runtime/i18n/sv.ts +++ b/packages/app/src/runtime/i18n/sv.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/tg.ts b/packages/app/src/runtime/i18n/tg.ts index 679d56aecd0a..f6de0be7b58c 100644 --- a/packages/app/src/runtime/i18n/tg.ts +++ b/packages/app/src/runtime/i18n/tg.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/th.ts b/packages/app/src/runtime/i18n/th.ts index c585c50027eb..d4840e570a2f 100644 --- a/packages/app/src/runtime/i18n/th.ts +++ b/packages/app/src/runtime/i18n/th.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/tk.ts b/packages/app/src/runtime/i18n/tk.ts index 0a24412b852b..d3d5285a1504 100644 --- a/packages/app/src/runtime/i18n/tk.ts +++ b/packages/app/src/runtime/i18n/tk.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/tr.ts b/packages/app/src/runtime/i18n/tr.ts index 64266970a1dd..5a3eccd12922 100644 --- a/packages/app/src/runtime/i18n/tr.ts +++ b/packages/app/src/runtime/i18n/tr.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/uk.ts b/packages/app/src/runtime/i18n/uk.ts index 99ba7a64e5be..b6c29d27e023 100644 --- a/packages/app/src/runtime/i18n/uk.ts +++ b/packages/app/src/runtime/i18n/uk.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/ur.ts b/packages/app/src/runtime/i18n/ur.ts index 3cdf0c942b44..6c252995178e 100644 --- a/packages/app/src/runtime/i18n/ur.ts +++ b/packages/app/src/runtime/i18n/ur.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/uz.ts b/packages/app/src/runtime/i18n/uz.ts index ad101eac4a81..abe2fc7a6cc8 100644 --- a/packages/app/src/runtime/i18n/uz.ts +++ b/packages/app/src/runtime/i18n/uz.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/vi.ts b/packages/app/src/runtime/i18n/vi.ts index 221843e1e291..4019b9017bf5 100644 --- a/packages/app/src/runtime/i18n/vi.ts +++ b/packages/app/src/runtime/i18n/vi.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/zh.ts b/packages/app/src/runtime/i18n/zh.ts index e646d0e2a222..d419f5f31944 100644 --- a/packages/app/src/runtime/i18n/zh.ts +++ b/packages/app/src/runtime/i18n/zh.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/zht.ts b/packages/app/src/runtime/i18n/zht.ts index 40cb629670b1..d4bd8c8c77a7 100644 --- a/packages/app/src/runtime/i18n/zht.ts +++ b/packages/app/src/runtime/i18n/zht.ts @@ -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", diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index df6d0325317b..a37c1bc532fd 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -295,7 +295,7 @@ export const OpencodePlugin = define (Equal.equals(snapshot, next) ? Effect.void : apply(next)))), diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index f0e067c34e7b..b7ceb4bd2faf 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -626,7 +626,7 @@ describe("OpencodePlugin", () => { expect(yield* catalog.provider.get(firstID)).toBeUndefined() state.advertised = true - yield* TestClock.adjust("1 minute") + yield* TestClock.adjust("10 minutes") yield* drain expect(yield* catalog.provider.get(firstID)).toMatchObject({ id: firstID, @@ -666,7 +666,7 @@ describe("OpencodePlugin", () => { state.alias = "coding-renamed" state.name = "Renamed route" - yield* TestClock.adjust("1 minute") + yield* TestClock.adjust("10 minutes") yield* drain expect(yield* catalog.model.get(firstID, routeID)).toMatchObject({ id: "route_1", @@ -676,19 +676,19 @@ describe("OpencodePlugin", () => { expect(yield* catalog.model.get(firstID, Model.ID.make("coding"))).toBeUndefined() state.disabled = true - yield* TestClock.adjust("1 minute") + 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("1 minute") + 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("1 minute") + yield* TestClock.adjust("10 minutes") yield* drain expect(yield* catalog.model.get(firstID, routeID)).toBeDefined() const requests = state.requests @@ -784,26 +784,26 @@ describe("OpencodePlugin", () => { expect(yield* websearch.default()).toBeUndefined() state.advertised = true - yield* TestClock.adjust("50 seconds") + yield* TestClock.adjust("9 minutes") yield* drain expect(state.requests).toBe(1) expect(rebuilds).toEqual(initial) expect(yield* websearch.default()).toBeUndefined() - yield* TestClock.adjust("10 seconds") + yield* TestClock.adjust("1 minute") yield* drain expect(state.requests).toBe(2) expect(rebuilds).toEqual({ catalog: initial.catalog + 1, websearch: initial.websearch + 1 }) expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" }) - yield* TestClock.adjust("1 minute") + yield* TestClock.adjust("10 minutes") yield* drain expect(state.requests).toBe(3) expect(rebuilds).toEqual({ catalog: initial.catalog + 1, websearch: initial.websearch + 1 }) expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" }) state.advertised = false - yield* TestClock.adjust("1 minute") + yield* TestClock.adjust("10 minutes") yield* drain expect(state.requests).toBe(4) expect(rebuilds).toEqual({ catalog: initial.catalog + 2, websearch: initial.websearch + 2 })