mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-11 11:26:24 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c52e9cf6c | ||
|
|
b844971cf4 | ||
|
|
79041059c2 | ||
|
|
eb82c26b75 |
@@ -86,7 +86,6 @@ export const OpenResponsesReasoningItem = Schema.Struct({
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
summary: Schema.Array(OpenResponsesReasoningSummaryText),
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
provider_metadata: Schema.optional(JsonObject),
|
||||
})
|
||||
|
||||
const OpenResponsesWebSearchCall = Schema.StructWithRest(
|
||||
@@ -183,7 +182,6 @@ export const InputItem = Schema.Union([
|
||||
content: Schema.Array(OpenResponsesOutputText),
|
||||
phase: Schema.optionalKey(MessagePhase),
|
||||
status: Schema.optional(Schema.String),
|
||||
provider_metadata: Schema.optional(JsonObject),
|
||||
}),
|
||||
OpenResponsesReasoningItem,
|
||||
Schema.Struct({
|
||||
@@ -193,7 +191,6 @@ export const InputItem = Schema.Union([
|
||||
name: Schema.String,
|
||||
namespace: Schema.optional(Schema.String),
|
||||
arguments: Schema.String,
|
||||
provider_metadata: Schema.optional(JsonObject),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("function_call_output"),
|
||||
@@ -226,7 +223,6 @@ type OpenResponsesReasoningInput = {
|
||||
id?: string
|
||||
summary: Array<{ type: "summary_text"; text: string }>
|
||||
encrypted_content?: string | null
|
||||
provider_metadata?: Record<string, unknown>
|
||||
}
|
||||
export const Tool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
@@ -440,8 +436,6 @@ export const decodeChannelEvent = (frame: string) =>
|
||||
export interface ProviderAdapter {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
/** Replay opaque gateway continuation state only for adapters that own this extension. */
|
||||
readonly preserveProviderMetadata?: boolean
|
||||
readonly nativeTool?: (
|
||||
native: NonNullable<ToolDefinition["native"]>,
|
||||
) => Effect.Effect<{ readonly type: string }, AIError>
|
||||
@@ -461,7 +455,6 @@ export interface ParserState {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly providerMetadataKey: string
|
||||
readonly preserveProviderMetadata: boolean
|
||||
readonly tools: ToolStream.State<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
@@ -521,16 +514,7 @@ const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadata
|
||||
return separator > 0 && separator < metadata.itemId.length - 1 ? metadata.itemId : undefined
|
||||
}
|
||||
|
||||
const replayProviderMetadata = (metadata: ProviderMetadata | undefined, key: string, adapter: ProviderAdapter) => {
|
||||
const value = metadata?.[key]?.providerMetadata
|
||||
return adapter.preserveProviderMetadata && ProviderShared.isRecord(value) ? { provider_metadata: value } : {}
|
||||
}
|
||||
|
||||
const lowerToolCall = (
|
||||
part: ToolCallPart,
|
||||
providerMetadataKey: string,
|
||||
adapter: ProviderAdapter,
|
||||
): OpenResponsesInputItem => {
|
||||
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
return {
|
||||
type: "function_call",
|
||||
@@ -539,15 +523,10 @@ const lowerToolCall = (
|
||||
name: part.name,
|
||||
namespace: part.namespace,
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
...replayProviderMetadata(part.providerMetadata, providerMetadataKey, adapter),
|
||||
}
|
||||
}
|
||||
|
||||
const lowerReasoning = (
|
||||
part: ReasoningPart,
|
||||
providerMetadataKey: string,
|
||||
adapter: ProviderAdapter,
|
||||
): OpenResponsesReasoningInput | undefined => {
|
||||
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
if (!ProviderShared.isRecord(metadata)) return undefined
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
@@ -560,7 +539,6 @@ const lowerReasoning = (
|
||||
...(id === undefined ? {} : { id }),
|
||||
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||
encrypted_content: encryptedContent,
|
||||
...replayProviderMetadata(part.providerMetadata, providerMetadataKey, adapter),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -709,7 +687,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
status: "completed",
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
...replayProviderMetadata(group.parts.at(-1)?.providerMetadata, providerMetadataKey, adapter),
|
||||
})),
|
||||
)
|
||||
content.splice(0, content.length)
|
||||
@@ -730,14 +707,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
flushText()
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey, adapter)
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey)
|
||||
if (!reasoning) continue
|
||||
const existing = reasoning.id === undefined ? undefined : reasoningItems[reasoning.id]
|
||||
if (existing) {
|
||||
existing.summary.push(...reasoning.summary)
|
||||
if (typeof reasoning.encrypted_content === "string")
|
||||
existing.encrypted_content = reasoning.encrypted_content
|
||||
if (reasoning.provider_metadata !== undefined) existing.provider_metadata = reasoning.provider_metadata
|
||||
continue
|
||||
}
|
||||
if (reasoning.id !== undefined) reasoningItems[reasoning.id] = reasoning
|
||||
@@ -747,7 +723,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
if (part.type === "tool-call") {
|
||||
flushText()
|
||||
if (part.providerExecuted === true) continue
|
||||
input.push(lowerToolCall(part, providerMetadataKey, adapter))
|
||||
input.push(lowerToolCall(part, providerMetadataKey))
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
@@ -1086,17 +1062,8 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
|
||||
return onReasoningDelta(state, { ...event, delta: event.text }, itemID)
|
||||
}
|
||||
|
||||
const outputMetadata = (state: ParserState, item: OutputItem, extra?: Record<string, unknown>) =>
|
||||
providerMetadata(state, {
|
||||
itemId: item.id,
|
||||
...extra,
|
||||
...(state.preserveProviderMetadata && ProviderShared.isRecord(item.provider_metadata)
|
||||
? { providerMetadata: item.provider_metadata }
|
||||
: {}),
|
||||
})
|
||||
|
||||
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
|
||||
outputMetadata(state, item, { reasoningEncryptedContent: item.encrypted_content ?? null })
|
||||
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
|
||||
|
||||
// Responses APIs normally stream reasoning items in this order:
|
||||
// `output_item.added` (reasoning) →
|
||||
@@ -1159,7 +1126,7 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
|
||||
}
|
||||
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
|
||||
if (state.tools[item.id] !== undefined) return [state, NO_EVENTS]
|
||||
const metadata = outputMetadata(state, item)
|
||||
const metadata = providerMetadata(state, { itemId: item.id })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
return [
|
||||
@@ -1282,7 +1249,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
content.push(decoded.type === "output_text" ? decoded.text : decoded.refusal)
|
||||
}
|
||||
const text = content.length > 0 ? content.join("") : undefined
|
||||
const metadata = outputMetadata(state, item, phase === undefined ? undefined : { phase })
|
||||
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
|
||||
return [
|
||||
@@ -1297,11 +1264,10 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
|
||||
if (item.type === "function_call") {
|
||||
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = outputMetadata(state, item)
|
||||
const pending = state.tools[item.id]
|
||||
const registered = pending !== undefined
|
||||
const tools = pending
|
||||
? ToolStream.start(state.tools, item.id, { ...pending, providerMetadata: metadata })
|
||||
const metadata = providerMetadata(state, { itemId: item.id })
|
||||
const registered = state.tools[item.id] !== undefined
|
||||
const tools = registered
|
||||
? state.tools
|
||||
: ToolStream.start(state.tools, item.id, {
|
||||
id: item.call_id,
|
||||
name: item.name,
|
||||
@@ -1555,7 +1521,6 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
|
||||
id: adapter.id,
|
||||
name: adapter.name,
|
||||
providerMetadataKey: metadataKey(request.model),
|
||||
preserveProviderMetadata: adapter.preserveProviderMetadata ?? false,
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import type { LLMRequest } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
|
||||
const ADAPTER = "organization-routes"
|
||||
|
||||
const adapter = {
|
||||
id: ADAPTER,
|
||||
name: "Organization routes",
|
||||
preserveProviderMetadata: true,
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
const Body = Schema.Struct({
|
||||
...OpenResponses.coreFields,
|
||||
store: Schema.Literal(false),
|
||||
provider_options: Schema.Struct({
|
||||
"openai-responses": Schema.Struct({ include: Schema.Array(Schema.String) }),
|
||||
}),
|
||||
stream: Schema.Literal(true),
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("OrganizationRoutes.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* OpenResponses.fromRequestWithAdapter(request, adapter)
|
||||
// A route can select another provider on each call. Keep full history and only
|
||||
// portable options; native caching and stored IDs would exclude translated
|
||||
// targets. Scope encrypted reasoning to native Responses targets so their
|
||||
// stateless continuations remain replayable without blocking other protocols.
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))({
|
||||
model: body.model,
|
||||
input: body.input,
|
||||
instructions: body.instructions,
|
||||
tools: body.tools,
|
||||
tool_choice: body.tool_choice,
|
||||
stream: true as const,
|
||||
store: false as const,
|
||||
provider_options: { "openai-responses": { include: ["reasoning.encrypted_content"] } },
|
||||
max_output_tokens:
|
||||
body.max_output_tokens !== undefined && body.max_output_tokens > 0 ? body.max_output_tokens : undefined,
|
||||
temperature: body.temperature,
|
||||
top_p: body.top_p,
|
||||
parallel_tool_calls: body.parallel_tool_calls,
|
||||
metadata: body.metadata,
|
||||
reasoning: body.reasoning?.effort === undefined ? undefined : { effort: body.reasoning.effort },
|
||||
text: body.text,
|
||||
})
|
||||
})
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
...OpenResponses.protocol,
|
||||
id: ADAPTER,
|
||||
body: { schema: Body, from: fromRequest },
|
||||
stream: {
|
||||
...OpenResponses.protocol.stream,
|
||||
initial: (request: LLMRequest) => OpenResponses.initial(request, adapter),
|
||||
},
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
providerMetadataKey: ADAPTER,
|
||||
protocol,
|
||||
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||
transport: HttpTransport.sseJson.with<typeof Body.Type>(),
|
||||
defaults: { providerOptions: { store: false, include: [] } },
|
||||
})
|
||||
|
||||
export * as OrganizationRoutes from "./organization-routes.js"
|
||||
@@ -6,7 +6,7 @@ import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderConfigurationError, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
|
||||
|
||||
export const id = ProviderID.make("alibaba")
|
||||
|
||||
@@ -82,13 +82,8 @@ export const configure = (input: Config) => {
|
||||
? hosts.get(region)
|
||||
: `${workspaceID}.${region}.maas.aliyuncs.com`
|
||||
if (baseURL === undefined) {
|
||||
if (region === undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Alibaba requires region or baseURL" })
|
||||
if (host === undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: `Alibaba region ${region} requires workspaceID or baseURL`,
|
||||
})
|
||||
if (region === undefined) throw new Error("Alibaba requires region or baseURL")
|
||||
if (host === undefined) throw new Error(`Alibaba region ${region} requires workspaceID or baseURL`)
|
||||
}
|
||||
const opts = { ...rest, auth: AuthOptions.bearer(input, ["DASHSCOPE_API_KEY", "ALIBABA_API_KEY"]) }
|
||||
const common = { ...opts, endpoint: { baseURL: baseURL ?? `https://${host}/compatible-mode/v1` } }
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
@@ -79,12 +79,9 @@ const defaults = (input: Config) => {
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock Mantle bearer auth requires apiKey" })
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
if (input.auth === "sigv4" && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Amazon Bedrock Mantle SigV4 auth does not accept apiKey",
|
||||
})
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||
const modelDefaults = defaults(input)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as BedrockConverse from "../protocols/bedrock-converse.js"
|
||||
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
|
||||
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
|
||||
@@ -39,9 +39,8 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
|
||||
const configuredRoute = (input: Config) => {
|
||||
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
|
||||
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock bearer auth requires apiKey" })
|
||||
if (auth === "sigv4" && apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock SigV4 auth does not accept apiKey" })
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
const resolvedRegion = BedrockAuth.resolveRegion(input)
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
|
||||
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
|
||||
@@ -36,12 +36,8 @@ const auth = (input: ProviderAuthOption<"optional">) => {
|
||||
}
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
if (!input.baseURL) throw new Error("Anthropic-compatible providers require a baseURL")
|
||||
const provider = input.provider ?? "anthropic-compatible"
|
||||
if (!input.baseURL)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: ProviderID.make(provider),
|
||||
message: "Anthropic-compatible providers require a baseURL",
|
||||
})
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
||||
const route = AnthropicMessages.route.with({
|
||||
...rest,
|
||||
@@ -65,13 +61,8 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
// Read before the exclusivity check narrows a conflicting settings object to `never`.
|
||||
const provider = ProviderID.make(settings.provider ?? id)
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider,
|
||||
message: "Anthropic-compatible apiKey cannot be combined with authToken",
|
||||
})
|
||||
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { AnthropicCompatible } from "./anthropic-compatible.js"
|
||||
|
||||
@@ -57,10 +57,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Anthropic apiKey cannot be combined with authToken",
|
||||
})
|
||||
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
@@ -163,7 +163,7 @@ const config = (settings: Settings): Config => {
|
||||
}
|
||||
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
|
||||
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Azure requires resourceName or baseURL" })
|
||||
throw new Error("Azure requires resourceName or baseURL")
|
||||
}
|
||||
|
||||
export const responsesModel: ProviderPackage.Definition<
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-ai-gateway")
|
||||
@@ -35,11 +35,7 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
export const baseURL = (input: GatewayURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
|
||||
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-workers-ai")
|
||||
@@ -28,11 +28,7 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
export const baseURL = (input: WorkersAIURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareWorkersAI.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
@@ -37,8 +37,7 @@ const route = Route.make({
|
||||
export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
|
||||
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -75,8 +74,7 @@ export const provider = {
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
|
||||
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||
@@ -67,7 +67,7 @@ export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
|
||||
throw new Error("Google Vertex Messages does not support API keys")
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -107,8 +107,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
|
||||
|
||||
@@ -39,7 +39,7 @@ export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
|
||||
throw new Error("Google Vertex Responses does not support API keys")
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -79,8 +79,7 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
if (settings.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import type { AnyAuthClient } from "google-auth-library"
|
||||
import { Effect, Redacted } from "effect"
|
||||
import { Auth, MissingCredentialError } from "../route/auth.js"
|
||||
import { ProviderConfigurationError, ProviderID } from "../schema/index.js"
|
||||
|
||||
const SCOPE = "https://www.googleapis.com/auth/cloud-platform"
|
||||
const id = ProviderID.make("google-vertex")
|
||||
|
||||
export type OAuthOptions =
|
||||
| { readonly accessToken?: string; readonly auth?: never }
|
||||
@@ -37,18 +35,12 @@ export const host = (location: string) => {
|
||||
|
||||
export const requireProject = (value: string | undefined) => {
|
||||
if (value) return value
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex requires a project when baseURL is not configured",
|
||||
})
|
||||
throw new Error("Google Vertex requires a project when baseURL is not configured")
|
||||
}
|
||||
|
||||
export const apiKey = (input: ApiKeyOptions) => {
|
||||
if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined))
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
|
||||
})
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
if (input.accessToken !== undefined || input.auth !== undefined) return undefined
|
||||
return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY
|
||||
}
|
||||
@@ -76,10 +68,7 @@ const adc = (project?: string) => {
|
||||
|
||||
export const oauth = (input: OAuthOptions, project?: string) => {
|
||||
if (input.accessToken !== undefined && input.auth !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex accessToken cannot be combined with auth",
|
||||
})
|
||||
throw new Error("Google Vertex accessToken cannot be combined with auth")
|
||||
if (input.auth) return input.auth
|
||||
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
|
||||
return adc(project)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderConfigurationError, ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
|
||||
export interface GeminiOptionsInput extends Gemini.OptionsInput {
|
||||
@@ -93,10 +93,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
||||
const apiKey = GoogleVertexShared.apiKey(input)
|
||||
const endpointModel = String(modelID).startsWith("endpoints/")
|
||||
if (apiKey !== undefined && endpointModel)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex tuned models do not support Express Mode API keys",
|
||||
})
|
||||
throw new Error("Google Vertex tuned models do not support Express Mode API keys")
|
||||
const location = GoogleVertexShared.location(inputLocation, "us-central1")
|
||||
const project = GoogleVertexShared.project(inputProject)
|
||||
const endpoint =
|
||||
@@ -126,10 +123,7 @@ export const provider = {
|
||||
}
|
||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
|
||||
})
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
return configure({
|
||||
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OrganizationRoutes } from "../protocols/organization-routes.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
|
||||
|
||||
export type Options = Pick<
|
||||
OpenResponsesProviderOptionsInput,
|
||||
"reasoningEffort" | "textVerbosity" | "parallelToolCalls" | "metadata" | "allowedTools"
|
||||
>
|
||||
|
||||
export const id = ProviderID.make("organization-routes")
|
||||
|
||||
export type Config = RouteDefaultsInput &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly provider?: string
|
||||
readonly baseURL: string
|
||||
readonly providerOptions?: Options
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
readonly providerOptions?: Options
|
||||
}
|
||||
|
||||
export const routes = [OrganizationRoutes.route]
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
const provider = input.provider ?? "organization-routes"
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
||||
const route = OrganizationRoutes.route.with({
|
||||
...rest,
|
||||
provider,
|
||||
endpoint: { baseURL },
|
||||
auth: AuthOptions.bearer(input, []),
|
||||
})
|
||||
return {
|
||||
id: ProviderID.make(provider),
|
||||
model: (modelID: string | ModelID) => route.model<Options>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = { id, configure }
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, Options>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
LanguageModel,
|
||||
LLMEvent,
|
||||
InvalidProviderOutputError,
|
||||
ProviderConfigurationError,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
mergeHttpOptions,
|
||||
@@ -129,10 +128,7 @@ const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends
|
||||
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
||||
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
||||
if (!endpointBaseURL(route.endpoint))
|
||||
throw new ProviderConfigurationError({
|
||||
provider: ProviderID.make(provider),
|
||||
message: `Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`,
|
||||
})
|
||||
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
|
||||
return LanguageModel.make<Options, Compact>({
|
||||
...mapped,
|
||||
provider,
|
||||
|
||||
@@ -50,19 +50,6 @@ export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOpe
|
||||
route: Schema.optional(RouteID),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Provider settings that are missing, conflicting, or unsupported, such as
|
||||
* Azure without `resourceName` or `baseURL`. Thrown synchronously while a
|
||||
* provider facade or package entrypoint configures a model, before any
|
||||
* request exists, so it is not an `AIError` reason.
|
||||
*/
|
||||
export class ProviderConfigurationError extends Schema.TaggedError<ProviderConfigurationError>(
|
||||
"AI.Error.ProviderConfiguration",
|
||||
)("ProviderConfiguration", {
|
||||
provider: ProviderID,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
|
||||
...ReasonFields,
|
||||
route: RouteID,
|
||||
|
||||
@@ -3,9 +3,6 @@ import { model } from "@opencode/ai/providers/openai"
|
||||
import { LLM } from "../src/index.js"
|
||||
import { Endpoint } from "../src/route/endpoint.js"
|
||||
|
||||
const configuration = (provider: string, message: string) =>
|
||||
expect.objectContaining({ _tag: "ProviderConfiguration", provider, message })
|
||||
|
||||
describe("provider package entrypoints", () => {
|
||||
test("semantic API aliases expose the same contract", async () => {
|
||||
const modules = await Promise.all([
|
||||
@@ -325,7 +322,7 @@ describe("provider package entrypoints", () => {
|
||||
const AnthropicCompatible = await import("@opencode/ai/providers/anthropic-compatible")
|
||||
expect(() =>
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
|
||||
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible providers require a baseURL"))
|
||||
).toThrow("Anthropic-compatible providers require a baseURL")
|
||||
})
|
||||
|
||||
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
|
||||
@@ -340,10 +337,10 @@ describe("provider package entrypoints", () => {
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
},
|
||||
]),
|
||||
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible apiKey cannot be combined with authToken"))
|
||||
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
expect(() =>
|
||||
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
|
||||
).toThrow(configuration("anthropic", "Anthropic apiKey cannot be combined with authToken"))
|
||||
).toThrow("Anthropic apiKey cannot be combined with authToken")
|
||||
})
|
||||
|
||||
test("maps legacy OpenAI organization and project settings to headers", () => {
|
||||
@@ -493,45 +490,43 @@ describe("provider package entrypoints", () => {
|
||||
"gemini-3.5-flash",
|
||||
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex apiKey cannot be combined with accessToken or auth"))
|
||||
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
|
||||
{ accessToken: "token", auth: {}, project: "vertex-project" },
|
||||
])
|
||||
expect(() => configured.model("gemini-3.5-flash")).toThrow(
|
||||
configuration("google-vertex", "Google Vertex accessToken cannot be combined with auth"),
|
||||
)
|
||||
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexMessages.model, undefined, [
|
||||
"claude-sonnet-4-6",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexChat.model, undefined, [
|
||||
"deepseek-ai/deepseek-v3.2-maas",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexResponses.model, undefined, [
|
||||
"xai/grok-4.20-reasoning",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,13 +76,7 @@ it.effect("Alibaba owns regional shared and workspace-specific endpoints", () =>
|
||||
|
||||
test("Alibaba requires explicit placement and supports complete base URL overrides", () => {
|
||||
for (const region of ["eu-central-1", "ap-northeast-1", "future-region"])
|
||||
expect(() => Alibaba.configure({ region })).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "alibaba",
|
||||
message: `Alibaba region ${region} requires workspaceID or baseURL`,
|
||||
}),
|
||||
)
|
||||
expect(() => Alibaba.configure({ region })).toThrow("requires workspaceID or baseURL")
|
||||
for (const config of [
|
||||
{ baseURL: "https://gateway.example/prefix" },
|
||||
{ region: "future-region", workspaceID: "ignored", baseURL: "https://gateway.example/prefix" },
|
||||
|
||||
@@ -1458,13 +1458,7 @@ describe("Bedrock Converse route", () => {
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
|
||||
}
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "amazon-bedrock",
|
||||
message: "Amazon Bedrock SigV4 auth does not accept apiKey",
|
||||
}),
|
||||
)
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
|
||||
@@ -378,11 +378,7 @@ describe("Google Vertex providers", () => {
|
||||
|
||||
test("rejects tuned Gemini models in express mode", () => {
|
||||
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "google-vertex",
|
||||
message: "Google Vertex tuned models do not support Express Mode API keys",
|
||||
}),
|
||||
"Google Vertex tuned models do not support Express Mode API keys",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMEvent, Message } from "../../src/index.js"
|
||||
import { configure } from "../../src/providers/organization-routes.js"
|
||||
import { provider } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const model = configure({
|
||||
apiKey: "session-test",
|
||||
baseURL: "https://console.example.test/inference/route/openai/v1",
|
||||
provider: "opencode-routes-org_test",
|
||||
headers: { "x-opencode-org-id": "org_test" },
|
||||
}).model("route/coding")
|
||||
|
||||
const fixtures = [
|
||||
{
|
||||
protocol: "openai-responses",
|
||||
content: [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_native",
|
||||
summary: [{ type: "summary_text", text: "Need a lookup" }],
|
||||
encrypted_content: "opaque-openai-reasoning",
|
||||
},
|
||||
{ type: "message", id: "msg_native", role: "assistant", content: [{ type: "output_text", text: "Checking." }] },
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_native_lookup",
|
||||
call_id: "call_lookup",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
{ type: "function_call", id: "fc_native_clock", call_id: "call_clock", name: "clock", arguments: "{}" },
|
||||
],
|
||||
},
|
||||
{
|
||||
protocol: "google",
|
||||
content: [
|
||||
{ text: "Need a lookup", thought: true },
|
||||
{ text: "Checking." },
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "opaque-google-signature" },
|
||||
{ functionCall: { name: "clock", args: {} } },
|
||||
],
|
||||
},
|
||||
{
|
||||
protocol: "anthropic-messages",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Need a lookup", signature: "opaque-anthropic-signature" },
|
||||
{ type: "text", text: "Checking." },
|
||||
{ type: "tool_use", id: "call_lookup", name: "lookup", input: { query: "weather" } },
|
||||
{ type: "tool_use", id: "call_clock", name: "clock", input: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
protocol: "openai-chat",
|
||||
content: [
|
||||
{
|
||||
role: "assistant",
|
||||
reasoning_content: "Need a lookup",
|
||||
content: "Checking.",
|
||||
tool_calls: [
|
||||
{ id: "call_lookup", type: "function", function: { name: "lookup", arguments: '{"query":"weather"}' } },
|
||||
{ id: "call_clock", type: "function", function: { name: "clock", arguments: "{}" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const finalText = sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_final", content: [{ type: "output_text", text: "Sunny." }] },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_final" } },
|
||||
)
|
||||
|
||||
describe("organization routes", () => {
|
||||
it.effect("uses the Responses route endpoint with stateless portable options", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
system: "Help with code.",
|
||||
prompt: "Hello",
|
||||
promptCacheKey: "session-cache",
|
||||
providerOptions: {
|
||||
store: true,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
previousResponseId: "resp_old",
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
serviceTier: "priority",
|
||||
},
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
expect(input.request.url).toBe("https://console.example.test/inference/route/openai/v1/responses")
|
||||
expect(input.request.headers.authorization).toBe("Bearer session-test")
|
||||
expect(input.request.headers["x-opencode-org-id"]).toBe("org_test")
|
||||
expect(yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(input.text)).toEqual({
|
||||
model: "route/coding",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Hello" }] }],
|
||||
instructions: "Help with code.",
|
||||
stream: true,
|
||||
store: false,
|
||||
provider_options: { "openai-responses": { include: ["reasoning.encrypted_content"] } },
|
||||
reasoning: { effort: "low" },
|
||||
})
|
||||
return input.respond(finalText, { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.text).toBe("Sunny.")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits an unknown output limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello", generation: { maxTokens: 0 } }))
|
||||
expect(prepared.body.max_output_tokens).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
it.effect(`preserves ${fixture.protocol} continuation metadata through a streamed parallel tool loop`, () =>
|
||||
Effect.gen(function* () {
|
||||
const metadata = {
|
||||
protocol: fixture.protocol,
|
||||
model: "native-model",
|
||||
connection_id: "conn_native",
|
||||
endpoint_id: "endpoint_native",
|
||||
content: fixture.content,
|
||||
group_id: "resp_tools",
|
||||
}
|
||||
const marker = { group_id: "resp_tools" }
|
||||
const items = [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Need a lookup" }],
|
||||
provider_metadata: metadata,
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Checking." }],
|
||||
provider_metadata: marker,
|
||||
},
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_lookup",
|
||||
call_id: "call_lookup",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
provider_metadata: marker,
|
||||
},
|
||||
{
|
||||
type: "function_call",
|
||||
id: "fc_clock",
|
||||
call_id: "call_clock",
|
||||
name: "clock",
|
||||
arguments: "{}",
|
||||
provider_metadata: marker,
|
||||
},
|
||||
]
|
||||
const first = yield* LLMClient.generate(LLM.request({ model, prompt: "Check weather and time." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id: "rs_1", summary: [] },
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
item_id: "rs_1",
|
||||
summary_index: 0,
|
||||
delta: "Need a lookup",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 1,
|
||||
item: { type: "message", id: "msg_1", role: "assistant", content: [] },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Checking." },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 2,
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_lookup",
|
||||
call_id: "call_lookup",
|
||||
name: "lookup",
|
||||
arguments: "",
|
||||
},
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_lookup", delta: '{"query":"weather"}' },
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
output_index: 3,
|
||||
item: { type: "function_call", id: "fc_clock", call_id: "call_clock", name: "clock", arguments: "" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "fc_clock", delta: "{}" },
|
||||
...items.map((item, output_index) => ({ type: "response.output_item.done", output_index, item })),
|
||||
{ type: "response.completed", response: { id: "resp_tools", output: items } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(first.toolCalls).toHaveLength(2)
|
||||
expect(first.events.filter(LLMEvent.is.toolCall)).toHaveLength(2)
|
||||
expect(
|
||||
first.message.content.map((part) => part.providerMetadata?.["opencode-routes-org_test"]?.providerMetadata),
|
||||
).toEqual([metadata, marker, marker, marker])
|
||||
|
||||
const second = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model,
|
||||
providerOptions: { previousResponseId: "resp_tools" },
|
||||
messages: [
|
||||
Message.user("Check weather and time."),
|
||||
first.message,
|
||||
Message.tool({ id: "call_lookup", name: "lookup", result: { weather: "sunny" } }),
|
||||
Message.tool({ id: "call_clock", name: "clock", result: { time: "noon" } }),
|
||||
],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const body = yield* Schema.decodeUnknownEffect(
|
||||
Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
input: Schema.Array(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
store: Schema.Boolean,
|
||||
include: Schema.optional(Schema.Array(Schema.String)),
|
||||
previous_response_id: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
)(input.text)
|
||||
expect(body.store).toBe(false)
|
||||
expect(body.include).toBeUndefined()
|
||||
expect(body.previous_response_id).toBeUndefined()
|
||||
expect(body.input.map((item) => item.type ?? item.role)).toEqual([
|
||||
"user",
|
||||
"reasoning",
|
||||
"message",
|
||||
"function_call",
|
||||
"function_call",
|
||||
"function_call_output",
|
||||
"function_call_output",
|
||||
])
|
||||
expect(body.input.slice(1, 5).map((item) => item.provider_metadata)).toEqual([
|
||||
metadata,
|
||||
marker,
|
||||
marker,
|
||||
marker,
|
||||
])
|
||||
expect(body.input.slice(3, 5).map((item) => item.call_id)).toEqual(["call_lookup", "call_clock"])
|
||||
return input.respond(finalText, { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(second.text).toBe("Sunny.")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("preserves opaque state on an empty reasoning item", () =>
|
||||
Effect.gen(function* () {
|
||||
const metadata = {
|
||||
protocol: "anthropic-messages",
|
||||
content: [{ type: "redacted_thinking", data: "opaque" }],
|
||||
group_id: "resp_empty",
|
||||
}
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_empty", summary: [], provider_metadata: metadata },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_empty" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const replay = yield* compileRequest(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.input).toEqual([
|
||||
{ type: "reasoning", id: "rs_empty", summary: [], encrypted_content: null, provider_metadata: metadata },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps opaque route metadata out of other Responses providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const other = provider
|
||||
.configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://other.example.test/v1",
|
||||
provider: "opencode-routes-org_test",
|
||||
})
|
||||
.model("other")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model: other, prompt: "Hello" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
content: [{ type: "output_text", text: "Hi" }],
|
||||
provider_metadata: { group_id: "resp_1", secret: "opaque" },
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.message.content[0]?.providerMetadata).toEqual({ "opencode-routes-org_test": { itemId: "msg_1" } })
|
||||
const replay = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: other,
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "text",
|
||||
text: "Hi",
|
||||
providerMetadata: {
|
||||
"opencode-routes-org_test": { itemId: "msg_1", providerMetadata: { secret: "opaque" } },
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(replay.body.input).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
content: [{ type: "output_text", text: "Hi" }],
|
||||
},
|
||||
])
|
||||
expect(replay.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -19,13 +19,11 @@ story("cancelling a version mismatch permits reconnecting again", async ({ mount
|
||||
const component = await mount("app-dialog-ssh--incompatible-session")
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("alert")).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("alert")).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
@@ -45,17 +43,6 @@ story("adding a server keeps all SSH challenges in the original connection dialo
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
story("adding an incompatible server advances to a dedicated update step", async ({ mount, page }) => {
|
||||
await mount("app-dialog-ssh--incompatible-host")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
|
||||
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("alert")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("button", { name: "Update and reconnect", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
|
||||
const component = await mount("app-dialog-ssh--incompatible-session")
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
|
||||
@@ -115,7 +115,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
})
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts"])
|
||||
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(
|
||||
@@ -134,6 +134,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
"true",
|
||||
)
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
|
||||
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import { DateTime } from "luxon"
|
||||
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
|
||||
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { useProviders } from "@/providers/catalog/providers"
|
||||
@@ -80,8 +79,8 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
const list = createMemo(() =>
|
||||
available().map((m) => ({
|
||||
...m,
|
||||
name: isOrganizationRouteProvider(m.provider.id) ? m.name : m.name.replace("(latest)", "").trim(),
|
||||
latest: !isOrganizationRouteProvider(m.provider.id) && m.name.includes("(latest)"),
|
||||
name: m.name.replace("(latest)", "").trim(),
|
||||
latest: m.name.includes("(latest)"),
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -101,7 +100,6 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
|
||||
const state = visibility().get(key)
|
||||
if (state === "hide") return false
|
||||
if (state === "show") return true
|
||||
if (isOrganizationRouteProvider(model.providerID)) return true
|
||||
if (latestSet().has(key)) return true
|
||||
const date = release().get(key)
|
||||
if (!date?.isValid) return true
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Popover } from "@kobalte/core/popover"
|
||||
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
|
||||
import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocal, type ModelSelection } from "@/providers/models/selection"
|
||||
@@ -198,9 +197,6 @@ const ModelList: Component<{
|
||||
>
|
||||
<span class="min-w-0 truncate">{item.name}</span>
|
||||
</Tooltip>
|
||||
<Show when={isOrganizationRouteProvider(item.provider.id)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.variable")}</Badge>
|
||||
</Show>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
|
||||
</Show>
|
||||
@@ -493,9 +489,6 @@ function ModelSelectorPopoverView(props: {
|
||||
onSelect={() => selectModel(item)}
|
||||
>
|
||||
<span class="min-w-0 truncate leading-5">{item.name}</span>
|
||||
<Show when={isOrganizationRouteProvider(item.provider.id)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.variable")}</Badge>
|
||||
</Show>
|
||||
<Show when={isFree(item.provider.id, item.cost)}>
|
||||
<Badge class="shrink-0">{language.t("model.tag.free")}</Badge>
|
||||
</Show>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Show, type Component, type JSX } from "solid-js"
|
||||
import { isOrganizationRouteProvider } from "@opencode/util/organization-routes"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
|
||||
type InputKey = "text" | "image" | "audio" | "video" | "pdf"
|
||||
@@ -9,7 +8,6 @@ type ModelInfo = {
|
||||
id: string
|
||||
name: string
|
||||
provider: {
|
||||
id?: string
|
||||
name: string
|
||||
}
|
||||
capabilities?: {
|
||||
@@ -38,9 +36,7 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
props,
|
||||
) => {
|
||||
const language = useLanguage()
|
||||
const route = () => isOrganizationRouteProvider(props.model.provider.id ?? "")
|
||||
const sourceName = (model: ModelInfo) => {
|
||||
if (route()) return model.provider.name
|
||||
const value = `${model.id} ${model.name}`.toLowerCase()
|
||||
|
||||
if (/claude|anthropic/.test(value)) return language.t("model.provider.anthropic")
|
||||
@@ -62,16 +58,14 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
const title = () => {
|
||||
const tags: Array<string> = []
|
||||
if (props.latest) tags.push(language.t("model.tag.latest"))
|
||||
if (route()) tags.push(language.t("model.tag.variable"))
|
||||
if (!route() && props.free) tags.push(language.t("model.tag.free"))
|
||||
if (props.free) tags.push(language.t("model.tag.free"))
|
||||
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
|
||||
return `${sourceName(props.model)} ${props.model.name}${suffix}`
|
||||
}
|
||||
const name = () => {
|
||||
const tags: Array<string> = []
|
||||
if (props.latest) tags.push(language.t("model.tag.latest"))
|
||||
if (route()) tags.push(language.t("model.tag.variable"))
|
||||
if (!route() && props.free) tags.push(language.t("model.tag.free"))
|
||||
if (props.free) tags.push(language.t("model.tag.free"))
|
||||
const suffix = tags.length ? ` (${tags.join(", ")})` : ""
|
||||
return `${props.model.name}${suffix}`
|
||||
}
|
||||
@@ -104,13 +98,11 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
<div class="flex w-[180px] flex-col gap-2">
|
||||
<ModelTooltipRow name={language.t("model.tooltip.model")} value={name()} />
|
||||
<ModelTooltipRow name={language.t("model.tooltip.provider")} value={props.model.provider.name} />
|
||||
<Show when={!route()}>
|
||||
<Show when={inputs()}>
|
||||
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
|
||||
</Show>
|
||||
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
|
||||
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
|
||||
<Show when={inputs()}>
|
||||
{(value) => <ModelTooltipRow name={language.t("model.tooltip.inputs")} value={value()} />}
|
||||
</Show>
|
||||
<ModelTooltipRow name={language.t("model.tooltip.reasoning")} value={reasoning()} />
|
||||
<ModelTooltipRow name={language.t("model.tooltip.context.label")} value={contextLimit()} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -118,17 +110,15 @@ export const ModelTooltip: Component<{ model: ModelInfo; latest?: boolean; free?
|
||||
return (
|
||||
<div class="flex flex-col gap-1 py-1">
|
||||
<div class="text-13-medium">{title()}</div>
|
||||
<Show when={!route()}>
|
||||
<Show when={inputs()}>
|
||||
{(value) => (
|
||||
<div class="text-12-regular text-text-invert-base">
|
||||
{language.t("model.tooltip.allows", { inputs: value() })}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="text-12-regular text-text-invert-base">{reasoning()}</div>
|
||||
<div class="text-12-regular text-text-invert-base">{context()}</div>
|
||||
<Show when={inputs()}>
|
||||
{(value) => (
|
||||
<div class="text-12-regular text-text-invert-base">
|
||||
{language.t("model.tooltip.allows", { inputs: value() })}
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<div class="text-12-regular text-text-invert-base">{reasoning()}</div>
|
||||
<div class="text-12-regular text-text-invert-base">{context()}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -267,7 +267,6 @@ export const dict = {
|
||||
"provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.",
|
||||
|
||||
"model.tag.free": "Free",
|
||||
"model.tag.variable": "Variable",
|
||||
"model.tag.latest": "Latest",
|
||||
"model.provider.anthropic": "Anthropic",
|
||||
"model.provider.openai": "OpenAI",
|
||||
|
||||
@@ -54,12 +54,8 @@ export function createWebPlatform(version: string) {
|
||||
|
||||
function getCurrentServerUrl() {
|
||||
if (import.meta.env.VITE_OPENCODE_SERVER_MODE === "none") return undefined
|
||||
if (import.meta.env.DEV) {
|
||||
const loopback =
|
||||
location.hostname === "localhost" || location.hostname === "[::1]" || location.hostname.startsWith("127.")
|
||||
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? (loopback ? location.hostname : "localhost")
|
||||
return `http://${host}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
}
|
||||
if (import.meta.env.DEV)
|
||||
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
|
||||
return location.origin
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { createData } from "@opencode/client/solid"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
|
||||
import { createServerNotificationState } from "@/shell/notifications/notification"
|
||||
import { createNotificationCoordinator } from "@/shell/notifications/coordinator"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { createDesktopData } from "./data"
|
||||
import { ModelState } from "./persistence"
|
||||
@@ -34,7 +33,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
},
|
||||
})
|
||||
const models = createGlobalModels()
|
||||
const notificationCoordinator = createNotificationCoordinator()
|
||||
|
||||
const settingsServer = createMemo(() => {
|
||||
const list = server.list
|
||||
@@ -59,7 +57,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
if (existing) return existing
|
||||
const serverCtx = createRoot((dispose) => {
|
||||
serverCtxDisposers.set(key, dispose)
|
||||
return createServerController(conn, server.scope(key), server.projects.forServer(key), notificationCoordinator)
|
||||
return createServerController(conn, server.scope(key), server.projects.forServer(key))
|
||||
}, owner)
|
||||
serverCtxs.set(key, serverCtx)
|
||||
return serverCtx
|
||||
@@ -133,7 +131,6 @@ function createServerController(
|
||||
conn: ServerConnection.Any,
|
||||
scope: ServerScope,
|
||||
projects: ReturnType<typeof createServerProjects>,
|
||||
notificationCoordinator: ReturnType<typeof createNotificationCoordinator>,
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
@@ -162,7 +159,7 @@ function createServerController(
|
||||
})
|
||||
const sync = createServerSyncContext(sdk, data)
|
||||
createPermissionAutoApprover({ sdk, data })
|
||||
const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator })
|
||||
const notification = createServerNotificationState({ sdk, data, key: connKey })
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
||||
|
||||
@@ -278,7 +278,6 @@ function Open(props: { initial?: string }) {
|
||||
export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" }
|
||||
export const AuthenticationRequired = { render: () => <Fixture initial="required" /> }
|
||||
export const SettingsReconnect = { render: () => <Fixture initial="required" settings connectionDelay={200} /> }
|
||||
export const IncompatibleHost = { render: () => <Fixture incompatible /> }
|
||||
export const IncompatibleSession = { render: () => <Fixture initial="required" session incompatible /> }
|
||||
export const InactiveSession = { render: () => <Fixture initial="required" session connectionDelay={3000} /> }
|
||||
export const KeyReconnect = { render: () => <Fixture initial="required" session keyOnly connectionDelay={3000} /> }
|
||||
|
||||
@@ -120,13 +120,7 @@ export function DialogSsh(props: {
|
||||
<Divider />
|
||||
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
|
||||
<div class="flex w-full min-w-0 flex-col gap-6">
|
||||
<Show
|
||||
when={
|
||||
!props.promptOnly &&
|
||||
item()?.stage !== "incompatible" &&
|
||||
(!state.prompted || (!!error() && !prompt()))
|
||||
}
|
||||
>
|
||||
<Show when={!props.promptOnly && (!state.prompted || (!!error() && !prompt()))}>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
<label class="settings-server-dialog-label" for="ssh-target">
|
||||
{language.t("ssh.target")}
|
||||
@@ -166,12 +160,6 @@ export function DialogSsh(props: {
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={item()?.stage === "incompatible"}>
|
||||
<div class="flex w-full min-w-0 flex-col gap-2" role="status" aria-live="polite">
|
||||
<span class="text-14-medium text-v2-text-text-base">{language.t("ssh.stage.incompatible")}</span>
|
||||
<span class="text-13-regular text-v2-text-text-muted">{language.t("ssh.error.version")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={prompt()} keyed>
|
||||
{(prompt) => (
|
||||
<div class="flex w-full min-w-0 flex-col gap-2">
|
||||
@@ -207,7 +195,7 @@ export function DialogSsh(props: {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={item()?.stage !== "incompatible" && error()}>
|
||||
<Show when={error()}>
|
||||
{(error) => (
|
||||
<span class="settings-server-dialog-error !leading-[var(--line-height-compact)]" role="alert">
|
||||
{error()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createMemo, createUniqueId, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createQuery, keepPreviousData } from "@tanstack/solid-query"
|
||||
import { createQuery } from "@tanstack/solid-query"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode/session-ui/v2/session-file-panel-v2"
|
||||
import { SessionReviewV2Sidebar } from "@opencode/session-ui/v2/session-review-v2"
|
||||
@@ -56,7 +56,6 @@ export function SessionFileBrowserTab(props: {
|
||||
queryKey: [serverSDK.scope, "session-open-file", workspaceKey(), value] as const,
|
||||
enabled: serverSDK.connection.status() === "connected" && value.length > 0,
|
||||
queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
})
|
||||
const files = createMemo(() => {
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { onCleanup } from "solid-js"
|
||||
|
||||
const FOCUS_LOCK = "opencode:notification-focus"
|
||||
const MAX_CLAIMED = 500
|
||||
|
||||
export function createNotificationCoordinator() {
|
||||
const locks = typeof navigator === "undefined" ? undefined : navigator.locks
|
||||
const claimed = new Set<string>()
|
||||
const focus = { pending: false, release: undefined as (() => void) | undefined }
|
||||
|
||||
const updateFocus = () => {
|
||||
if (typeof document === "undefined" || !document.hasFocus()) {
|
||||
focus.release?.()
|
||||
return
|
||||
}
|
||||
if (!locks || focus.pending || focus.release) return
|
||||
|
||||
focus.pending = true
|
||||
void locks
|
||||
.request(FOCUS_LOCK, { mode: "shared" }, async () => {
|
||||
focus.pending = false
|
||||
if (!document.hasFocus()) return
|
||||
await new Promise<void>((resolve) => {
|
||||
focus.release = resolve
|
||||
})
|
||||
focus.release = undefined
|
||||
})
|
||||
.catch(() => {
|
||||
focus.pending = false
|
||||
})
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("focus", updateFocus)
|
||||
window.addEventListener("blur", updateFocus)
|
||||
document.addEventListener("visibilitychange", updateFocus)
|
||||
updateFocus()
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("focus", updateFocus)
|
||||
window.removeEventListener("blur", updateFocus)
|
||||
document.removeEventListener("visibilitychange", updateFocus)
|
||||
focus.release?.()
|
||||
})
|
||||
}
|
||||
|
||||
const once = async (kind: "sound" | "system", eventID: string, run: () => Promise<unknown> | void) => {
|
||||
const key = `${kind}:${eventID}`
|
||||
const execute = async () => {
|
||||
if (!claim(kind, key, claimed)) return
|
||||
await run()
|
||||
}
|
||||
if (!locks) return execute()
|
||||
await locks.request(`opencode:notification:${key}`, execute)
|
||||
}
|
||||
|
||||
return {
|
||||
sound(eventID: string, run: () => Promise<unknown> | void) {
|
||||
return once("sound", eventID, run)
|
||||
},
|
||||
system(eventID: string, run: () => Promise<unknown> | void) {
|
||||
return once("system", eventID, async () => {
|
||||
if (typeof document !== "undefined" && document.hasFocus()) return
|
||||
if (!locks) return run()
|
||||
await locks.request(FOCUS_LOCK, { mode: "exclusive", ifAvailable: true }, async (lock) => {
|
||||
if (!lock) return
|
||||
await run()
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function claim(kind: "sound" | "system", eventID: string, claimed: Set<string>) {
|
||||
if (claimed.has(eventID)) return false
|
||||
|
||||
if (typeof localStorage !== "undefined") {
|
||||
try {
|
||||
const storageKey = `opencode:notification-${kind}`
|
||||
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? "[]")
|
||||
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
|
||||
if (events.includes(eventID)) {
|
||||
claimed.add(eventID)
|
||||
return false
|
||||
}
|
||||
localStorage.setItem(storageKey, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
|
||||
} catch {
|
||||
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
claimed.add(eventID)
|
||||
return true
|
||||
}
|
||||
@@ -11,8 +11,7 @@ import { useSettings } from "@/settings/model"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { playSoundById } from "@/shell/notifications/sound"
|
||||
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
|
||||
import { playSoundByIdOnce } from "@/shell/notifications/sound"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
import { ServerConnection, useServers } from "@/runtime/server/registry"
|
||||
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
|
||||
@@ -115,12 +114,7 @@ function buildNotificationIndex(list: Notification[]) {
|
||||
return index
|
||||
}
|
||||
|
||||
export function createServerNotificationState(input: {
|
||||
sdk: ServerSDK
|
||||
data: Data
|
||||
key: ServerConnection.Key
|
||||
coordinator: ReturnType<typeof createNotificationCoordinator>
|
||||
}) {
|
||||
export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
@@ -229,7 +223,7 @@ export function createServerNotificationState(input: {
|
||||
if (session.parentID) return
|
||||
|
||||
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.agentEnabled()) {
|
||||
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.agent()))
|
||||
void playSoundByIdOnce(settings.sounds.agent(), `${input.key}\0${eventID}`)
|
||||
}
|
||||
|
||||
append({
|
||||
@@ -241,10 +235,8 @@ export function createServerNotificationState(input: {
|
||||
})
|
||||
|
||||
if (settings.notifications.agent()) {
|
||||
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
|
||||
platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
),
|
||||
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -256,7 +248,7 @@ export function createServerNotificationState(input: {
|
||||
if (session?.parentID) return
|
||||
|
||||
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.errorsEnabled()) {
|
||||
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.errors()))
|
||||
void playSoundByIdOnce(settings.sounds.errors(), `${input.key}\0${eventID}`)
|
||||
}
|
||||
|
||||
append({
|
||||
@@ -271,10 +263,8 @@ export function createServerNotificationState(input: {
|
||||
session?.title ??
|
||||
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
|
||||
if (settings.notifications.errors()) {
|
||||
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
|
||||
platform.notify(language.t("notification.session.error.title"), description, () =>
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
),
|
||||
void platform.notify(language.t("notification.session.error.title"), description, () =>
|
||||
openNotificationSession(tabs, input.key, sessionID),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -74,6 +74,9 @@ function getLoads() {
|
||||
}
|
||||
|
||||
const cache = new Map<SoundID, Promise<string | undefined>>()
|
||||
const claimed = new Set<string>()
|
||||
const CLAIMED_STORAGE_KEY = "opencode:notification-sounds"
|
||||
const MAX_CLAIMED = 500
|
||||
|
||||
export function soundSrc(id: string | undefined) {
|
||||
const loads = getLoads()
|
||||
@@ -100,3 +103,34 @@ export function playSound(src: string | undefined) {
|
||||
export function playSoundById(id: string | undefined) {
|
||||
return soundSrc(id).then((src) => playSound(src))
|
||||
}
|
||||
|
||||
export async function playSoundByIdOnce(id: string | undefined, eventID: string) {
|
||||
const play = async () => {
|
||||
if (!claim(eventID)) return
|
||||
await playSoundById(id)
|
||||
}
|
||||
|
||||
if (typeof navigator === "undefined" || !navigator.locks) return play()
|
||||
await navigator.locks.request(`${CLAIMED_STORAGE_KEY}:${eventID}`, play)
|
||||
}
|
||||
|
||||
function claim(eventID: string) {
|
||||
if (claimed.has(eventID)) return false
|
||||
|
||||
if (typeof localStorage !== "undefined") {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(CLAIMED_STORAGE_KEY) ?? "[]")
|
||||
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
|
||||
if (events.includes(eventID)) {
|
||||
claimed.add(eventID)
|
||||
return false
|
||||
}
|
||||
localStorage.setItem(CLAIMED_STORAGE_KEY, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
|
||||
} catch {
|
||||
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
claimed.add(eventID)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ mock.module("@/runtime/platform/platform", () => ({
|
||||
}))
|
||||
|
||||
const { LocalProvider, useLocal } = await import("@/providers/models/selection")
|
||||
const { ModelsProvider, useModels } = await import("@/providers/models/models")
|
||||
const { ModelsProvider } = 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,13 +150,11 @@ function fixture(input: { session?: Commit; agents?: Agent[]; config?: ConfigMod
|
||||
mount(draft = false) {
|
||||
active = result
|
||||
let local!: ReturnType<typeof useLocal>
|
||||
let models!: ReturnType<typeof useModels>
|
||||
let composer: ReturnType<typeof createComposerModelSelection> | undefined
|
||||
const dispose = createRoot((dispose) => {
|
||||
createComponent(ModelsProvider, {
|
||||
directory,
|
||||
get children() {
|
||||
models = useModels()
|
||||
return createComponent(LocalProvider, {
|
||||
get children() {
|
||||
local = useLocal()
|
||||
@@ -169,7 +167,7 @@ function fixture(input: { session?: Commit; agents?: Agent[]; config?: ConfigMod
|
||||
return dispose
|
||||
})
|
||||
cleanups.push(dispose)
|
||||
return { local, models, composer, dispose }
|
||||
return { local, composer, dispose }
|
||||
},
|
||||
}
|
||||
const target = Persist.serverWorkspace(ServerScope.local, directory, "model-selection")
|
||||
@@ -459,47 +457,3 @@ 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])
|
||||
})
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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()
|
||||
})
|
||||
@@ -1,23 +0,0 @@
|
||||
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)
|
||||
+155
-341
@@ -1,248 +1,187 @@
|
||||
export * as AISDKNative from "./aisdk-native.js"
|
||||
|
||||
import { isRecord } from "@opencode/ai/utils/record"
|
||||
import { Effect, Option, Schema, Struct } from "effect"
|
||||
import { Provider } from "./provider.js"
|
||||
|
||||
export interface Mapping {
|
||||
readonly package: string
|
||||
readonly settings: Readonly<Record<string, unknown>>
|
||||
readonly settings: Provider.Settings
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
readonly body?: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
export interface MapInput {
|
||||
readonly packageName: string | undefined
|
||||
readonly settings: Readonly<Record<string, unknown>>
|
||||
readonly settings: Provider.Settings
|
||||
readonly modelID: string
|
||||
readonly providerID: string
|
||||
}
|
||||
|
||||
// A wrongly typed legacy value is dropped rather than failing the whole decode.
|
||||
const lenient = <S extends Schema.Top>(schema: S) =>
|
||||
Schema.optional(Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined)))))
|
||||
|
||||
const Credentials = Schema.Struct({
|
||||
accessKeyId: Schema.String,
|
||||
secretAccessKey: Schema.String,
|
||||
sessionToken: lenient(Schema.String),
|
||||
region: lenient(Schema.String),
|
||||
})
|
||||
|
||||
/** AI SDK settings whose spelling differs from the native package. Everything else passes through. */
|
||||
const Legacy = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
apiKey: lenient(Schema.String),
|
||||
baseURL: lenient(Schema.String),
|
||||
headers: lenient(Schema.Record(Schema.String, Schema.String)),
|
||||
extraBody: lenient(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
useCompletionUrls: lenient(Schema.Boolean),
|
||||
// Bedrock
|
||||
auth: lenient(Schema.Literals(["bearer", "sigv4"])),
|
||||
bearerToken: lenient(Schema.String),
|
||||
endpoint: lenient(Schema.String),
|
||||
region: lenient(Schema.String),
|
||||
credentials: lenient(Credentials),
|
||||
accessKeyId: lenient(Schema.String),
|
||||
secretAccessKey: lenient(Schema.String),
|
||||
sessionToken: lenient(Schema.String),
|
||||
anthropicBeta: lenient(Schema.Array(Schema.String)),
|
||||
serviceTier: lenient(Schema.String),
|
||||
reasoningConfig: lenient(
|
||||
Schema.Struct({
|
||||
type: lenient(Schema.String),
|
||||
display: lenient(Schema.String),
|
||||
maxReasoningEffort: lenient(Schema.String),
|
||||
budgetTokens: lenient(Schema.Number),
|
||||
}),
|
||||
),
|
||||
additionalModelRequestFields: lenient(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
anthropic_beta: lenient(Schema.Array(Schema.String)),
|
||||
output_config: lenient(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
reasoning: lenient(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
// OpenRouter
|
||||
appName: lenient(Schema.String),
|
||||
appUrl: lenient(Schema.String),
|
||||
api_keys: lenient(Schema.Record(Schema.String, Schema.String)),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type Legacy = typeof Legacy.Type
|
||||
const decode = Schema.decodeUnknownSync(Legacy)
|
||||
|
||||
/** Maps a legacy AI SDK package onto the native package that replaces it. */
|
||||
export function map(input: MapInput): Mapping | undefined {
|
||||
const baseSettings = mapBaseSettings(input.settings)
|
||||
switch (input.packageName) {
|
||||
const settings = decode(input.settings)
|
||||
const native = mapPackage(input.packageName, input.modelID, settings)
|
||||
if (!native) return
|
||||
const converse = native === "@opencode/ai/providers/amazon-bedrock"
|
||||
const mapped = {
|
||||
...Struct.omit(settings, ["headers", "extraBody", ...OPENROUTER_KEYS]),
|
||||
...(native === "@opencode/ai/providers/openai-compatible" ? { provider: input.providerID } : {}),
|
||||
}
|
||||
return {
|
||||
package: native,
|
||||
settings: native.startsWith("@opencode/ai/providers/amazon-bedrock") ? bedrockSettings(mapped, converse) : mapped,
|
||||
...(settings.headers === undefined ? {} : { headers: settings.headers }),
|
||||
...(settings.extraBody === undefined ? {} : { body: settings.extraBody }),
|
||||
...(converse ? bedrockRequest(input.modelID, settings) : {}),
|
||||
...(native === "@opencode/ai/providers/openrouter" ? openRouterRequest(settings) : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function mapPackage(packageName: string | undefined, modelID: string, settings: Legacy) {
|
||||
switch (packageName) {
|
||||
case "@ai-sdk/anthropic":
|
||||
return {
|
||||
package: "@opencode/ai/providers/anthropic",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.authToken === "string" ? { authToken: input.settings.authToken } : {}),
|
||||
...mapProviderOptions(input.settings, ["apiKey", "authToken", "baseURL"]),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/amazon-bedrock":
|
||||
return {
|
||||
package: "@opencode/ai/providers/amazon-bedrock",
|
||||
settings: mapBedrockSettings(input.settings, baseSettings),
|
||||
...mapBedrockRequest(input),
|
||||
}
|
||||
case "@ai-sdk/amazon-bedrock/mantle":
|
||||
return mapBedrockMantle(input, baseSettings)
|
||||
case "@ai-sdk/azure":
|
||||
return {
|
||||
package: `@opencode/ai/providers/azure/${input.settings.useCompletionUrls === true ? "chat" : "responses"}`,
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.resourceName === "string" ? { resourceName: input.settings.resourceName } : {}),
|
||||
...(typeof input.settings.apiVersion === "string" ? { apiVersion: input.settings.apiVersion } : {}),
|
||||
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
|
||||
...(typeof input.settings.useDeploymentBasedUrls === "boolean"
|
||||
? { useDeploymentBasedUrls: input.settings.useDeploymentBasedUrls }
|
||||
: {}),
|
||||
...mapOpenAIOptions(input.settings),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/cerebras":
|
||||
case "@ai-sdk/deepinfra":
|
||||
case "@ai-sdk/groq":
|
||||
case "@ai-sdk/togetherai":
|
||||
return {
|
||||
package: `@opencode/ai/providers/${input.packageName.slice("@ai-sdk/".length)}`,
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...mapProviderOptions(input.settings, ["apiKey", "baseURL", "fetch", "headers", "name"]),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
case "@ai-sdk/google":
|
||||
return {
|
||||
package: "@opencode/ai/providers/google",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...mapGoogleOptions(input.settings),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/google-vertex":
|
||||
return {
|
||||
package: "@opencode/ai/providers/google-vertex",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...(typeof input.settings.accessToken === "string" ? { accessToken: input.settings.accessToken } : {}),
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
|
||||
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
|
||||
...mapGoogleOptions(input.settings),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
case "@ai-sdk/google-vertex/anthropic":
|
||||
return {
|
||||
package: "@opencode/ai/providers/google-vertex/messages",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...(typeof input.settings.accessToken === "string" ? { accessToken: input.settings.accessToken } : {}),
|
||||
...(typeof input.settings.location === "string" ? { location: input.settings.location } : {}),
|
||||
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
|
||||
...(isRecord(input.settings.thinking) || typeof input.settings.effort === "string"
|
||||
? {
|
||||
providerOptions: {
|
||||
...(isRecord(input.settings.thinking) ? { thinking: input.settings.thinking } : {}),
|
||||
...(typeof input.settings.effort === "string" ? { effort: input.settings.effort } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
}
|
||||
case "@ai-sdk/groq":
|
||||
case "@ai-sdk/mistral":
|
||||
return {
|
||||
package: "@opencode/ai/providers/mistral",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...mapMistralOptions(input.settings),
|
||||
},
|
||||
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
|
||||
...(isRecord(input.settings.extraBody) ? { body: input.settings.extraBody } : {}),
|
||||
}
|
||||
case "@ai-sdk/openai":
|
||||
return {
|
||||
package: "@opencode/ai/providers/openai",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...(typeof input.settings.organization === "string" ? { organization: input.settings.organization } : {}),
|
||||
...(typeof input.settings.project === "string" ? { project: input.settings.project } : {}),
|
||||
...(isStringRecord(input.settings.queryParams) ? { queryParams: input.settings.queryParams } : {}),
|
||||
...mapProviderOptions(input.settings, ["apiKey", "baseURL", "organization", "project", "queryParams"]),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/openai-compatible":
|
||||
if (typeof input.settings.baseURL !== "string") return
|
||||
return {
|
||||
package: "@opencode/ai/providers/openai-compatible",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
provider: input.providerID,
|
||||
...mapProviderOptions(input.settings, ["apiKey", "baseURL"]),
|
||||
},
|
||||
}
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return mapOpenRouter(input.settings, baseSettings)
|
||||
case "@ai-sdk/togetherai":
|
||||
case "@ai-sdk/xai":
|
||||
return {
|
||||
package: "@opencode/ai/providers/xai",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(input.settings),
|
||||
...mapXAIOptions(input.settings),
|
||||
},
|
||||
}
|
||||
case "@ai-sdk/amazon-bedrock":
|
||||
return `@opencode/ai/providers/${packageName.slice("@ai-sdk/".length)}`
|
||||
case "@ai-sdk/amazon-bedrock/mantle":
|
||||
return `@opencode/ai/providers/amazon-bedrock/mantle/${modelID.includes("gpt-oss") ? "chat" : "responses"}`
|
||||
case "@ai-sdk/azure":
|
||||
return `@opencode/ai/providers/azure/${settings.useCompletionUrls === true ? "chat" : "responses"}`
|
||||
case "@ai-sdk/google-vertex/anthropic":
|
||||
return "@opencode/ai/providers/google-vertex/messages"
|
||||
case "@ai-sdk/openai-compatible":
|
||||
return settings.baseURL === undefined ? undefined : "@opencode/ai/providers/openai-compatible"
|
||||
case "@openrouter/ai-sdk-provider":
|
||||
return "@opencode/ai/providers/openrouter"
|
||||
}
|
||||
}
|
||||
|
||||
function mapProviderOptions(settings: Readonly<Record<string, unknown>>, excluded: ReadonlyArray<string>) {
|
||||
const options = Object.fromEntries(Object.entries(settings).filter(([name]) => !excluded.includes(name)))
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
}
|
||||
// AI SDK spellings the native Bedrock packages do not read.
|
||||
const BEDROCK_KEYS = [
|
||||
"bearerToken",
|
||||
"endpoint",
|
||||
"credentials",
|
||||
"credentialProvider",
|
||||
"accessKeyId",
|
||||
"secretAccessKey",
|
||||
"sessionToken",
|
||||
]
|
||||
// Request settings Converse takes in the body; translated by `bedrockRequest`.
|
||||
const CONVERSE_KEYS = ["additionalModelRequestFields", "reasoningConfig", "anthropicBeta", "serviceTier"]
|
||||
|
||||
function mapBedrockMantle(input: MapInput, baseSettings: Readonly<Record<string, unknown>>): Mapping | undefined {
|
||||
const settings = input.settings
|
||||
const chat = input.modelID.includes("gpt-oss")
|
||||
function bedrockSettings(settings: Legacy, converse: boolean) {
|
||||
const region = settings.region ?? settings.credentials?.region
|
||||
const credentials = settings.credentials ?? settings
|
||||
const baseURL = settings.baseURL ?? settings.endpoint
|
||||
return {
|
||||
package: `@opencode/ai/providers/amazon-bedrock/mantle/${chat ? "chat" : "responses"}`,
|
||||
settings: {
|
||||
...mapBedrockSettings(settings, baseSettings),
|
||||
...mapOpenAIOptions(settings),
|
||||
},
|
||||
...(isStringRecord(settings.headers) ? { headers: settings.headers } : {}),
|
||||
...Struct.omit(settings, converse ? [...BEDROCK_KEYS, ...CONVERSE_KEYS] : BEDROCK_KEYS),
|
||||
...(baseURL === undefined
|
||||
? {}
|
||||
: { baseURL: region === undefined ? baseURL : baseURL.replaceAll("${AWS_REGION}", region) }),
|
||||
...(settings.apiKey === undefined && settings.bearerToken !== undefined ? { apiKey: settings.bearerToken } : {}),
|
||||
...(region === undefined || credentials.accessKeyId === undefined || credentials.secretAccessKey === undefined
|
||||
? {}
|
||||
: {
|
||||
credentials: {
|
||||
region,
|
||||
accessKeyId: credentials.accessKeyId,
|
||||
secretAccessKey: credentials.secretAccessKey,
|
||||
...(credentials.sessionToken === undefined ? {} : { sessionToken: credentials.sessionToken }),
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function mapBedrockSettings(
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
baseSettings: Readonly<Record<string, unknown>>,
|
||||
) {
|
||||
const apiKey =
|
||||
typeof settings.apiKey === "string"
|
||||
? settings.apiKey
|
||||
: typeof settings.bearerToken === "string"
|
||||
? settings.bearerToken
|
||||
: undefined
|
||||
const region = bedrockRegion(settings)
|
||||
const credentials = mapBedrockCredentials(settings, region)
|
||||
return {
|
||||
...baseSettings,
|
||||
...(typeof baseSettings.baseURL === "string" && region !== undefined
|
||||
? { baseURL: baseSettings.baseURL.replaceAll("${AWS_REGION}", region) }
|
||||
: {}),
|
||||
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
|
||||
? { baseURL: settings.endpoint }
|
||||
: {}),
|
||||
...(apiKey === undefined ? {} : { apiKey }),
|
||||
...(settings.auth === "bearer" || settings.auth === "sigv4" ? { auth: settings.auth } : {}),
|
||||
...(credentials === undefined ? {} : { credentials }),
|
||||
...(typeof settings.profile === "string" ? { profile: settings.profile } : {}),
|
||||
...(typeof settings.region === "string" ? { region: settings.region } : {}),
|
||||
...(typeof settings.topP === "number" ? { topP: settings.topP } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
|
||||
const settings = input.settings
|
||||
const headers = isStringRecord(settings.headers) ? settings.headers : undefined
|
||||
const additional = isRecord(settings.additionalModelRequestFields) ? settings.additionalModelRequestFields : {}
|
||||
const reasoning = isRecord(settings.reasoningConfig) ? settings.reasoningConfig : undefined
|
||||
const anthropic = input.modelID.includes("anthropic")
|
||||
const openai = input.modelID.includes("openai.")
|
||||
// Converse passes OpenAI fields through verbatim. gpt-oss (Harmony) takes the
|
||||
// flat chat-completions `reasoning_effort`; GPT-5.6+ reject it and take the
|
||||
// Responses-style `reasoning.effort` instead.
|
||||
const harmony = input.modelID.includes("openai.gpt-oss")
|
||||
const effort = typeof reasoning?.maxReasoningEffort === "string" ? reasoning.maxReasoningEffort : undefined
|
||||
const type = typeof reasoning?.type === "string" ? reasoning.type : undefined
|
||||
const budget = typeof reasoning?.budgetTokens === "number" ? reasoning.budgetTokens : undefined
|
||||
const display = typeof reasoning?.display === "string" ? reasoning.display : undefined
|
||||
const betas = Array.isArray(settings.anthropicBeta)
|
||||
? settings.anthropicBeta.filter((item): item is string => typeof item === "string")
|
||||
: []
|
||||
const existingBetas = Array.isArray(additional.anthropic_beta)
|
||||
? additional.anthropic_beta.filter((item): item is string => typeof item === "string")
|
||||
: []
|
||||
function bedrockRequest(modelID: string, settings: Legacy): Pick<Mapping, "body"> {
|
||||
const additional = settings.additionalModelRequestFields ?? {}
|
||||
const reasoning = settings.reasoningConfig
|
||||
const anthropic = modelID.includes("anthropic")
|
||||
const openai = modelID.includes("openai.")
|
||||
// gpt-oss (Harmony) takes the flat chat-completions `reasoning_effort`; GPT-5.6+ take Responses-style `reasoning.effort`.
|
||||
const harmony = modelID.includes("openai.gpt-oss")
|
||||
const effort = reasoning?.maxReasoningEffort
|
||||
const type = reasoning?.type
|
||||
const budget = reasoning?.budgetTokens
|
||||
const display = reasoning?.display
|
||||
const betas = settings.anthropicBeta ?? []
|
||||
const fields = Provider.mergeOverlay(additional, {
|
||||
...(betas.length > 0 ? { anthropic_beta: [...existingBetas, ...betas] } : {}),
|
||||
...(betas.length > 0 ? { anthropic_beta: [...(additional.anthropic_beta ?? []), ...betas] } : {}),
|
||||
...(anthropic && type === "enabled" && budget !== undefined
|
||||
? { thinking: { type: "enabled", budget_tokens: budget } }
|
||||
: {}),
|
||||
...(anthropic && type === "adaptive"
|
||||
? { thinking: { type: "adaptive", ...(display === undefined ? {} : { display }) } }
|
||||
: {}),
|
||||
...(anthropic && effort !== undefined
|
||||
? {
|
||||
output_config: {
|
||||
...(isRecord(additional.output_config) ? additional.output_config : {}),
|
||||
effort,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(anthropic && effort !== undefined ? { output_config: { ...additional.output_config, effort } } : {}),
|
||||
...(!anthropic && openai && harmony && effort !== undefined ? { reasoning_effort: effort } : {}),
|
||||
...(!anthropic && openai && !harmony && effort !== undefined
|
||||
? { reasoning: { ...(isRecord(additional.reasoning) ? additional.reasoning : {}), effort } }
|
||||
? { reasoning: { ...additional.reasoning, effort } }
|
||||
: {}),
|
||||
...(!anthropic && !openai && effort !== undefined
|
||||
? {
|
||||
@@ -256,151 +195,26 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
|
||||
})
|
||||
const body = {
|
||||
...(fields && Object.keys(fields).length > 0 ? { additionalModelRequestFields: fields } : {}),
|
||||
...(typeof settings.serviceTier === "string" ? { serviceTier: { type: settings.serviceTier } } : {}),
|
||||
}
|
||||
return {
|
||||
...(headers === undefined ? {} : { headers }),
|
||||
...(Object.keys(body).length === 0 ? {} : { body }),
|
||||
...(settings.serviceTier === undefined ? {} : { serviceTier: { type: settings.serviceTier } }),
|
||||
}
|
||||
return Object.keys(body).length === 0 ? {} : { body }
|
||||
}
|
||||
|
||||
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, region: string | undefined) {
|
||||
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
|
||||
if (
|
||||
region === undefined ||
|
||||
typeof credentials.accessKeyId !== "string" ||
|
||||
typeof credentials.secretAccessKey !== "string"
|
||||
)
|
||||
return undefined
|
||||
return {
|
||||
region,
|
||||
accessKeyId: credentials.accessKeyId,
|
||||
secretAccessKey: credentials.secretAccessKey,
|
||||
...(typeof credentials.sessionToken === "string" ? { sessionToken: credentials.sessionToken } : {}),
|
||||
}
|
||||
}
|
||||
// Constructor options the native OpenRouter package takes as headers, plus `compatibility`, which the
|
||||
// native package would otherwise forward to the request body.
|
||||
const OPENROUTER_KEYS = ["appName", "appUrl", "api_keys", "compatibility"] as const
|
||||
|
||||
function bedrockRegion(settings: Readonly<Record<string, unknown>>) {
|
||||
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
|
||||
return typeof settings.region === "string"
|
||||
? settings.region
|
||||
: typeof credentials.region === "string"
|
||||
? credentials.region
|
||||
: undefined
|
||||
}
|
||||
|
||||
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = {
|
||||
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||
...(typeof settings.reasoningSummary === "string" ? { reasoningSummary: settings.reasoningSummary } : {}),
|
||||
...(Array.isArray(settings.include) ? { include: settings.include } : {}),
|
||||
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
|
||||
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
|
||||
...(typeof settings.textVerbosity === "string" ? { textVerbosity: settings.textVerbosity } : {}),
|
||||
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
}
|
||||
|
||||
function mapMistralOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = {
|
||||
...(typeof settings.safePrompt === "boolean" ? { safePrompt: settings.safePrompt } : {}),
|
||||
...(typeof settings.documentImageLimit === "number" ? { documentImageLimit: settings.documentImageLimit } : {}),
|
||||
...(typeof settings.documentPageLimit === "number" ? { documentPageLimit: settings.documentPageLimit } : {}),
|
||||
...(typeof settings.parallelToolCalls === "boolean" ? { parallelToolCalls: settings.parallelToolCalls } : {}),
|
||||
...(typeof settings.promptCacheKey === "string" ? { promptCacheKey: settings.promptCacheKey } : {}),
|
||||
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||
...(settings.promptMode === "reasoning" ? { promptMode: settings.promptMode } : {}),
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
}
|
||||
|
||||
function mapBaseSettings(settings: Readonly<Record<string, unknown>>) {
|
||||
return {
|
||||
...(typeof settings.baseURL === "string" ? { baseURL: settings.baseURL } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function mapAPIKey(settings: Readonly<Record<string, unknown>>) {
|
||||
return typeof settings.apiKey === "string" ? { apiKey: settings.apiKey } : {}
|
||||
}
|
||||
|
||||
function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const input = settings.thinkingConfig
|
||||
const thinkingConfig = {
|
||||
...(isRecord(input) && typeof input.thinkingBudget === "number" ? { thinkingBudget: input.thinkingBudget } : {}),
|
||||
...(isRecord(input) && typeof input.includeThoughts === "boolean"
|
||||
? { includeThoughts: input.includeThoughts }
|
||||
: {}),
|
||||
...(isRecord(input) && typeof input.thinkingLevel === "string" ? { thinkingLevel: input.thinkingLevel } : {}),
|
||||
}
|
||||
const options = {
|
||||
...(typeof settings.cachedContent === "string" ? { cachedContent: settings.cachedContent } : {}),
|
||||
...(isStringRecord(settings.labels) ? { labels: settings.labels } : {}),
|
||||
...(Array.isArray(settings.safetySettings) ? { safetySettings: settings.safetySettings } : {}),
|
||||
...(typeof settings.serviceTier === "string" ? { serviceTier: settings.serviceTier } : {}),
|
||||
...(Object.keys(thinkingConfig).length > 0 ? { thinkingConfig } : {}),
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
}
|
||||
|
||||
function mapOpenRouter(
|
||||
settings: Readonly<Record<string, unknown>>,
|
||||
baseSettings: Readonly<Record<string, unknown>>,
|
||||
): Mapping {
|
||||
function openRouterRequest(settings: Legacy): Pick<Mapping, "headers"> {
|
||||
const headers =
|
||||
Provider.mergeHeaders(
|
||||
{
|
||||
...(typeof settings.appName === "string" ? { "X-OpenRouter-Title": settings.appName } : {}),
|
||||
...(typeof settings.appUrl === "string" ? { "HTTP-Referer": settings.appUrl } : {}),
|
||||
...(isStringRecord(settings.api_keys) && Object.keys(settings.api_keys).length > 0
|
||||
? { "X-Provider-API-Keys": JSON.stringify(settings.api_keys) }
|
||||
: {}),
|
||||
...(settings.appName === undefined ? {} : { "X-OpenRouter-Title": settings.appName }),
|
||||
...(settings.appUrl === undefined ? {} : { "HTTP-Referer": settings.appUrl }),
|
||||
...(settings.api_keys === undefined || Object.keys(settings.api_keys).length === 0
|
||||
? {}
|
||||
: { "X-Provider-API-Keys": JSON.stringify(settings.api_keys) }),
|
||||
},
|
||||
isStringRecord(settings.headers) ? settings.headers : undefined,
|
||||
settings.headers,
|
||||
) ?? {}
|
||||
return {
|
||||
package: "@opencode/ai/providers/openrouter",
|
||||
settings: {
|
||||
...baseSettings,
|
||||
...mapAPIKey(settings),
|
||||
...mapOpenRouterOptions(settings),
|
||||
},
|
||||
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
||||
...(isRecord(settings.extraBody) ? { body: settings.extraBody } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
return mapProviderOptions(settings, [
|
||||
"apiKey",
|
||||
"api_keys",
|
||||
"appName",
|
||||
"appUrl",
|
||||
"authToken",
|
||||
"baseURL",
|
||||
"chunkTimeout",
|
||||
"compatibility",
|
||||
"extraBody",
|
||||
"fetch",
|
||||
"headers",
|
||||
"promptCacheKey",
|
||||
"timeout",
|
||||
])
|
||||
}
|
||||
|
||||
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
|
||||
return isRecord(value) && Object.values(value).every((item) => typeof item === "string")
|
||||
}
|
||||
|
||||
function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
|
||||
const options = {
|
||||
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
|
||||
...(typeof settings.store === "boolean" ? { store: settings.store } : {}),
|
||||
}
|
||||
if (Object.keys(options).length === 0) return {}
|
||||
return { providerOptions: options }
|
||||
return Object.keys(headers).length === 0 ? {} : { headers }
|
||||
}
|
||||
|
||||
@@ -41,8 +41,6 @@ export const layer = Layer.effect(
|
||||
[
|
||||
"SessionRunnerModel.VariantUnavailableError",
|
||||
"SessionRunnerModel.UnsupportedPackageError",
|
||||
"SessionRunnerModel.ModelConfigurationError",
|
||||
"SessionRunnerModel.ModelInitializationError",
|
||||
"SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
"SessionRunnerModel.UnsupportedCompactionError",
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as ModelResolver from "./model-resolver.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { LanguageModel, ProviderConfigurationError } from "@opencode/ai"
|
||||
import { LanguageModel } from "@opencode/ai"
|
||||
import { Auth } from "@opencode/ai/route"
|
||||
import { Context, Effect, Layer, Schema, Struct } from "effect"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
@@ -39,40 +39,6 @@ export class UnsupportedPackageError extends Schema.TaggedError<UnsupportedPacka
|
||||
}
|
||||
}
|
||||
|
||||
export const InitializationPhase = Schema.Literals(["load", "init", "construct"])
|
||||
export type InitializationPhase = typeof InitializationPhase.Type
|
||||
|
||||
/** Provider settings are missing, conflicting, or unsupported; the provider's own message tells the user what to fix. */
|
||||
export class ModelConfigurationError extends Schema.TaggedError<ModelConfigurationError>()(
|
||||
"SessionRunnerModel.ModelConfigurationError",
|
||||
{
|
||||
providerID: Provider.ID,
|
||||
modelID: ID,
|
||||
package: Schema.String,
|
||||
detail: Schema.String,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Cannot initialize ${this.providerID}/${this.modelID}: ${this.detail}`
|
||||
}
|
||||
}
|
||||
|
||||
/** A supported package failed unexpectedly while loading or constructing the model. */
|
||||
export class ModelInitializationError extends Schema.TaggedError<ModelInitializationError>()(
|
||||
"SessionRunnerModel.ModelInitializationError",
|
||||
{
|
||||
providerID: Provider.ID,
|
||||
modelID: ID,
|
||||
package: Schema.String,
|
||||
phase: InitializationPhase,
|
||||
detail: Schema.String,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Cannot initialize ${this.providerID}/${this.modelID}: ${this.detail}`
|
||||
}
|
||||
}
|
||||
|
||||
export class UnresolvedProviderVariablesError extends Schema.TaggedError<UnresolvedProviderVariablesError>()(
|
||||
"SessionRunnerModel.UnresolvedProviderVariablesError",
|
||||
{
|
||||
@@ -102,8 +68,6 @@ export class UnsupportedCompactionError extends Schema.TaggedError<UnsupportedCo
|
||||
export type Error =
|
||||
| VariantUnavailableError
|
||||
| UnsupportedPackageError
|
||||
| ModelConfigurationError
|
||||
| ModelInitializationError
|
||||
| UnresolvedProviderVariablesError
|
||||
| UnsupportedCompactionError
|
||||
| Integration.AuthorizationError
|
||||
@@ -171,11 +135,7 @@ export const fromCatalogModel = (
|
||||
dependencies?: Dependencies,
|
||||
): Effect.Effect<
|
||||
LanguageModel,
|
||||
| UnsupportedPackageError
|
||||
| ModelConfigurationError
|
||||
| ModelInitializationError
|
||||
| UnresolvedProviderVariablesError
|
||||
| UnsupportedCompactionError
|
||||
UnsupportedPackageError | UnresolvedProviderVariablesError | UnsupportedCompactionError
|
||||
> =>
|
||||
resolveCatalogModel(model, credential, dependencies).pipe(
|
||||
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
|
||||
@@ -218,16 +178,14 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
...configuration,
|
||||
}) ?? {},
|
||||
)
|
||||
return yield* loadAISDK({ ...resolved, settings }).pipe(
|
||||
Effect.mapError((error) => initialization(resolved, "init", error.cause)),
|
||||
)
|
||||
return yield* loadAISDK({ ...resolved, settings }).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
}
|
||||
if (!native) return yield* unsupported(resolved)
|
||||
|
||||
const specifier = native
|
||||
const mapped = yield* prepareProviderSettings(resolved, mapping?.settings ?? configured)
|
||||
const mapped = yield* prepareProviderSettings(resolved, Provider.nativeSettings(mapping?.settings ?? configured))
|
||||
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
|
||||
Effect.mapError((error) => initialization(resolved, "load", error.cause)),
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const settings = {
|
||||
...(credential ? Struct.omit(mapped, ["accessToken", "apiKey", "authToken"]) : mapped),
|
||||
@@ -246,15 +204,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
: runtime.compatibility,
|
||||
})
|
||||
},
|
||||
catch: (cause) =>
|
||||
cause instanceof ProviderConfigurationError
|
||||
? new ModelConfigurationError({
|
||||
providerID: resolved.providerID,
|
||||
modelID: resolved.id,
|
||||
package: resolved.package ?? "unknown",
|
||||
detail: cause.message,
|
||||
})
|
||||
: initialization(resolved, "construct", cause),
|
||||
catch: () => unsupported(resolved),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -327,24 +277,6 @@ const unsupported = (model: Info) =>
|
||||
package: model.package ?? "unknown",
|
||||
})
|
||||
|
||||
const initialization = (model: Info, phase: InitializationPhase, cause: unknown) =>
|
||||
new ModelInitializationError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
package: model.package ?? "unknown",
|
||||
phase,
|
||||
detail: causeMessage(cause) ?? `${phase} failed for ${model.package ?? "unknown"}`,
|
||||
})
|
||||
|
||||
// Unexpected throws still carry the most useful diagnosis in their message; a stack or an unknown value does not.
|
||||
const causeMessage = (cause: unknown): string | undefined => {
|
||||
if (typeof cause === "string") return cause.trim() || undefined
|
||||
if (!(cause instanceof globalThis.Error)) return undefined
|
||||
const message = cause.message.trim()
|
||||
if (message) return message
|
||||
return causeMessage(cause.cause)
|
||||
}
|
||||
|
||||
export const resolveModel = (
|
||||
model: Info,
|
||||
variant: VariantID | undefined,
|
||||
|
||||
@@ -11,7 +11,6 @@ import PROMPT_ASTRA from "./system-prompt/gpt-astra.txt"
|
||||
import PROMPT_KIMI from "./system-prompt/kimi.txt"
|
||||
import PROMPT_META from "./system-prompt/meta.txt"
|
||||
import PROMPT_TRINITY from "./system-prompt/trinity.txt"
|
||||
import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt"
|
||||
|
||||
export const OpenAIPlugin = make("opencode.prompt.openai", (model) => {
|
||||
const id = model.id.toLowerCase()
|
||||
@@ -19,19 +18,6 @@ export const OpenAIPlugin = make("opencode.prompt.openai", (model) => {
|
||||
return id.includes("gpt-6") ? PROMPT_ASTRA : PROMPT_GPT
|
||||
})
|
||||
|
||||
export const AnthropicPlugin = make(
|
||||
"opencode.prompt.anthropic",
|
||||
(model) => {
|
||||
const id = model.id.toLowerCase()
|
||||
if (!id.includes("claude")) return undefined
|
||||
return PROMPT_ANTHROPIC
|
||||
},
|
||||
"append",
|
||||
)
|
||||
|
||||
// Both OpenAIToolsPlugin and AnthropicToolsPlugin are disabled intentionally until we can figure out a good ux for displaying
|
||||
// heavy grep/glob usage done via shell or other mechanisms
|
||||
|
||||
export const OpenAIToolsPlugin = make("opencode.optimize.openai.tools", (model, tools) => {
|
||||
const ids = [model.id, model.modelID, model.family].join(" ").toLowerCase()
|
||||
if (!ids.includes("gpt")) return undefined
|
||||
@@ -59,12 +45,11 @@ export const MetaPlugin = make("opencode.prompt.meta", (model) => {
|
||||
return PROMPT_META.replaceAll("{{MODEL_NAME}}", model.name)
|
||||
})
|
||||
|
||||
export const Plugins = [OpenAIPlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
|
||||
export const Plugins = [OpenAIPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
|
||||
|
||||
function make(
|
||||
id: string,
|
||||
optimize: (model: Model.Info, tools: SessionHooks["context"]["tools"]) => string | undefined,
|
||||
mode: "override" | "append" = "override",
|
||||
) {
|
||||
return define({
|
||||
id,
|
||||
@@ -81,9 +66,7 @@ function make(
|
||||
if ((yield* ctx.agent.get({ agentID: event.agent })).data.system) return
|
||||
const system = event.system[0]
|
||||
if (!system) return
|
||||
const rendered = SessionSystemPrompt.render(template, Object.keys(event.tools))
|
||||
const text = mode === "append" ? `${system.text}\n\n${rendered}` : rendered
|
||||
event.system[0] = { ...system, text }
|
||||
event.system[0] = { ...system, text: SessionSystemPrompt.render(template, Object.keys(event.tools)) }
|
||||
}).pipe(Effect.catch(() => Effect.void))
|
||||
yield* ctx.session.hook("context", hook)
|
||||
yield* ctx.session.hook("compaction", hook)
|
||||
|
||||
@@ -295,7 +295,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
|
||||
// Console config can change independently of local credential activity, so re-fetch
|
||||
// periodically and only rebuild the catalog and search providers when the snapshot differs.
|
||||
yield* Effect.sleep(Duration.minutes(1)).pipe(
|
||||
yield* Effect.sleep(Duration.minutes(10)).pipe(
|
||||
Effect.andThen(
|
||||
loading.withPermit(
|
||||
load().pipe(Effect.flatMap((next) => (Equal.equals(snapshot, next) ? Effect.void : apply(next)))),
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Code comments
|
||||
By default, match the surrounding comment density: where the code has none, add none. Use comments sparingly, only where they are appropriate, such as for behavior that is not obvious from the code itself. Instructions from the user or the project take precedence over this guidance.
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as Provider from "./provider.js"
|
||||
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Struct } from "effect"
|
||||
import { Provider } from "@opencode/schema/provider"
|
||||
import type { ProviderPackageDefinition } from "@opencode/ai"
|
||||
import { isRecord } from "@opencode/ai/utils/record"
|
||||
@@ -69,7 +69,6 @@ const builtins = new Map<string, () => Promise<unknown>>([
|
||||
["@opencode/ai/providers/openai/chat", () => import("@opencode/ai/providers/openai/chat")],
|
||||
["@opencode/ai/providers/openai/responses", () => import("@opencode/ai/providers/openai/responses")],
|
||||
["@opencode/ai/providers/openai-compatible", () => import("@opencode/ai/providers/openai-compatible")],
|
||||
["@opencode/ai/providers/organization-routes", () => import("@opencode/ai/providers/organization-routes")],
|
||||
["@opencode/ai/providers/openrouter", () => import("@opencode/ai/providers/openrouter")],
|
||||
["@opencode/ai/providers/togetherai", () => import("@opencode/ai/providers/togetherai")],
|
||||
["@opencode/ai/providers/xai", () => import("@opencode/ai/providers/xai")],
|
||||
@@ -104,6 +103,22 @@ export const loadPackage = Effect.fn("Provider.loadPackage")(function* (input: s
|
||||
return yield* importPackage(specifier, entrypoint)
|
||||
})
|
||||
|
||||
// opencode-only; handled in aisdk.ts.
|
||||
const TRANSPORT_KEYS = ["chunkTimeout", "fetch", "timeout"] as const
|
||||
// Credentials and request overlays that must not be duplicated into providerOptions.
|
||||
const PACKAGE_KEYS = ["accessToken", "apiKey", "authToken", "baseURL", "body", "headers"] as const
|
||||
|
||||
/**
|
||||
* opencode settings are flat, but `@opencode/ai` packages still read request options from a nested
|
||||
* `providerOptions`. Until that is flattened, hand the same settings to both places and let each side
|
||||
* pick the keys it knows.
|
||||
*/
|
||||
export function nativeSettings(settings: Settings): Settings {
|
||||
const flat = Struct.omit({ ...settings.providerOptions, ...settings }, ["providerOptions", ...TRANSPORT_KEYS])
|
||||
const providerOptions = Struct.omit(flat, PACKAGE_KEYS)
|
||||
return { ...flat, ...(Object.keys(providerOptions).length === 0 ? {} : { providerOptions }) }
|
||||
}
|
||||
|
||||
export function mergeOverlay(
|
||||
base: Readonly<Record<string, unknown>> | undefined,
|
||||
overlay: Readonly<Record<string, unknown>> | undefined,
|
||||
|
||||
@@ -33,10 +33,6 @@ export const VariantUnavailableError = ModelResolver.VariantUnavailableError
|
||||
export type VariantUnavailableError = ModelResolver.VariantUnavailableError
|
||||
export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
|
||||
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
|
||||
export const ModelConfigurationError = ModelResolver.ModelConfigurationError
|
||||
export type ModelConfigurationError = ModelResolver.ModelConfigurationError
|
||||
export const ModelInitializationError = ModelResolver.ModelInitializationError
|
||||
export type ModelInitializationError = ModelResolver.ModelInitializationError
|
||||
export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
|
||||
export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
|
||||
export const UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
|
||||
|
||||
@@ -55,8 +55,6 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
cause instanceof SessionRunnerModel.ModelUnavailableError ||
|
||||
cause instanceof SessionRunnerModel.VariantUnavailableError ||
|
||||
cause instanceof SessionRunnerModel.UnsupportedPackageError ||
|
||||
cause instanceof SessionRunnerModel.ModelConfigurationError ||
|
||||
cause instanceof SessionRunnerModel.ModelInitializationError ||
|
||||
cause instanceof SessionRunnerModel.UnresolvedProviderVariablesError
|
||||
)
|
||||
return { type: "provider.no-route", message: cause.message }
|
||||
|
||||
@@ -21,12 +21,10 @@ describe("AISDKNative", () => {
|
||||
settings: {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://api.meta.ai/v1",
|
||||
providerOptions: {
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
truncation: "auto",
|
||||
},
|
||||
reasoningEffort: "xhigh",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
truncation: "auto",
|
||||
organization: "org",
|
||||
},
|
||||
})
|
||||
@@ -35,7 +33,7 @@ describe("AISDKNative", () => {
|
||||
settings: {
|
||||
baseURL: "https://example.com/v1",
|
||||
provider: "test-provider",
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -53,10 +51,8 @@ describe("AISDKNative", () => {
|
||||
settings: {
|
||||
authToken: "token",
|
||||
baseURL: "https://anthropic.example/v1",
|
||||
providerOptions: {
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "high",
|
||||
},
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "high",
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -68,7 +64,6 @@ describe("AISDKNative", () => {
|
||||
apiKey: "secret",
|
||||
baseURL: `https://${name}.example/v1`,
|
||||
headers: { "x-provider": name },
|
||||
name: "custom-provider",
|
||||
reasoningEffort: "high",
|
||||
customOption: { enabled: true },
|
||||
}),
|
||||
@@ -77,7 +72,8 @@ describe("AISDKNative", () => {
|
||||
settings: {
|
||||
apiKey: "secret",
|
||||
baseURL: `https://${name}.example/v1`,
|
||||
providerOptions: { reasoningEffort: "high", customOption: { enabled: true } },
|
||||
reasoningEffort: "high",
|
||||
customOption: { enabled: true },
|
||||
},
|
||||
headers: { "x-provider": name },
|
||||
})
|
||||
@@ -101,10 +97,8 @@ describe("AISDKNative", () => {
|
||||
settings: {
|
||||
project: "project",
|
||||
location: "us-central1",
|
||||
providerOptions: {
|
||||
labels: { environment: "test" },
|
||||
thinkingConfig: { thinkingLevel: "high" },
|
||||
},
|
||||
labels: { environment: "test" },
|
||||
thinkingConfig: { thinkingLevel: "high" },
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -123,52 +117,25 @@ describe("AISDKNative", () => {
|
||||
promptCacheKey: "session-123",
|
||||
reasoningEffort: "high",
|
||||
promptMode: "reasoning",
|
||||
fetch: "ignored",
|
||||
generateId: "ignored",
|
||||
structuredOutputs: true,
|
||||
unsupported: true,
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode/ai/providers/mistral",
|
||||
settings: {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://mistral.example/v1",
|
||||
providerOptions: {
|
||||
safePrompt: false,
|
||||
documentImageLimit: 4,
|
||||
documentPageLimit: 12,
|
||||
parallelToolCalls: false,
|
||||
promptCacheKey: "session-123",
|
||||
reasoningEffort: "high",
|
||||
promptMode: "reasoning",
|
||||
},
|
||||
safePrompt: false,
|
||||
documentImageLimit: 4,
|
||||
documentPageLimit: 12,
|
||||
parallelToolCalls: false,
|
||||
promptCacheKey: "session-123",
|
||||
reasoningEffort: "high",
|
||||
promptMode: "reasoning",
|
||||
},
|
||||
headers: { "x-provider": "mistral" },
|
||||
body: { custom: { enabled: true } },
|
||||
})
|
||||
})
|
||||
|
||||
test("omits invalid and runtime-only Mistral settings", () => {
|
||||
expect(
|
||||
map("@ai-sdk/mistral", {
|
||||
headers: { valid: "header", invalid: 1 },
|
||||
extraBody: "invalid",
|
||||
safePrompt: "false",
|
||||
documentImageLimit: "4",
|
||||
documentPageLimit: null,
|
||||
parallelToolCalls: 0,
|
||||
promptCacheKey: false,
|
||||
reasoningEffort: false,
|
||||
promptMode: "unsupported",
|
||||
fetch: "ignored",
|
||||
generateId: "ignored",
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode/ai/providers/mistral",
|
||||
settings: {},
|
||||
})
|
||||
})
|
||||
|
||||
test("maps both models.dev Bedrock packages to native providers", () => {
|
||||
expect(map("@ai-sdk/amazon-bedrock", { region: "us-east-1" })).toEqual({
|
||||
package: "@opencode/ai/providers/amazon-bedrock",
|
||||
@@ -197,7 +164,7 @@ describe("AISDKNative", () => {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
queryParams: { feature: "enabled" },
|
||||
useDeploymentBasedUrls: true,
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
reasoningEffort: "high",
|
||||
},
|
||||
})
|
||||
expect(map("@ai-sdk/azure", { ...settings, useCompletionUrls: true }, "custom-deployment")?.package).toBe(
|
||||
@@ -259,9 +226,9 @@ describe("AISDKNative", () => {
|
||||
|
||||
// GPT-5.6+ reject `reasoning_effort` and take the Responses-style nested field.
|
||||
for (const modelID of ["global.openai.gpt-5.6-sol", "us.openai.gpt-5.6-sol", "us.openai.gpt-6-astra"]) {
|
||||
expect(
|
||||
map("@ai-sdk/amazon-bedrock", { reasoningConfig: { maxReasoningEffort: "none" } }, modelID)?.body,
|
||||
).toEqual({ additionalModelRequestFields: { reasoning: { effort: "none" } } })
|
||||
expect(map("@ai-sdk/amazon-bedrock", { reasoningConfig: { maxReasoningEffort: "none" } }, modelID)?.body).toEqual(
|
||||
{ additionalModelRequestFields: { reasoning: { effort: "none" } } },
|
||||
)
|
||||
}
|
||||
expect(
|
||||
map(
|
||||
@@ -292,11 +259,9 @@ describe("AISDKNative", () => {
|
||||
apiKey: "token",
|
||||
baseURL: "https://mantle.test/v1",
|
||||
region: "us-west-2",
|
||||
providerOptions: {
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
headers: { "x-test": "value" },
|
||||
})
|
||||
@@ -330,7 +295,6 @@ describe("AISDKNative", () => {
|
||||
},
|
||||
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/v1",
|
||||
credentialProvider: "ignored",
|
||||
fetch: "ignored",
|
||||
store: false,
|
||||
},
|
||||
"openai.gpt-oss-120b",
|
||||
@@ -345,7 +309,7 @@ describe("AISDKNative", () => {
|
||||
region: "eu-west-1",
|
||||
},
|
||||
baseURL: "https://bedrock-mantle.eu-west-1.api.aws/v1",
|
||||
providerOptions: { store: false },
|
||||
store: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -389,12 +353,10 @@ describe("AISDKNative", () => {
|
||||
).toEqual({
|
||||
package: "@opencode/ai/providers/openrouter",
|
||||
settings: {
|
||||
providerOptions: {
|
||||
models: ["anthropic/claude-sonnet-4.6"],
|
||||
provider: { only: ["anthropic"], require_parameters: true },
|
||||
reasoning: { effort: "high" },
|
||||
future_option: { enabled: true },
|
||||
},
|
||||
models: ["anthropic/claude-sonnet-4.6"],
|
||||
provider: { only: ["anthropic"], require_parameters: true },
|
||||
reasoning: { effort: "high" },
|
||||
future_option: { enabled: true },
|
||||
},
|
||||
headers: {
|
||||
"x-openrouter-title": "Configured",
|
||||
@@ -415,21 +377,18 @@ describe("AISDKNative", () => {
|
||||
thinkingBudget: 0,
|
||||
includeThoughts: false,
|
||||
thinkingLevel: "high",
|
||||
unknown: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode/ai/providers/google",
|
||||
settings: {
|
||||
providerOptions: {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
|
||||
serviceTier: "flex",
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 0,
|
||||
includeThoughts: false,
|
||||
thinkingLevel: "high",
|
||||
},
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
|
||||
serviceTier: "flex",
|
||||
thinkingConfig: {
|
||||
thinkingBudget: 0,
|
||||
includeThoughts: false,
|
||||
thinkingLevel: "high",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -438,7 +397,7 @@ describe("AISDKNative", () => {
|
||||
test("maps Google thinking settings independently", () => {
|
||||
for (const thinkingConfig of [{ thinkingBudget: -1 }, { includeThoughts: true }, { thinkingLevel: "medium" }]) {
|
||||
expect(map("@ai-sdk/google", { thinkingConfig })).toMatchObject({
|
||||
settings: { providerOptions: { thinkingConfig } },
|
||||
settings: { thinkingConfig },
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -452,11 +411,9 @@ describe("AISDKNative", () => {
|
||||
}),
|
||||
).toMatchObject({
|
||||
settings: {
|
||||
providerOptions: {
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
|
||||
serviceTier: "future-tier",
|
||||
},
|
||||
cachedContent: "cachedContents/example",
|
||||
safetySettings: [{ category: "HARM_CATEGORY_HARASSMENT", threshold: "BLOCK_NONE" }],
|
||||
serviceTier: "future-tier",
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -479,10 +436,8 @@ describe("AISDKNative", () => {
|
||||
baseURL: "https://vertex.example/v1",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
providerOptions: {
|
||||
labels: { component: "opencode", environment: "test" },
|
||||
thinkingConfig: { thinkingLevel: "high" },
|
||||
},
|
||||
labels: { component: "opencode", environment: "test" },
|
||||
thinkingConfig: { thinkingLevel: "high" },
|
||||
},
|
||||
headers: { "x-test": "value" },
|
||||
})
|
||||
@@ -506,10 +461,8 @@ describe("AISDKNative", () => {
|
||||
baseURL: "https://vertex.example/v1",
|
||||
location: "eu",
|
||||
project: "vertex-project",
|
||||
providerOptions: {
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "high",
|
||||
},
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
effort: "high",
|
||||
},
|
||||
headers: { "x-test": "value" },
|
||||
})
|
||||
@@ -528,28 +481,9 @@ describe("AISDKNative", () => {
|
||||
settings: {
|
||||
apiKey: "secret",
|
||||
baseURL: "https://xai.example/v1",
|
||||
providerOptions: {
|
||||
reasoningEffort: "custom",
|
||||
store: true,
|
||||
},
|
||||
reasoningEffort: "custom",
|
||||
store: true,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("omits invalid and unsupported xAI settings", () => {
|
||||
expect(
|
||||
map("@ai-sdk/xai", {
|
||||
reasoningEffort: 10,
|
||||
store: "yes",
|
||||
include: ["unknown"],
|
||||
logprobs: true,
|
||||
topLogprobs: 8,
|
||||
previousResponseId: "response-id",
|
||||
searchParameters: { mode: "auto" },
|
||||
}),
|
||||
).toEqual({
|
||||
package: "@opencode/ai/providers/xai",
|
||||
settings: {},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -855,6 +855,7 @@ describe("ModelResolver", () => {
|
||||
expect(modelID).toBe("api-test-model")
|
||||
expect(settings).toEqual({
|
||||
region: "test",
|
||||
providerOptions: { region: "test" },
|
||||
headers: { "x-package": "header" },
|
||||
body: { custom: true },
|
||||
})
|
||||
@@ -1039,11 +1040,7 @@ describe("ModelResolver", () => {
|
||||
const packages = [
|
||||
["@ai-sdk/anthropic", "@opencode/ai/providers/anthropic", "api-model"],
|
||||
["@ai-sdk/amazon-bedrock", "@opencode/ai/providers/amazon-bedrock", "api-model"],
|
||||
[
|
||||
"@ai-sdk/amazon-bedrock/mantle",
|
||||
"@opencode/ai/providers/amazon-bedrock/mantle/chat",
|
||||
"openai.gpt-oss-120b",
|
||||
],
|
||||
["@ai-sdk/amazon-bedrock/mantle", "@opencode/ai/providers/amazon-bedrock/mantle/chat", "openai.gpt-oss-120b"],
|
||||
["@ai-sdk/azure", "@opencode/ai/providers/azure/responses", "api-model"],
|
||||
["@ai-sdk/cerebras", "@opencode/ai/providers/cerebras", "api-model"],
|
||||
["@ai-sdk/deepinfra", "@opencode/ai/providers/deepinfra", "api-model"],
|
||||
@@ -1395,81 +1392,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports provider configuration errors from supported packages", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/azure"), {
|
||||
providerID: Provider.ID.azure,
|
||||
modelID: "gpt-5.4-nano",
|
||||
}),
|
||||
Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("oauth"),
|
||||
access: "oauth-token",
|
||||
refresh: "refresh",
|
||||
expires: Date.now() + 60_000,
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.ModelConfigurationError",
|
||||
providerID: "azure",
|
||||
modelID: "test-model",
|
||||
package: "aisdk:@ai-sdk/azure",
|
||||
detail: "Azure requires resourceName or baseURL",
|
||||
})
|
||||
expect(failure.message).toBe("Cannot initialize azure/test-model: Azure requires resourceName or baseURL")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("distinguishes unexpected constructor failures from configuration errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* ModelResolver.fromCatalogModel(model("@opencode/ai/providers/custom"), undefined, {
|
||||
loadPackage: () =>
|
||||
Effect.succeed({
|
||||
model: () => {
|
||||
throw new Error("custom provider crashed")
|
||||
},
|
||||
}),
|
||||
}).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.ModelInitializationError",
|
||||
phase: "construct",
|
||||
detail: "custom provider crashed",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports package load and AISDK initialization failures with their causes", () =>
|
||||
Effect.gen(function* () {
|
||||
const load = yield* ModelResolver.fromCatalogModel(model("@opencode/ai/providers/custom"), undefined, {
|
||||
loadPackage: (specifier) =>
|
||||
Effect.fail(
|
||||
new Provider.LoadError({ package: specifier, cause: new Error(`Provider package ${specifier} is broken`) }),
|
||||
),
|
||||
}).pipe(Effect.flip)
|
||||
expect(load).toMatchObject({
|
||||
_tag: "SessionRunnerModel.ModelInitializationError",
|
||||
phase: "load",
|
||||
detail: "Provider package @opencode/ai/providers/custom is broken",
|
||||
})
|
||||
|
||||
const init = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/cohere")), undefined, {
|
||||
loadAISDK: (runtime) =>
|
||||
Effect.fail(
|
||||
new AISDK.InitError({ providerID: runtime.providerID, cause: new Error("Cohere plugin failed") }),
|
||||
),
|
||||
}).pipe(Effect.flip)
|
||||
expect(init).toMatchObject({
|
||||
_tag: "SessionRunnerModel.ModelInitializationError",
|
||||
phase: "init",
|
||||
detail: "Cohere plugin failed",
|
||||
})
|
||||
expect(init.message).toBe("Cannot initialize test-provider/test-model: Cohere plugin failed")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("drops an empty API key before loading an AISDK package", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* ModelResolver.fromCatalogModel(
|
||||
|
||||
@@ -19,11 +19,9 @@ import PROMPT_GPT from "../../src/plugin/system-prompt/gpt.txt"
|
||||
import PROMPT_ASTRA from "../../src/plugin/system-prompt/gpt-astra.txt"
|
||||
import PROMPT_KIMI from "../../src/plugin/system-prompt/kimi.txt"
|
||||
import PROMPT_TRINITY from "../../src/plugin/system-prompt/trinity.txt"
|
||||
import PROMPT_ANTHROPIC from "../../src/plugin/system-prompt/anthropic.txt"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const fallback = SessionSystemPrompt.make([])
|
||||
const appended = `${fallback}\n\n${SessionSystemPrompt.render(PROMPT_ANTHROPIC, [])}`
|
||||
const makeHost = Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
@@ -50,7 +48,6 @@ describe("OptimizePlugin", () => {
|
||||
test("enables prompt plugins without model-specific tool optimization", () => {
|
||||
expect(OptimizePlugin.Plugins.map((plugin) => plugin.id)).toEqual([
|
||||
"opencode.prompt.openai",
|
||||
"opencode.prompt.anthropic",
|
||||
"opencode.prompt.kimi",
|
||||
"opencode.prompt.arcee",
|
||||
"opencode.prompt.meta",
|
||||
@@ -79,7 +76,7 @@ describe("OptimizePlugin", () => {
|
||||
["gpt-5-codex", PROMPT_GPT],
|
||||
["gpt-6-astra", PROMPT_ASTRA],
|
||||
["gemini-2.5-pro", fallback],
|
||||
["claude-sonnet-4", appended],
|
||||
["claude-sonnet-4", fallback],
|
||||
["kimi-k2", PROMPT_KIMI],
|
||||
["trinity", PROMPT_TRINITY],
|
||||
["meta/muse-spark-1.1", PROMPT_META.replaceAll("{{MODEL_NAME}}", "Muse Spark")],
|
||||
@@ -131,30 +128,6 @@ describe("OptimizePlugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("appends the Anthropic prompt to the baseline without changing tools or project instructions", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const pluginHost = yield* makeHost
|
||||
yield* catalog.transform((editor) =>
|
||||
editor.model.update(Provider.ID.make("test"), Model.ID.make("claude-sonnet-4"), () => {}),
|
||||
)
|
||||
yield* OptimizePlugin.AnthropicPlugin.effect(pluginHost)
|
||||
const event = context("claude-sonnet-4")
|
||||
event.system.push(SystemPart.make("Project instructions"))
|
||||
|
||||
yield* hooks.trigger("session", "context", event)
|
||||
|
||||
const baseline = SessionSystemPrompt.render(fallback, Object.keys(event.tools))
|
||||
expect(event.system.map((part) => part.text)).toEqual([
|
||||
`${baseline}\n\n${SessionSystemPrompt.render(PROMPT_ANTHROPIC, Object.keys(event.tools))}`,
|
||||
"Project instructions",
|
||||
])
|
||||
expect(event.system[0]?.text.startsWith(baseline)).toBe(true)
|
||||
expect(Object.keys(event.tools).sort()).toEqual(["edit", "glob", "grep", "patch", "read", "shell", "write"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("curates search tools across providers without changing editing tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
@@ -325,7 +298,7 @@ describe("OptimizePlugin", () => {
|
||||
["codex-family-alias", "custom-deployment", "GPT-CODEX", fallback],
|
||||
["astra-api-alias", "gpt-6-astra", undefined, fallback],
|
||||
["astra-family-alias", "custom-deployment", "gpt-6", fallback],
|
||||
["claude-catalog-alias", "custom-model", undefined, appended],
|
||||
["claude-catalog-alias", "custom-model", undefined, fallback],
|
||||
["anthropic-api-alias", "Claude-Opus-4-8", undefined, fallback],
|
||||
["anthropic-family-alias", "custom-deployment", "CLAUDE-SONNET", fallback],
|
||||
] as const
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LLM, Message } from "@opencode/ai"
|
||||
import { LLM } from "@opencode/ai"
|
||||
import { LLMClient, RequestExecutor } from "@opencode/ai/route"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
@@ -538,213 +538,6 @@ 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(() => {
|
||||
@@ -784,26 +577,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 })
|
||||
|
||||
@@ -19,7 +19,6 @@ 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",
|
||||
]
|
||||
|
||||
@@ -28,4 +27,24 @@ describe("Provider", () => {
|
||||
expect(loaded.model).toBeFunction()
|
||||
}
|
||||
})
|
||||
|
||||
test("offers flat settings to native packages as both connection settings and request options", () => {
|
||||
expect(
|
||||
Provider.nativeSettings({
|
||||
apiKey: "secret",
|
||||
baseURL: "https://example.com/v1",
|
||||
region: "us-east-1",
|
||||
reasoningEffort: "high",
|
||||
chunkTimeout: 1000,
|
||||
providerOptions: { textVerbosity: "low" },
|
||||
}),
|
||||
).toEqual({
|
||||
apiKey: "secret",
|
||||
baseURL: "https://example.com/v1",
|
||||
region: "us-east-1",
|
||||
reasoningEffort: "high",
|
||||
textVerbosity: "low",
|
||||
providerOptions: { region: "us-east-1", reasoningEffort: "high", textVerbosity: "low" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -140,30 +140,6 @@ describe("toSessionError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves provider configuration and initialization errors", () => {
|
||||
const configuration = new ModelResolver.ModelConfigurationError({
|
||||
providerID: Provider.ID.make("azure"),
|
||||
modelID: ID.make("gpt-5.4-nano"),
|
||||
package: "aisdk:@ai-sdk/azure",
|
||||
detail: "Azure requires resourceName or baseURL",
|
||||
})
|
||||
expect(toSessionError(configuration)).toEqual({
|
||||
type: "provider.no-route",
|
||||
message: "Cannot initialize azure/gpt-5.4-nano: Azure requires resourceName or baseURL",
|
||||
})
|
||||
const initialization = new ModelResolver.ModelInitializationError({
|
||||
providerID: Provider.ID.make("custom"),
|
||||
modelID: ID.make("model"),
|
||||
package: "@opencode/ai/providers/custom",
|
||||
phase: "load",
|
||||
detail: "Provider package @opencode/ai/providers/custom is broken",
|
||||
})
|
||||
expect(toSessionError(initialization)).toEqual({
|
||||
type: "provider.no-route",
|
||||
message: "Cannot initialize custom/model: Provider package @opencode/ai/providers/custom is broken",
|
||||
})
|
||||
})
|
||||
|
||||
test("retries rate limits, provider-internal, transport, and unrecognized failures", () => {
|
||||
const eligible = [
|
||||
llm(new RateLimitError({ message: "rate" })),
|
||||
|
||||
@@ -1151,62 +1151,3 @@ 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,
|
||||
])
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ it.live(
|
||||
Effect.provideService(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) => {
|
||||
expect(request.url).toBe("https://registry.npmjs.org/@opencode%2fcli/beta")
|
||||
expect(request.url).toBe("https://registry.npmjs.org/@opencode-ai%2fcli/beta")
|
||||
return Effect.succeed(HttpClientResponse.fromWeb(request, response))
|
||||
}),
|
||||
),
|
||||
@@ -65,7 +65,7 @@ posix(
|
||||
|
||||
test("pins platform-specific artifacts and rejects unsafe inputs", () => {
|
||||
expect(RemoteCli.archiveUrl("linux-x64-baseline-musl", "2.0.0-beta.1")).toBe(
|
||||
"https://registry.npmjs.org/@opencode/cli-linux-x64-baseline-musl/-/cli-linux-x64-baseline-musl-2.0.0-beta.1.tgz",
|
||||
"https://registry.npmjs.org/@opencode-ai/cli-linux-x64-baseline-musl/-/cli-linux-x64-baseline-musl-2.0.0-beta.1.tgz",
|
||||
)
|
||||
expect(() => RemoteCli.installScript({ version: '2.0.0"; whoami', source: { type: "installer" } })).toThrow()
|
||||
expect(() => RemoteCli.archiveUrl("linux-x64;whoami", "2.0.0")).toThrow()
|
||||
|
||||
@@ -68,7 +68,7 @@ printf 'OPENCODE_REMOTE_TARGET=%s\\n' "$target"
|
||||
export function archiveUrl(target: string, version: string) {
|
||||
if (!/^(linux|darwin)-(x64-baseline|arm64)(-musl)?$/.test(target))
|
||||
throw new Failure({ code: "platform", detail: target })
|
||||
return `https://registry.npmjs.org/@opencode/cli-${target}/-/cli-${target}-${requireVersion(version)}.tgz`
|
||||
return `https://registry.npmjs.org/@opencode-ai/cli-${target}/-/cli-${target}-${requireVersion(version)}.tgz`
|
||||
}
|
||||
|
||||
type Source = { type: "download"; url: string } | { type: "archive" } | { type: "installer"; binary?: string }
|
||||
@@ -114,12 +114,12 @@ const Beta = Schema.Struct({ version: Schema.String.check(Schema.isPattern(/^0\.
|
||||
|
||||
export const latestBeta = Effect.fn("RemoteCli.latestBeta")(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const metadata = yield* http.get("https://registry.npmjs.org/@opencode%2fcli/beta").pipe(
|
||||
const metadata = yield* http.get("https://registry.npmjs.org/@opencode-ai%2fcli/beta").pipe(
|
||||
Effect.flatMap(HttpClientResponse.filterStatusOk),
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(Beta)),
|
||||
Effect.timeout("30 seconds"),
|
||||
Effect.mapError(
|
||||
() => new Failure({ code: "install", detail: "https://registry.npmjs.org/@opencode%2fcli/beta" }),
|
||||
() => new Failure({ code: "install", detail: "https://registry.npmjs.org/@opencode-ai%2fcli/beta" }),
|
||||
),
|
||||
)
|
||||
return metadata.version
|
||||
|
||||
@@ -9,7 +9,6 @@ export function createDesktopNotify(api: ElectronAPI): Platform["notify"] {
|
||||
const notification = new Notification(title, {
|
||||
body: description ?? "",
|
||||
icon: "https://opencode.ai/favicon-96x96-v3.png",
|
||||
silent: true,
|
||||
})
|
||||
notification.onclick = () => {
|
||||
void api.showWindow()
|
||||
|
||||
@@ -1280,100 +1280,6 @@ flowchart TD
|
||||
])
|
||||
})
|
||||
|
||||
test("expands & node groups into fan-in and fan-out edges", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
|
||||
N[Native] & M[Mapped] & O --> LM["LanguageModel"]
|
||||
LM -->|prepare| REQ & LOG`)
|
||||
|
||||
expect(diagram.nodes).toEqual([
|
||||
{ id: "N", label: "Native", shape: "box" },
|
||||
{ id: "M", label: "Mapped", shape: "box" },
|
||||
{ id: "O", label: "O", shape: "box" },
|
||||
{ id: "LM", label: "LanguageModel", shape: "box" },
|
||||
{ id: "REQ", label: "REQ", shape: "box" },
|
||||
{ id: "LOG", label: "LOG", shape: "box" },
|
||||
])
|
||||
expect(diagram.edges).toEqual([
|
||||
{ from: "N", to: "LM", label: "" },
|
||||
{ from: "M", to: "LM", label: "" },
|
||||
{ from: "O", to: "LM", label: "" },
|
||||
{ from: "LM", to: "REQ", label: "prepare" },
|
||||
{ from: "LM", to: "LOG", label: "prepare" },
|
||||
])
|
||||
})
|
||||
|
||||
test("expands & groups on both sides of an edge and through a chain", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
|
||||
A & B --> C & D --> E`)
|
||||
|
||||
expect(diagram.edges).toEqual([
|
||||
{ from: "A", to: "C", label: "" },
|
||||
{ from: "A", to: "D", label: "" },
|
||||
{ from: "B", to: "C", label: "" },
|
||||
{ from: "B", to: "D", label: "" },
|
||||
{ from: "C", to: "E", label: "" },
|
||||
{ from: "D", to: "E", label: "" },
|
||||
])
|
||||
})
|
||||
|
||||
test("declares every node of a bare & group inside the current subgraph", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart TD
|
||||
subgraph Runtime
|
||||
A[Alpha] & B[Beta]:::focus
|
||||
end
|
||||
A --> B`)
|
||||
|
||||
expect(diagram.nodes).toEqual([
|
||||
{ id: "A", label: "Alpha", shape: "box" },
|
||||
{ id: "B", label: "Beta", shape: "box" },
|
||||
])
|
||||
expect(diagram.subgraphs?.[0]?.nodeIds).toEqual(["A", "B"])
|
||||
})
|
||||
|
||||
test("keeps & inside quoted or bracketed labels as label text", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
|
||||
A["Fetch & parse"] & B[R&D] --> C[Done & dusted]`)
|
||||
|
||||
expect(diagram.nodes).toEqual([
|
||||
{ id: "A", label: "Fetch & parse", shape: "box" },
|
||||
{ id: "B", label: "R&D", shape: "box" },
|
||||
{ id: "C", label: "Done & dusted", shape: "box" },
|
||||
])
|
||||
expect(diagram.edges).toEqual([
|
||||
{ from: "A", to: "C", label: "" },
|
||||
{ from: "B", to: "C", label: "" },
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps & inside edge labels as label text", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
|
||||
X[a & b] -->|x & y| Y`)
|
||||
|
||||
expect(diagram.nodes.find((node) => node.id === "X")?.label).toBe("a & b")
|
||||
expect(diagram.edges).toEqual([{ from: "X", to: "Y", label: "x & y" }])
|
||||
})
|
||||
|
||||
test("rejects empty & group members", () => {
|
||||
for (const statement of ["A & --> B", "& A --> B", "A --> B &", "A &"]) {
|
||||
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n ${statement}`)).toThrow(
|
||||
`Unsupported syntax in flowchart diagram at line 2: "${statement}"`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test("renders a fan-in expressed with & the same as separate edge statements", () => {
|
||||
const grouped = renderFlowchartDiagram(`flowchart LR
|
||||
N & M & O --> LM[LanguageModel] --> REQ[LLMRequest]`)
|
||||
const separate = renderFlowchartDiagram(`flowchart LR
|
||||
N --> LM[LanguageModel]
|
||||
M --> LM
|
||||
O --> LM
|
||||
LM --> REQ[LLMRequest]`)
|
||||
|
||||
expect(grouped).toBe(separate)
|
||||
expect(grouped).toContain("LanguageModel")
|
||||
})
|
||||
|
||||
test("parses chained undirected solid edges", () => {
|
||||
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
|
||||
A --- B --- C`)
|
||||
|
||||
@@ -127,41 +127,6 @@ function stripNodeToken(token: string): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** Split an `&`-joined node group, leaving `&` inside labels (brackets or quotes) untouched. */
|
||||
function splitNodeGroup(token: string): string[] {
|
||||
const groups: string[] = []
|
||||
const stack: string[] = []
|
||||
let quote: '"' | "'" | undefined
|
||||
let start = 0
|
||||
const closes: Record<string, string> = { "[": "]", "(": ")", "{": "}" }
|
||||
|
||||
for (let index = 0; index < token.length; index++) {
|
||||
const character = token[index]!
|
||||
if (quote) {
|
||||
if (character === quote && token[index - 1] !== "\\") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (character === '"' || character === "'") {
|
||||
quote = character
|
||||
continue
|
||||
}
|
||||
if (character in closes) {
|
||||
stack.push(character)
|
||||
continue
|
||||
}
|
||||
if (stack.length > 0 && character === closes[stack.at(-1)!]) {
|
||||
stack.pop()
|
||||
continue
|
||||
}
|
||||
if (stack.length === 0 && character === "&") {
|
||||
groups.push(token.slice(start, index))
|
||||
start = index + 1
|
||||
}
|
||||
}
|
||||
groups.push(token.slice(start))
|
||||
return groups
|
||||
}
|
||||
|
||||
function edgeStyleFromArrow(...arrows: string[]): FlowchartEdgeStyle | undefined {
|
||||
if (arrows.some((arrow) => arrow.includes("=="))) return "thick"
|
||||
if (arrows.some((arrow) => arrow.includes("."))) return "dashed"
|
||||
@@ -340,57 +305,51 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
|
||||
|
||||
const edgeOperators = parseEdgeOperators(line)
|
||||
if (edgeOperators.length > 0) {
|
||||
// Each chain position may be an `&` group (`A & B --> C`), so endpoints are lists of node tokens.
|
||||
const nodeGroups = [
|
||||
const nodeTokens = [
|
||||
line.slice(0, edgeOperators[0]!.index),
|
||||
...edgeOperators.map((operator, index) =>
|
||||
line.slice(operator.end, edgeOperators[index + 1]?.index ?? line.length),
|
||||
),
|
||||
].map((group) => splitNodeGroup(group).map(stripNodeToken))
|
||||
]
|
||||
|
||||
if (nodeGroups.every((group) => group.every((token) => token.length > 0))) {
|
||||
const unsupportedEndpoint = nodeGroups.find((group, index) => {
|
||||
if (nodeTokens.every((token) => stripNodeToken(token).length > 0)) {
|
||||
const unsupportedEndpoint = nodeTokens.find((token, index) => {
|
||||
const stripped = stripNodeToken(token)
|
||||
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
|
||||
return group.some(
|
||||
(stripped) =>
|
||||
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
|
||||
!isSupportedNodeToken(stripped),
|
||||
return (
|
||||
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
|
||||
!isSupportedNodeToken(stripped)
|
||||
)
|
||||
})
|
||||
if (unsupportedEndpoint) throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
|
||||
const chainNodeIds = nodeGroups.map((group, index) => {
|
||||
const chainNodeIds = nodeTokens.map((token, index) => {
|
||||
const stripped = stripNodeToken(token)
|
||||
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
|
||||
return group.map((stripped) => {
|
||||
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
|
||||
return ensureNode(nodes, stripped).id
|
||||
})
|
||||
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
|
||||
return ensureNode(nodes, stripped).id
|
||||
})
|
||||
for (const nodeId of chainNodeIds.flat()) {
|
||||
for (const nodeId of chainNodeIds) {
|
||||
if (nodes.has(nodeId)) addNodeToSubgraph(currentSubgraph, nodeId)
|
||||
}
|
||||
for (let index = 0; index < edgeOperators.length; index++) {
|
||||
const operator = edgeOperators[index]!
|
||||
for (const from of chainNodeIds[index]!) {
|
||||
for (const to of chainNodeIds[index + 1]!) {
|
||||
const edge = createEdge(
|
||||
from,
|
||||
to,
|
||||
operator.label,
|
||||
operator.style,
|
||||
operator.arrowhead,
|
||||
operator.sourceArrowhead,
|
||||
)
|
||||
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
|
||||
}
|
||||
}
|
||||
const edge = createEdge(
|
||||
chainNodeIds[index]!,
|
||||
chainNodeIds[index + 1]!,
|
||||
operator.label,
|
||||
operator.style,
|
||||
operator.arrowhead,
|
||||
operator.sourceArrowhead,
|
||||
)
|
||||
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const nodeGroup = splitNodeGroup(line)
|
||||
if (nodeGroup.every(isSupportedNodeToken)) {
|
||||
for (const token of nodeGroup) addNodeToSubgraph(currentSubgraph, ensureNode(nodes, stripNodeToken(token)).id)
|
||||
if (isSupportedNodeToken(line)) {
|
||||
const node = ensureNode(nodes, line)
|
||||
addNodeToSubgraph(currentSubgraph, node.id)
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("parser diagnostics", () => {
|
||||
})
|
||||
|
||||
test("does not partially parse unsupported flowchart syntax", () => {
|
||||
for (const statement of ["A & --> C", "A((Start)) --> B", "A-->B; B-->C"]) {
|
||||
for (const statement of ["A & B --> C", "A((Start)) --> B", "A-->B; B-->C"]) {
|
||||
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n ${statement}`)).toThrow(MermaidSyntaxError)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -597,7 +597,7 @@ function groupContent(
|
||||
detail?: TimelineDetail,
|
||||
): PartGroup[] {
|
||||
const groups: PartGroup[] = []
|
||||
let adjacent: { type: "context" | "file"; refs: PartRef[]; tools: boolean } | undefined
|
||||
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[]; tools: boolean } | undefined
|
||||
const flush = () => {
|
||||
const current = adjacent
|
||||
const first = current?.refs[0]
|
||||
@@ -665,7 +665,8 @@ function toolGroupType(
|
||||
const category = timelineCategory(content)!
|
||||
if (detail[category].placement === "grouped") return "context"
|
||||
if (currentToolFailed(content)) return undefined
|
||||
if (content.name === "patch" || content.name === "edit" || content.name === "write") return "file"
|
||||
if (content.name === "patch") return "patch"
|
||||
if (content.name === "edit") return "edit"
|
||||
return undefined
|
||||
}
|
||||
if (content.name === "question" || currentToolHasLoadedFiles(content)) return undefined
|
||||
@@ -683,7 +684,8 @@ function toolGroupType(
|
||||
)
|
||||
return undefined
|
||||
if (currentContentDefaultOpen(content, shellExpanded, editExpanded) !== true) return "context"
|
||||
if (content.name === "patch" || content.name === "edit" || content.name === "write") return "file"
|
||||
if (content.name === "patch") return "patch"
|
||||
if (content.name === "edit") return "edit"
|
||||
return undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -691,13 +691,6 @@ describe("current session timeline rows", () => {
|
||||
state: { status: "running", input: {}, metadata: { files: [] } },
|
||||
time: { created: 10 },
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
id: "tool_write_1",
|
||||
name: "write",
|
||||
state: { status: "running", input: {}, metadata: { files: [] } },
|
||||
time: { created: 11 },
|
||||
},
|
||||
],
|
||||
time: { created: 2, completed: 8 },
|
||||
},
|
||||
@@ -723,11 +716,14 @@ describe("current session timeline rows", () => {
|
||||
{
|
||||
type: "file",
|
||||
key: "part:msg_assistant:tool_patch_3",
|
||||
refs: [{ messageID: "msg_assistant", partID: "tool_patch_3" }],
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
key: "part:msg_assistant:tool_edit_1",
|
||||
refs: [
|
||||
{ messageID: "msg_assistant", partID: "tool_patch_3" },
|
||||
{ messageID: "msg_assistant", partID: "tool_edit_1" },
|
||||
{ messageID: "msg_assistant", partID: "tool_edit_2" },
|
||||
{ messageID: "msg_assistant", partID: "tool_write_1" },
|
||||
],
|
||||
},
|
||||
])
|
||||
@@ -794,8 +790,8 @@ describe("current session timeline rows", () => {
|
||||
test.each([
|
||||
{ shell: false, edit: false, types: ["context"] },
|
||||
{ shell: true, edit: false, types: ["part", "context"] },
|
||||
{ shell: false, edit: true, types: ["context", "file", "context"] },
|
||||
{ shell: true, edit: true, types: ["part", "file", "context"] },
|
||||
{ shell: false, edit: true, types: ["context", "file", "part", "file", "context"] },
|
||||
{ shell: true, edit: true, types: ["part", "file", "part", "file", "context"] },
|
||||
])("keeps tools expanded by settings outside collapsed groups ($shell, $edit)", ({ shell, edit, types }) => {
|
||||
const source = [
|
||||
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
|
||||
|
||||
@@ -16,12 +16,12 @@ import {
|
||||
type JSX,
|
||||
} from "solid-js"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { type SessionSummary, useData } from "../context"
|
||||
import { useFileComponent } from "@opencode/ui/context/file"
|
||||
import { type UiI18n, useI18n } from "@opencode/ui/context/i18n"
|
||||
import { BasicTool, GenericTool } from "../components/basic-tool"
|
||||
import { Collapsible } from "@opencode/ui/collapsible"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { Icon, type IconProps } from "@opencode/ui/icon"
|
||||
import { ToolErrorCard } from "../components/tool-error-card"
|
||||
@@ -837,24 +837,8 @@ export function CurrentFileToolGroup(props: {
|
||||
const files = createMemo((previous: { key: string; toolID: string; value: unknown }[]) => {
|
||||
const next = props.tools.flatMap((tool) => {
|
||||
const files = currentToolMetadata(tool).files
|
||||
if (Array.isArray(files) && files.length > 0)
|
||||
return files.map((value, index) => ({ key: `${tool.id}:${index}`, toolID: tool.id, value }))
|
||||
if (tool.name !== "write") return []
|
||||
const input = currentToolInput(tool)
|
||||
if (typeof input.path !== "string" || typeof input.content !== "string" || !input.content) return []
|
||||
return [
|
||||
{
|
||||
key: `${tool.id}:0`,
|
||||
toolID: tool.id,
|
||||
value: {
|
||||
file: input.path,
|
||||
patch: createTwoFilesPatch(input.path, input.path, "", input.content),
|
||||
additions: input.content.split("\n").length - Number(input.content.endsWith("\n")),
|
||||
deletions: 0,
|
||||
status: "modified",
|
||||
},
|
||||
},
|
||||
]
|
||||
if (!Array.isArray(files)) return []
|
||||
return files.map((value, index) => ({ key: `${tool.id}:${index}`, toolID: tool.id, value }))
|
||||
})
|
||||
const updates = new Map(next.map((entry) => [entry.key, entry.value]))
|
||||
const existing = new Set(previous.map((entry) => entry.key))
|
||||
@@ -880,10 +864,7 @@ export function CurrentFileToolGroup(props: {
|
||||
props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
|
||||
)
|
||||
const render = ToolRegistry.render("patch") ?? GenericTool
|
||||
const tool = createMemo(() => {
|
||||
const name = props.tools[0]?.name
|
||||
return name === "edit" || name === "write" ? name : "patch"
|
||||
})
|
||||
const tool = createMemo(() => (props.tools[0]?.name === "edit" ? "edit" : "patch"))
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -998,7 +979,7 @@ export const ToolRegistry = {
|
||||
render: getTool,
|
||||
}
|
||||
|
||||
function FileTool(props: ToolProps & { title: string; count: number; children?: JSX.Element }) {
|
||||
function FileTool(props: ToolProps & { title: string; count: number; children: JSX.Element }) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<BasicTool
|
||||
@@ -1939,37 +1920,40 @@ ToolRegistry.register({
|
||||
ToolRegistry.register({
|
||||
name: "write",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
const path = createMemo(() => (typeof props.input.path === "string" ? props.input.path : ""))
|
||||
const content = createMemo(() => (typeof props.input.content === "string" ? props.input.content : ""))
|
||||
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, path()))
|
||||
return (
|
||||
<div data-component="write-tool">
|
||||
<Show when={content() && path()}>
|
||||
<ToolFileAccordion
|
||||
path={path()}
|
||||
defaultOpen={props.defaultOpen}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
forceOpen={props.forceOpen}
|
||||
defer={props.deferContent !== false}
|
||||
>
|
||||
<div data-component="write-content">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="text"
|
||||
file={{
|
||||
name: path(),
|
||||
contents: content(),
|
||||
cacheKey: checksum(content()),
|
||||
}}
|
||||
overflow="scroll"
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
</Show>
|
||||
<DiagnosticsDisplay diagnostics={diagnostics()} />
|
||||
<FileTool {...props} title={i18n.t("ui.messagePart.title.write")} count={path() ? 1 : 0}>
|
||||
<Show when={path()}>
|
||||
<ToolFileAccordion
|
||||
path={path()}
|
||||
defaultOpen={props.defaultOpen}
|
||||
open={props.open}
|
||||
onOpenChange={props.onOpenChange}
|
||||
forceOpen={props.forceOpen}
|
||||
defer={props.deferContent !== false}
|
||||
>
|
||||
<div data-component="write-content">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="text"
|
||||
file={{
|
||||
name: path(),
|
||||
contents: content(),
|
||||
cacheKey: checksum(content()),
|
||||
}}
|
||||
overflow="scroll"
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
</ToolFileAccordion>
|
||||
</Show>
|
||||
<DiagnosticsDisplay diagnostics={diagnostics()} />
|
||||
</FileTool>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -1983,11 +1967,7 @@ ToolRegistry.register({
|
||||
const files = createMemo(() => patchFileGroups(props.metadata.files))
|
||||
const [expanded, setExpanded] = createSignal<string[]>([])
|
||||
const title = createMemo(() =>
|
||||
props.tool === "edit"
|
||||
? i18n.t("ui.messagePart.title.edit")
|
||||
: props.tool === "write"
|
||||
? i18n.t("ui.messagePart.title.write")
|
||||
: i18n.t("ui.tool.patch"),
|
||||
props.tool === "edit" ? i18n.t("ui.messagePart.title.edit") : i18n.t("ui.tool.patch"),
|
||||
)
|
||||
const open = createMemo(() => {
|
||||
if (!props.fileOpen) return expanded()
|
||||
@@ -2004,90 +1984,92 @@ ToolRegistry.register({
|
||||
|
||||
return (
|
||||
<div data-component="apply-patch-tool">
|
||||
<Show when={files().length > 0} fallback={<FileTool {...props} title={title()} count={0} />}>
|
||||
<FileAccordionGroup>
|
||||
<Index each={files()}>
|
||||
{(file) => {
|
||||
const value = () => file().path
|
||||
const active = createMemo(() => open().includes(value()))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
<FileTool {...props} title={title()} count={files().length}>
|
||||
<Show when={files().length > 0}>
|
||||
<FileAccordionGroup>
|
||||
<Index each={files()}>
|
||||
{(file) => {
|
||||
const value = () => file().path
|
||||
const active = createMemo(() => open().includes(value()))
|
||||
const [visible, setVisible] = createSignal(false)
|
||||
|
||||
createEffect(() => {
|
||||
if (!active()) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
createEffect(() => {
|
||||
if (!active()) {
|
||||
setVisible(false)
|
||||
return
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
requestAnimationFrame(() => {
|
||||
if (!active()) return
|
||||
setVisible(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<FileAccordionItem
|
||||
open={active()}
|
||||
onOpenChange={(expanded) =>
|
||||
change(expanded ? [...open(), value()] : open().filter((path) => path !== value()))
|
||||
}
|
||||
type={file().type}
|
||||
header={
|
||||
<div data-slot="apply-patch-trigger-content">
|
||||
<div data-slot="apply-patch-file-info">
|
||||
<FileIcon node={{ path: file().path, type: "file" }} />
|
||||
<div data-slot="apply-patch-file-name-container">
|
||||
<Show when={file().path.includes("/")}>
|
||||
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(file().path)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="apply-patch-filename">{getFilename(file().path)}</span>
|
||||
return (
|
||||
<FileAccordionItem
|
||||
open={active()}
|
||||
onOpenChange={(expanded) =>
|
||||
change(expanded ? [...open(), value()] : open().filter((path) => path !== value()))
|
||||
}
|
||||
type={file().type}
|
||||
header={
|
||||
<div data-slot="apply-patch-trigger-content">
|
||||
<div data-slot="apply-patch-file-info">
|
||||
<FileIcon node={{ path: file().path, type: "file" }} />
|
||||
<div data-slot="apply-patch-file-name-container">
|
||||
<Show when={file().path.includes("/")}>
|
||||
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(file().path)}\u202C`}</span>
|
||||
</Show>
|
||||
<span data-slot="apply-patch-filename">{getFilename(file().path)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="apply-patch-trigger-actions">
|
||||
<Switch>
|
||||
<Match when={file().type === "add"}>
|
||||
<span data-slot="apply-patch-change" data-type="added">
|
||||
{i18n.t("ui.patch.action.created")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={file().type === "delete"}>
|
||||
<span data-slot="apply-patch-change" data-type="removed">
|
||||
{i18n.t("ui.patch.action.deleted")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges
|
||||
appearance="standard"
|
||||
changes={{ additions: file().additions, deletions: file().deletions }}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
<div data-slot="apply-patch-trigger-actions">
|
||||
<Switch>
|
||||
<Match when={file().type === "add"}>
|
||||
<span data-slot="apply-patch-change" data-type="added">
|
||||
{i18n.t("ui.patch.action.created")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={file().type === "delete"}>
|
||||
<span data-slot="apply-patch-change" data-type="removed">
|
||||
{i18n.t("ui.patch.action.deleted")}
|
||||
</span>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<DiffChanges
|
||||
appearance="standard"
|
||||
changes={{ additions: file().additions, deletions: file().deletions }}
|
||||
}
|
||||
>
|
||||
<Show when={props.deferContent === false || visible()}>
|
||||
<For each={file().views}>
|
||||
{(view) => (
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
virtualize={props.virtualizeDiff}
|
||||
fileDiff={view.fileDiff}
|
||||
hunkSeparators={view.fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Icon name="chevron-grabber-vertical" size="small" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show when={props.deferContent === false || visible()}>
|
||||
<For each={file().views}>
|
||||
{(view) => (
|
||||
<div data-component="apply-patch-file-diff">
|
||||
<Dynamic
|
||||
component={fileComponent}
|
||||
mode="diff"
|
||||
virtualize={props.virtualizeDiff}
|
||||
fileDiff={view.fileDiff}
|
||||
hunkSeparators={view.fileDiff.isPartial ? "simple" : "line-info-basic"}
|
||||
onRendered={props.onContentRendered}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</FileAccordionItem>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</FileAccordionGroup>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</FileAccordionItem>
|
||||
)
|
||||
}}
|
||||
</Index>
|
||||
</FileAccordionGroup>
|
||||
</Show>
|
||||
</FileTool>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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"
|
||||
@@ -47,7 +46,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
releaseDate: model.time.released,
|
||||
description: provider?.name ?? model.providerID,
|
||||
category,
|
||||
footer: modelPriceLabel(model),
|
||||
footer: free(model) ? "Free" : undefined,
|
||||
onSelect: () => {
|
||||
onSelect(model.providerID, model.id)
|
||||
},
|
||||
@@ -80,7 +79,7 @@ export function DialogModel(props: { providerID?: string }) {
|
||||
releaseDate: model.time.released,
|
||||
description: favorite ? "(Favorite)" : undefined,
|
||||
category: connected() ? (provider?.name ?? model.providerID) : undefined,
|
||||
footer: modelPriceLabel(model),
|
||||
footer: free(model) ? "Free" : undefined,
|
||||
onSelect() {
|
||||
onSelect(model.providerID, model.id)
|
||||
},
|
||||
@@ -212,7 +211,6 @@ export function sortModelOptions<
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
function free(model: { cost: Array<{ input: number }> }) {
|
||||
return model.cost.length > 0 && model.cost.every((cost) => cost.input === 0)
|
||||
}
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import type { SessionMessageAssistant } from "@opencode/client"
|
||||
import { groupEntries, mergeGroups, splitGroups, type GroupNode } from "./tree"
|
||||
|
||||
export type PartRef = {
|
||||
messageID: string
|
||||
partID: string
|
||||
}
|
||||
|
||||
export type CacheUsage = {
|
||||
read: number
|
||||
model: SessionMessageAssistant["model"]
|
||||
}
|
||||
|
||||
export type SessionEntry =
|
||||
| { type: "message"; messageID: string }
|
||||
| { type: "compaction-queued"; inboxID: string }
|
||||
| { type: "part"; ref: PartRef }
|
||||
| { type: "assistant-footer"; messageID: string }
|
||||
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
|
||||
|
||||
type GroupKind = "reasoning" | "exploration"
|
||||
type SessionGroup = {
|
||||
type: "group"
|
||||
children: readonly GroupNode<SessionEntry, GroupKind>[]
|
||||
size: number
|
||||
completed: boolean
|
||||
} & ({ kind: "reasoning" } | { kind: "exploration"; pending: PartRef[] })
|
||||
|
||||
export type SessionRow = SessionEntry | SessionGroup
|
||||
|
||||
export type AppendPart =
|
||||
| { type: "text" }
|
||||
| { type: "reasoning"; time?: { completed?: number } }
|
||||
| { type: "tool"; name: string }
|
||||
|
||||
export type ProjectionEntry = {
|
||||
entry: SessionEntry
|
||||
part?: AppendPart
|
||||
closesPrevious?: boolean
|
||||
}
|
||||
|
||||
/** Hydrate a fresh history batch in one pass rather than merging one leaf at a time. */
|
||||
export function projectEntries(entries: ProjectionEntry[]): SessionRow[] {
|
||||
const nodes = groupEntries(entries, (item) => (item.part ? partPath(item.part) : []))
|
||||
return nodes.map((node, index) => {
|
||||
if (node.type === "entry") return node.entry.entry
|
||||
const next = nodes[index + 1]
|
||||
const completed =
|
||||
(next !== undefined && (next.type === "group" || next.entry.closesPrevious !== false)) ||
|
||||
(node.kind === "reasoning" &&
|
||||
node.children.every(
|
||||
(child) =>
|
||||
child.type === "entry" &&
|
||||
child.entry.part?.type === "reasoning" &&
|
||||
child.entry.part.time?.completed !== undefined,
|
||||
))
|
||||
const group = { ...node, children: node.children.map(unwrap), completed }
|
||||
return node.kind === "reasoning" ? { ...group, kind: "reasoning" } : { ...group, kind: "exploration", pending: [] }
|
||||
})
|
||||
}
|
||||
|
||||
function unwrap(node: GroupNode<ProjectionEntry, GroupKind>): GroupNode<SessionEntry, GroupKind> {
|
||||
if (node.type === "entry") return { ...node, entry: node.entry.entry }
|
||||
return { ...node, children: node.children.map(unwrap) }
|
||||
}
|
||||
|
||||
function partPath(part: AppendPart): readonly GroupKind[] {
|
||||
if (part.type === "reasoning") return ["reasoning"]
|
||||
if (part.type === "tool" && ["read", "glob", "grep"].includes(part.name.toLowerCase())) return ["exploration"]
|
||||
return []
|
||||
}
|
||||
|
||||
/** Production rules only: keep lifecycle/status decisions outside the tree engine. */
|
||||
export function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
|
||||
const [node] = groupEntries<SessionEntry, GroupKind>([{ type: "part", ref }], () => partPath(part))
|
||||
if (node.type === "entry") {
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, node.entry)
|
||||
return
|
||||
}
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group" && previous.kind === node.kind) {
|
||||
// Permission-blocked tools remain at the end, just as the former refs/pending
|
||||
// partition did. Inserting a new ref must precede those blocked tools.
|
||||
const pending = previous.kind === "exploration" ? previous.pending.length : 0
|
||||
const [left, right] = splitGroups([previous], previous.size - pending)
|
||||
const [merged] = mergeGroups(mergeGroups(left, [node]), right)
|
||||
if (merged.type !== "group") throw new Error("Expected merged session group")
|
||||
previous.children = merged.children
|
||||
previous.size = merged.size
|
||||
if (part.type === "reasoning") previous.completed &&= part.time?.completed !== undefined
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(
|
||||
index,
|
||||
0,
|
||||
node.kind === "reasoning"
|
||||
? { ...node, kind: "reasoning", completed: part.type === "reasoning" && part.time?.completed !== undefined }
|
||||
: { ...node, kind: "exploration", pending: [], completed: false },
|
||||
)
|
||||
}
|
||||
|
||||
export function completePrevious(rows: SessionRow[], index = rows.length) {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group") previous.completed = true
|
||||
}
|
||||
|
||||
/** Part references for an existing production subgroup, not a flat timeline. */
|
||||
export function groupRefs(row: SessionGroup, includePending = false): PartRef[] {
|
||||
const pending = !includePending && row.kind === "exploration" ? row.pending : []
|
||||
const visit = (nodes: readonly GroupNode<SessionEntry, GroupKind>[]): PartRef[] =>
|
||||
nodes.flatMap((node) => {
|
||||
if (node.type === "group") return visit(node.children)
|
||||
if (node.entry.type !== "part") return []
|
||||
const ref = node.entry.ref
|
||||
if (pending.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)) return []
|
||||
return [ref]
|
||||
})
|
||||
return visit(row.children)
|
||||
}
|
||||
|
||||
export function partitionPending(rows: SessionRow[], pending: Set<string>) {
|
||||
rows.forEach((row) => {
|
||||
if (row.type !== "group" || row.kind !== "exploration") return
|
||||
// The production exploration rule creates direct part children. Preserve the
|
||||
// existing stable partition order when permissions are admitted or dismissed.
|
||||
const blocked = (node: GroupNode<SessionEntry, GroupKind>) =>
|
||||
node.type === "entry" && node.entry.type === "part" && pending.has(node.entry.ref.partID)
|
||||
row.children = [...row.children.filter((node) => !blocked(node)), ...row.children.filter(blocked)]
|
||||
row.pending = groupRefs(row, true).filter((ref) => pending.has(ref.partID))
|
||||
})
|
||||
}
|
||||
|
||||
export function hasPart(rows: SessionRow[], ref: PartRef) {
|
||||
return rows.some((row) => {
|
||||
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
|
||||
if (row.type !== "group") return false
|
||||
return groupRefs(row, true).some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
|
||||
})
|
||||
}
|
||||
@@ -110,7 +110,6 @@ import { isRecord } from "../../util/record"
|
||||
import { createHistoryPrepend } from "./history"
|
||||
import { context, use, type PendingAction } from "./render-context"
|
||||
import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, reasoningContent, TextPart } from "./message-parts"
|
||||
import { groupRefs } from "./grouping/session"
|
||||
export { InlineToolRow } from "./message-parts"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
@@ -1443,14 +1442,12 @@ function SessionRowView(props: SessionRowViewProps) {
|
||||
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
|
||||
</Match>
|
||||
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
|
||||
{(row) => (
|
||||
<SessionReasoningGroupView refs={groupRefs(row())} completed={row().completed} message={props.message} />
|
||||
)}
|
||||
{(row) => <SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />}
|
||||
</Match>
|
||||
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
|
||||
{(row) => (
|
||||
<SessionGroupView
|
||||
refs={groupRefs(row())}
|
||||
refs={row().refs}
|
||||
pending={row().pending}
|
||||
completed={row().completed}
|
||||
message={props.message}
|
||||
|
||||
@@ -4,20 +4,36 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useConfig } from "../../config"
|
||||
import { useData } from "../../context/data"
|
||||
import { useClient } from "../../context/client"
|
||||
import {
|
||||
append,
|
||||
completePrevious,
|
||||
groupRefs,
|
||||
hasPart,
|
||||
partitionPending,
|
||||
projectEntries,
|
||||
type AppendPart,
|
||||
type CacheUsage,
|
||||
type PartRef,
|
||||
type ProjectionEntry,
|
||||
type SessionRow,
|
||||
} from "./grouping/session"
|
||||
export type { CacheUsage, PartRef, SessionRow } from "./grouping/session"
|
||||
|
||||
export type PartRef = {
|
||||
messageID: string
|
||||
partID: string
|
||||
}
|
||||
|
||||
export type CacheUsage = {
|
||||
read: number
|
||||
model: SessionMessageAssistant["model"]
|
||||
}
|
||||
|
||||
export type SessionRow =
|
||||
| { type: "message"; messageID: string }
|
||||
| { type: "compaction-queued"; inboxID: string }
|
||||
| { type: "part"; ref: PartRef }
|
||||
| {
|
||||
type: "group"
|
||||
kind: "reasoning"
|
||||
refs: PartRef[]
|
||||
completed: boolean
|
||||
}
|
||||
| {
|
||||
type: "group"
|
||||
kind: "exploration"
|
||||
refs: PartRef[]
|
||||
pending: PartRef[]
|
||||
completed: boolean
|
||||
}
|
||||
| { type: "assistant-footer"; messageID: string }
|
||||
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
|
||||
const data = useData()
|
||||
@@ -164,7 +180,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
|
||||
(row) =>
|
||||
row.type === "group" &&
|
||||
row.kind === "reasoning" &&
|
||||
groupRefs(row).some((item) => item.messageID === ref.messageID && item.partID === ref.partID),
|
||||
row.refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID),
|
||||
)
|
||||
if (row?.type === "group" && row.kind === "reasoning") row.completed = true
|
||||
}),
|
||||
@@ -283,15 +299,16 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
||||
const usage = turnTokens
|
||||
? { steps: [] as SessionMessageAssistant[], previousTurnCache: undefined as CacheUsage | undefined }
|
||||
: undefined
|
||||
const entries = [
|
||||
return [
|
||||
...messages.filter((message) => !pending.has(message.id)),
|
||||
...pendingCompactions,
|
||||
...messages.filter(isInput),
|
||||
].reduce<ProjectionEntry[]>((rows, message) => {
|
||||
].reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
if (message.type === "synthetic" && !message.description?.trim()) return rows
|
||||
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
|
||||
rows.push({ entry: { type: "message", messageID: message.id }, closesPrevious: !pending.has(message.id) })
|
||||
if (!pending.has(message.id)) completePrevious(rows)
|
||||
rows.push({ type: "message", messageID: message.id })
|
||||
return rows
|
||||
}
|
||||
usage?.steps.push(message)
|
||||
@@ -299,22 +316,21 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
||||
message.content.forEach((part) => {
|
||||
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
|
||||
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
|
||||
rows.push({ entry: { type: "part", ref: { messageID: message.id, partID } }, part })
|
||||
append(rows, { messageID: message.id, partID }, part)
|
||||
})
|
||||
const terminal = (message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error
|
||||
if (terminal || message.retry) {
|
||||
rows.push({ entry: { type: "assistant-footer", messageID: message.id } })
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "assistant-footer", messageID: message.id })
|
||||
}
|
||||
if (terminal && usage) {
|
||||
const stepsWithUsage = usage.steps.filter(hasTokenUsage)
|
||||
const last = stepsWithUsage.at(-1)
|
||||
if (last) {
|
||||
rows.push({
|
||||
entry: {
|
||||
type: "turn-usage",
|
||||
messageIDs: stepsWithUsage.map((step) => step.id),
|
||||
...(usage.previousTurnCache === undefined ? {} : { previousCache: usage.previousTurnCache }),
|
||||
},
|
||||
type: "turn-usage",
|
||||
messageIDs: stepsWithUsage.map((step) => step.id),
|
||||
...(usage.previousTurnCache === undefined ? {} : { previousCache: usage.previousTurnCache }),
|
||||
})
|
||||
usage.previousTurnCache = { read: last.tokens.cache.read, model: last.model }
|
||||
}
|
||||
@@ -322,7 +338,6 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
||||
}
|
||||
return rows
|
||||
}, [])
|
||||
return projectEntries(entries)
|
||||
}
|
||||
|
||||
export function cacheReuseDrop(previous: CacheUsage | undefined, current: CacheUsage) {
|
||||
@@ -412,7 +427,7 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMess
|
||||
row.type === "part"
|
||||
? row.ref.messageID
|
||||
: row.type === "group"
|
||||
? groupRefs(row)[0]?.messageID
|
||||
? row.refs[0]?.messageID
|
||||
: row.type === "assistant-footer"
|
||||
? row.messageID
|
||||
: row.type === "turn-usage"
|
||||
@@ -431,3 +446,66 @@ export function resolvePart(message: SessionMessageAssistant, partID: string) {
|
||||
const ordinal = Number(match[2])
|
||||
return message.content.filter((part) => part.type === match[1])[ordinal]
|
||||
}
|
||||
|
||||
type AppendPart =
|
||||
| { type: "text" }
|
||||
| { type: "reasoning"; time?: { completed?: number } }
|
||||
| { type: "tool"; name: string }
|
||||
|
||||
function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
|
||||
if (part.type === "reasoning") {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group" && previous.kind === "reasoning") {
|
||||
previous.refs.push(ref)
|
||||
previous.completed &&= part.time?.completed !== undefined
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, {
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
refs: [ref],
|
||||
completed: part.time?.completed !== undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (part.type === "tool" && exploration(part.name)) {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group" && previous.kind === "exploration") {
|
||||
previous.refs.push(ref)
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, { type: "group", kind: "exploration", refs: [ref], pending: [], completed: false })
|
||||
return
|
||||
}
|
||||
completePrevious(rows, index)
|
||||
rows.splice(index, 0, { type: "part", ref })
|
||||
}
|
||||
|
||||
function completePrevious(rows: SessionRow[], index = rows.length) {
|
||||
const previous = rows[index - 1]
|
||||
if (previous?.type === "group") previous.completed = true
|
||||
}
|
||||
|
||||
function partitionPending(rows: SessionRow[], pending: Set<string>) {
|
||||
rows.forEach((row) => {
|
||||
if (row.type !== "group" || row.kind !== "exploration") return
|
||||
const refs = [...row.refs, ...row.pending]
|
||||
row.refs = refs.filter((ref) => !pending.has(ref.partID))
|
||||
row.pending = refs.filter((ref) => pending.has(ref.partID))
|
||||
})
|
||||
}
|
||||
|
||||
function exploration(name: string) {
|
||||
return ["read", "glob", "grep"].includes(name.toLowerCase())
|
||||
}
|
||||
|
||||
function hasPart(rows: SessionRow[], ref: PartRef) {
|
||||
return rows.some((row) => {
|
||||
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
|
||||
if (row.type !== "group") return false
|
||||
const refs = row.kind === "exploration" ? [...row.refs, ...row.pending] : row.refs
|
||||
return refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { go } from "fuzzysort"
|
||||
import { modelPriceLabel, prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
|
||||
import { prioritizeFavorites, sortModelOptions } from "../../../../src/component/dialog-model"
|
||||
|
||||
describe("prioritizeFavorites", () => {
|
||||
test("uses the favorite order captured when the dialog opened", () => {
|
||||
@@ -82,22 +82,3 @@ 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,
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { append, groupRefs, partitionPending, type SessionRow } from "../../../src/routes/session/grouping/session"
|
||||
|
||||
test("a pending tool does not hide a later tool reusing its call ID in another message", () => {
|
||||
const rows: SessionRow[] = []
|
||||
const blocked = { messageID: "assistant-a", partID: "call-reused" }
|
||||
const later = { messageID: "assistant-b", partID: "call-reused" }
|
||||
append(rows, blocked, { type: "tool", name: "read" })
|
||||
partitionPending(rows, new Set([blocked.partID]))
|
||||
append(rows, later, { type: "tool", name: "read" })
|
||||
|
||||
const group = rows[0]
|
||||
if (group.type !== "group" || group.kind !== "exploration") throw new Error("Expected exploration group")
|
||||
expect(groupRefs(group)).toEqual([later])
|
||||
expect(group.pending).toEqual([blocked])
|
||||
expect(groupRefs(group, true)).toEqual([later, blocked])
|
||||
|
||||
partitionPending(rows, new Set())
|
||||
expect(groupRefs(group)).toEqual([later, blocked])
|
||||
expect(group.pending).toEqual([])
|
||||
})
|
||||
@@ -280,11 +280,10 @@ test("groups exploration parts across assistant messages until a delimiter", ()
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
size: 3,
|
||||
children: [
|
||||
partChild("assistant-1", "read-1"),
|
||||
partChild("assistant-1", "glob-1"),
|
||||
partChild("assistant-2", "grep-1"),
|
||||
refs: [
|
||||
{ messageID: "assistant-1", partID: "read-1" },
|
||||
{ messageID: "assistant-1", partID: "glob-1" },
|
||||
{ messageID: "assistant-2", partID: "grep-1" },
|
||||
],
|
||||
},
|
||||
{ type: "part", ref: { messageID: "assistant-2", partID: "text:0" } },
|
||||
@@ -306,8 +305,7 @@ test("keeps non-exploration tools as individual part rows", () => {
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "read-1")],
|
||||
refs: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
},
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
|
||||
{
|
||||
@@ -315,8 +313,7 @@ test("keeps non-exploration tools as individual part rows", () => {
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: false,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "grep-1")],
|
||||
refs: [{ messageID: "assistant-1", partID: "grep-1" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -337,16 +334,14 @@ test("assigns stable kind ordinals within an assistant message", () => {
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
completed: true,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "reasoning:0")],
|
||||
refs: [{ messageID: "assistant-1", partID: "reasoning:0" }],
|
||||
},
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:1" } },
|
||||
{
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
completed: false,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "reasoning:1")],
|
||||
refs: [{ messageID: "assistant-1", partID: "reasoning:1" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -366,16 +361,17 @@ test("groups adjacent reasoning parts until a visible boundary", () => {
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
completed: true,
|
||||
size: 2,
|
||||
children: [partChild("assistant-1", "reasoning:0"), partChild("assistant-1", "reasoning:1")],
|
||||
refs: [
|
||||
{ messageID: "assistant-1", partID: "reasoning:0" },
|
||||
{ messageID: "assistant-1", partID: "reasoning:1" },
|
||||
],
|
||||
},
|
||||
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
|
||||
{
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
completed: false,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "reasoning:2")],
|
||||
refs: [{ messageID: "assistant-1", partID: "reasoning:2" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -397,16 +393,17 @@ test("groups across empty assistant reasoning parts", () => {
|
||||
type: "group",
|
||||
kind: "reasoning",
|
||||
completed: true,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "reasoning:0")],
|
||||
refs: [{ messageID: "assistant-1", partID: "reasoning:0" }],
|
||||
},
|
||||
{
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: false,
|
||||
size: 2,
|
||||
children: [partChild("assistant-1", "read-1"), partChild("assistant-2", "grep-1")],
|
||||
refs: [
|
||||
{ messageID: "assistant-1", partID: "read-1" },
|
||||
{ messageID: "assistant-2", partID: "grep-1" },
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -428,8 +425,7 @@ test("completes exploration groups when another row follows", () => {
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "read-1")],
|
||||
refs: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
},
|
||||
{ type: "message", messageID: "user-1" },
|
||||
{
|
||||
@@ -437,8 +433,7 @@ test("completes exploration groups when another row follows", () => {
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
size: 1,
|
||||
children: [partChild("assistant-2", "grep-1")],
|
||||
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
|
||||
},
|
||||
{ type: "assistant-footer", messageID: "assistant-2" },
|
||||
])
|
||||
@@ -473,8 +468,10 @@ test("hides synthetic messages without descriptions", () => {
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: false,
|
||||
size: 2,
|
||||
children: [partChild("assistant-1", "read-1"), partChild("assistant-2", "grep-1")],
|
||||
refs: [
|
||||
{ messageID: "assistant-1", partID: "read-1" },
|
||||
{ messageID: "assistant-2", partID: "grep-1" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(reduceSessionRows(messages, new Set(["synthetic-1"]))).toEqual(rows)
|
||||
@@ -499,8 +496,7 @@ test("renders synthetic messages with descriptions", () => {
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: true,
|
||||
size: 1,
|
||||
children: [partChild("assistant-1", "read-1")],
|
||||
refs: [{ messageID: "assistant-1", partID: "read-1" }],
|
||||
},
|
||||
{ type: "message", messageID: "synthetic-1" },
|
||||
{
|
||||
@@ -508,16 +504,11 @@ test("renders synthetic messages with descriptions", () => {
|
||||
kind: "exploration",
|
||||
pending: [],
|
||||
completed: false,
|
||||
size: 1,
|
||||
children: [partChild("assistant-2", "grep-1")],
|
||||
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
function partChild(messageID: string, partID: string) {
|
||||
return { type: "entry" as const, entry: { type: "part" as const, ref: { messageID, partID } }, size: 1 as const }
|
||||
}
|
||||
|
||||
test("renders a footer for a pre-output retry assistant after replay", () => {
|
||||
const message = assistant("assistant-retry", [])
|
||||
message.retry = {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
@@ -1,57 +1,3 @@
|
||||
---
|
||||
title: "Theme"
|
||||
---
|
||||
|
||||
OpenCode includes built-in light and dark themes. Choose a color scheme in settings, or use colors derived from your terminal.
|
||||
|
||||
## Choose a theme
|
||||
|
||||
Press `Ctrl+P`, then select **Open settings** to change the theme and color mode.
|
||||
|
||||
You can also set them in your global [CLI config](/cli/config):
|
||||
|
||||
```json title="~/.config/opencode/cli.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/v2/cli.json",
|
||||
"theme": {
|
||||
"name": "tokyonight",
|
||||
"mode": "system"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Color mode
|
||||
|
||||
Choose whether OpenCode follows your terminal's appearance or always uses light or dark colors:
|
||||
|
||||
| Mode | Behavior |
|
||||
| --- | --- |
|
||||
| `system` | Follow the terminal's detected light or dark appearance. |
|
||||
| `dark` | Always use the theme's dark colors. |
|
||||
| `light` | Always use the theme's light colors. |
|
||||
|
||||
For example, always use dark colors:
|
||||
|
||||
```json title="cli.json"
|
||||
{
|
||||
"theme": {
|
||||
"name": "tokyonight",
|
||||
"mode": "dark"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Terminal colors
|
||||
|
||||
When OpenCode can read your terminal palette, the theme picker also includes `system`. This theme derives its colors from your terminal's foreground, background, and ANSI palette.
|
||||
|
||||
```json title="cli.json"
|
||||
{
|
||||
"theme": {
|
||||
"name": "system",
|
||||
"mode": "system"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The theme name `system` selects terminal-derived colors. The color mode `system` follows the terminal's light or dark appearance.
|
||||
|
||||
Reference in New Issue
Block a user