mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 19:26:15 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04c23b8846 | ||
|
|
8f3465c951 | ||
|
|
466b75b19d | ||
|
|
833dd2ed7f | ||
|
|
bbe985b4d0 | ||
|
|
5b1321a8ca | ||
|
|
52c98a4eeb | ||
|
|
b6f85c2250 | ||
|
|
6e8aefcfa0 | ||
|
|
f1f0f47ee2 | ||
|
|
203b9f59b7 | ||
|
|
48bcbd09ef | ||
|
|
d86f732df3 |
@@ -124,7 +124,7 @@
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -1173,7 +1173,7 @@
|
||||
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.21.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ONj+Q8qOdNQp5XbH5jnMwzT9IKZJsSN0p0lkceS4GtUtNOPVLpNzSS8gqQdGMKfBvA0ESbkL8BTaSN1Rc9miEw=="],
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.2.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA=="],
|
||||
|
||||
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
|
||||
|
||||
|
||||
@@ -436,21 +436,22 @@ const mapFinishReason = (reason: string): FinishReason => {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// AWS Bedrock Converse reports `inputTokens` (inclusive total) with
|
||||
// `cacheReadInputTokens` and `cacheWriteInputTokens` as subsets. Pass
|
||||
// the total through and derive the non-cached breakdown. Bedrock does
|
||||
// not break reasoning out of `outputTokens` for any current model.
|
||||
// AWS reports inputTokens separately from cache reads and writes.
|
||||
// Bedrock does not break reasoning out of outputTokens for current models.
|
||||
const mapUsage = (usage: BedrockUsageSchema | undefined): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const cacheTotal = (usage.cacheReadInputTokens ?? 0) + (usage.cacheWriteInputTokens ?? 0)
|
||||
const nonCached = ProviderShared.subtractTokens(usage.inputTokens, cacheTotal)
|
||||
const inputTokens = ProviderShared.sumTokens(
|
||||
usage.inputTokens,
|
||||
usage.cacheReadInputTokens,
|
||||
usage.cacheWriteInputTokens,
|
||||
)
|
||||
return new Usage({
|
||||
inputTokens: usage.inputTokens,
|
||||
inputTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
nonCachedInputTokens: usage.inputTokens,
|
||||
cacheReadInputTokens: usage.cacheReadInputTokens,
|
||||
cacheWriteInputTokens: usage.cacheWriteInputTokens,
|
||||
totalTokens: ProviderShared.totalTokens(usage.inputTokens, usage.outputTokens, usage.totalTokens),
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.outputTokens, usage.totalTokens),
|
||||
providerMetadata: { bedrock: usage },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ const OpenAIResponsesEvent = Schema.Struct({
|
||||
Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
service_tier: optionalNull(Schema.String),
|
||||
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })),
|
||||
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
|
||||
usage: optionalNull(OpenAIResponsesUsage),
|
||||
error: optionalNull(OpenAIResponsesErrorPayload),
|
||||
}),
|
||||
@@ -602,7 +602,8 @@ const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => {
|
||||
|
||||
const mapFinishReason = (event: OpenAIResponsesEvent, hasFunctionCall: boolean): FinishReason => {
|
||||
const reason = event.response?.incomplete_details?.reason
|
||||
if (reason === undefined || reason === null) return hasFunctionCall ? "tool-calls" : "stop"
|
||||
if (reason === undefined || reason === null)
|
||||
return hasFunctionCall ? "tool-calls" : event.type === "response.incomplete" ? "unknown" : "stop"
|
||||
if (reason === "max_output_tokens") return "length"
|
||||
if (reason === "content_filter") return "content-filter"
|
||||
return hasFunctionCall ? "tool-calls" : "unknown"
|
||||
|
||||
@@ -34,11 +34,12 @@ import { ProviderFailureClassification } from "./errors"
|
||||
*
|
||||
* **Semantics by provider**:
|
||||
*
|
||||
* - OpenAI Chat / Responses / Gemini / Bedrock: provider reports inclusive
|
||||
* - OpenAI Chat / Responses / Gemini: provider reports inclusive
|
||||
* `inputTokens` and an inclusive `outputTokens`; mapper subtracts to
|
||||
* derive the breakdown.
|
||||
* - Anthropic: provider reports the breakdown natively (`input_tokens` is
|
||||
* non-cached only); mapper sums to derive the inclusive `inputTokens`.
|
||||
* - Anthropic and Bedrock report the input breakdown natively: Anthropic's
|
||||
* `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their
|
||||
* mappers sum the breakdown to derive the inclusive `inputTokens`.
|
||||
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
|
||||
* `reasoningTokens` is `undefined` and `outputTokens` carries the
|
||||
* combined total — a documented limitation of the Anthropic API.
|
||||
|
||||
+53
File diff suppressed because one or more lines are too long
@@ -13,12 +13,8 @@ const RECORDING_REGION = process.env.BEDROCK_RECORDING_REGION ?? "us-east-1"
|
||||
// call wouldn't deterministically prove cache mapping works. Override with
|
||||
// BEDROCK_CACHE_MODEL_ID if your account has access elsewhere.
|
||||
const model = AmazonBedrock.configure({
|
||||
credentials: {
|
||||
region: RECORDING_REGION,
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID ?? "fixture",
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? "fixture",
|
||||
sessionToken: process.env.AWS_SESSION_TOKEN,
|
||||
},
|
||||
apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK ?? "fixture",
|
||||
region: RECORDING_REGION,
|
||||
}).model(process.env.BEDROCK_CACHE_MODEL_ID ?? "us.anthropic.claude-haiku-4-5-20251001-v1:0")
|
||||
|
||||
const cacheRequest = LLM.request({
|
||||
@@ -36,7 +32,7 @@ const recorded = recordedTests({
|
||||
prefix: "bedrock-converse-cache",
|
||||
provider: "amazon-bedrock",
|
||||
protocol: "bedrock-converse",
|
||||
requires: ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
|
||||
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
|
||||
// Two identical requests in one cassette — replay walks the cassette in
|
||||
// recording order so the second call replays the cached-hit interaction.
|
||||
})
|
||||
@@ -45,10 +41,20 @@ describe("Bedrock Converse cache recorded", () => {
|
||||
recorded.effect.with("writes then reads cachePoint on identical second call", { tags: ["cache"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* LLMClient.generate(cacheRequest)
|
||||
expect(first.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(0)
|
||||
expect(first.usage?.cacheWriteInputTokens ?? 0).toBeGreaterThan(0)
|
||||
expect(first.usage?.inputTokens).toBe(
|
||||
(first.usage?.nonCachedInputTokens ?? 0) +
|
||||
(first.usage?.cacheReadInputTokens ?? 0) +
|
||||
(first.usage?.cacheWriteInputTokens ?? 0),
|
||||
)
|
||||
|
||||
const second = yield* LLMClient.generate(cacheRequest)
|
||||
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
|
||||
expect(second.usage?.inputTokens).toBe(
|
||||
(second.usage?.nonCachedInputTokens ?? 0) +
|
||||
(second.usage?.cacheReadInputTokens ?? 0) +
|
||||
(second.usage?.cacheWriteInputTokens ?? 0),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -269,6 +269,39 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds cache reads and writes to Bedrock input usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { text: "Hello" } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
[
|
||||
"metadata",
|
||||
{
|
||||
usage: {
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
totalTokens: 12,
|
||||
cacheReadInputTokens: 3,
|
||||
cacheWriteInputTokens: 2,
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 10,
|
||||
nonCachedInputTokens: 5,
|
||||
cacheReadInputTokens: 3,
|
||||
cacheWriteInputTokens: 2,
|
||||
outputTokens: 2,
|
||||
totalTokens: 12,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles streamed tool call input", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
|
||||
@@ -870,6 +870,32 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps incomplete response reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const generate = (incompleteDetails: object) =>
|
||||
LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
type: "response.incomplete",
|
||||
response: { id: "resp_incomplete", incomplete_details: incompleteDetails },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const length = yield* generate({ reason: "max_output_tokens" })
|
||||
const contentFilter = yield* generate({ reason: "content_filter" })
|
||||
const unknown = yield* generate({})
|
||||
|
||||
expect([length.finishReason, contentFilter.finishReason, unknown.finishReason]).toEqual([
|
||||
"length",
|
||||
"content-filter",
|
||||
"unknown",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
// OpenAI's documented stream orders output text within one message item; no
|
||||
// provider-valid same-kind overlap is evidenced, so done boundaries close it.
|
||||
it.effect("closes sequential output messages before starting the next", () =>
|
||||
|
||||
@@ -88,7 +88,7 @@ export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (pro
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
instant
|
||||
openDelay={0}
|
||||
contentStyle={{ "font-family": "var(--v2-font-family-sans)" }}
|
||||
value={
|
||||
<ModelTooltip
|
||||
|
||||
@@ -93,7 +93,7 @@ const ModelList: Component<{
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
instant
|
||||
openDelay={0}
|
||||
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item.provider.id, item.cost)} />}
|
||||
>
|
||||
{node}
|
||||
@@ -452,7 +452,7 @@ export function ModelSelectorPopoverV2(props: {
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={6}
|
||||
instant
|
||||
openDelay={0}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
|
||||
@@ -47,6 +47,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
||||
@@ -52,7 +52,12 @@ export const PromptImageAttachments: Component<PromptImageAttachmentsProps> = (p
|
||||
<For each={props.comments ?? []}>
|
||||
{(item) => (
|
||||
<div class="relative group shrink-0">
|
||||
<TooltipV2 value={item.comment} placement="top" contentClass="max-w-[300px] break-words">
|
||||
<TooltipV2
|
||||
value={item.comment}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
contentClass="max-w-[300px] break-words"
|
||||
>
|
||||
<CommentCardV2
|
||||
comment={item.comment ?? ""}
|
||||
path={item.path}
|
||||
|
||||
@@ -607,6 +607,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
placement="bottom"
|
||||
title={language.t("command.session.new")}
|
||||
keybind={command.keybind("session.new")}
|
||||
openDelay={800}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -636,7 +637,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
>
|
||||
<Show when={hasProjects() && nav()}>
|
||||
<div class="flex items-center gap-0 transition-transform">
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")}>
|
||||
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={800}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-left"
|
||||
@@ -646,7 +647,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")}>
|
||||
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={800}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon="chevron-right"
|
||||
|
||||
@@ -263,6 +263,7 @@ function ProviderTip(props: { ready: () => boolean; connected: () => boolean; op
|
||||
<TooltipV2
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={language.t("common.dismiss")}
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type AuthenticateRequest,
|
||||
type CancelNotification,
|
||||
type CloseSessionRequest,
|
||||
type DeleteSessionRequest,
|
||||
type ForkSessionRequest,
|
||||
type InitializeRequest,
|
||||
type ListSessionsRequest,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
type PromptRequest,
|
||||
type ResumeSessionRequest,
|
||||
type SetSessionConfigOptionRequest,
|
||||
type SetSessionModelRequest,
|
||||
type SetSessionModeRequest,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
@@ -28,12 +28,12 @@ export function create(client: OpenCodeClient, connection: AgentSideConnection)
|
||||
newSession: (params: NewSessionRequest) => run(service.newSession(params)),
|
||||
loadSession: (params: LoadSessionRequest) => run(service.loadSession(params)),
|
||||
listSessions: (params: ListSessionsRequest) => run(service.listSessions(params)),
|
||||
deleteSession: (params: DeleteSessionRequest) => run(service.deleteSession(params)),
|
||||
resumeSession: (params: ResumeSessionRequest) => run(service.resumeSession(params)),
|
||||
closeSession: (params: CloseSessionRequest) => run(service.closeSession(params)),
|
||||
unstable_forkSession: (params: ForkSessionRequest) => run(service.forkSession(params)),
|
||||
setSessionConfigOption: (params: SetSessionConfigOptionRequest) => run(service.setSessionConfigOption(params)),
|
||||
setSessionMode: (params: SetSessionModeRequest) => run(service.setSessionMode(params)),
|
||||
unstable_setSessionModel: (params: SetSessionModelRequest) => run(service.setSessionModel(params)),
|
||||
prompt: (params: PromptRequest) => run(service.prompt(params)),
|
||||
cancel: (params: CancelNotification) => run(service.cancel(params)),
|
||||
} satisfies Agent
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function streamTurn(input: {
|
||||
readonly sessionID: string
|
||||
readonly cwd: string
|
||||
readonly start: TurnStart
|
||||
readonly userMessageID?: string | null
|
||||
readonly writeTextFile: boolean
|
||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||
readonly control: TurnControl
|
||||
}): Promise<PromptResponse> {
|
||||
@@ -170,6 +170,7 @@ export async function streamTurn(input: {
|
||||
tools.delete(event.data.callID)
|
||||
await syncEditedFiles({
|
||||
connection: input.connection,
|
||||
writeTextFile: input.writeTextFile,
|
||||
sessionID: input.sessionID,
|
||||
cwd: input.cwd,
|
||||
toolName: current.name,
|
||||
@@ -231,7 +232,7 @@ export async function streamTurn(input: {
|
||||
if (!started) {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
return response(undefined, undefined, "interrupted", true, undefined, input.userMessageID)
|
||||
return response(undefined, undefined, "interrupted", true, undefined)
|
||||
}
|
||||
}
|
||||
const terminal = await completed
|
||||
@@ -246,7 +247,6 @@ export async function streamTurn(input: {
|
||||
terminal,
|
||||
control.cancelled,
|
||||
finish,
|
||||
input.userMessageID,
|
||||
)
|
||||
} catch (error) {
|
||||
streamController.abort()
|
||||
@@ -400,7 +400,6 @@ function response(
|
||||
terminal: "succeeded" | "failed" | "interrupted",
|
||||
cancelled: boolean,
|
||||
finish: SessionMessageAssistant["finish"],
|
||||
messageID: string | null | undefined,
|
||||
): PromptResponse {
|
||||
const error = assistant?.error ?? executionError
|
||||
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
|
||||
@@ -423,7 +422,7 @@ function response(
|
||||
}
|
||||
: undefined
|
||||
const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
|
||||
return { stopReason, ...(usage ? { usage } : {}), ...(messageID ? { userMessageId: messageID } : {}), _meta: {} }
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: {} }
|
||||
}
|
||||
|
||||
function resolveStopReason(input: {
|
||||
|
||||
@@ -53,13 +53,14 @@ export async function replyPermission(input: {
|
||||
|
||||
export async function syncEditedFiles(input: {
|
||||
readonly connection: Partial<Pick<AgentSideConnection, "writeTextFile">>
|
||||
readonly writeTextFile: boolean
|
||||
readonly sessionID: string
|
||||
readonly cwd: string
|
||||
readonly toolName: string
|
||||
readonly toolInput: ToolInput
|
||||
readonly structured: Readonly<Record<string, unknown>>
|
||||
}) {
|
||||
if (!input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
|
||||
if (!input.writeTextFile || !input.connection.writeTextFile || toToolKind(input.toolName) !== "edit") return
|
||||
const files = Array.isArray(input.structured.files)
|
||||
? input.structured.files.flatMap((file): string[] => {
|
||||
if (!file || typeof file !== "object") return []
|
||||
|
||||
@@ -16,6 +16,8 @@ import type {
|
||||
CancelNotification,
|
||||
CloseSessionRequest,
|
||||
CloseSessionResponse,
|
||||
DeleteSessionRequest,
|
||||
DeleteSessionResponse,
|
||||
ForkSessionRequest,
|
||||
ForkSessionResponse,
|
||||
InitializeRequest,
|
||||
@@ -33,8 +35,6 @@ import type {
|
||||
ResumeSessionResponse,
|
||||
SetSessionConfigOptionRequest,
|
||||
SetSessionConfigOptionResponse,
|
||||
SetSessionModelRequest,
|
||||
SetSessionModelResponse,
|
||||
SetSessionModeRequest,
|
||||
SetSessionModeResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
@@ -47,7 +47,8 @@ import { ACPError } from "./error"
|
||||
|
||||
export const AuthMethodID = "opencode-login"
|
||||
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission">
|
||||
type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission"> &
|
||||
Partial<Pick<AgentSideConnection, "writeTextFile">>
|
||||
|
||||
type Catalog = {
|
||||
readonly providers: ConfigOptionProvider[]
|
||||
@@ -83,12 +84,12 @@ export interface Interface {
|
||||
newSession(input: NewSessionRequest): Promise<NewSessionResponse>
|
||||
loadSession(input: LoadSessionRequest): Promise<LoadSessionResponse>
|
||||
listSessions(input: ListSessionsRequest): Promise<ListSessionsResponse>
|
||||
deleteSession(input: DeleteSessionRequest): Promise<DeleteSessionResponse>
|
||||
resumeSession(input: ResumeSessionRequest): Promise<ResumeSessionResponse>
|
||||
closeSession(input: CloseSessionRequest): Promise<CloseSessionResponse>
|
||||
forkSession(input: ForkSessionRequest): Promise<ForkSessionResponse>
|
||||
setSessionConfigOption(input: SetSessionConfigOptionRequest): Promise<SetSessionConfigOptionResponse>
|
||||
setSessionMode(input: SetSessionModeRequest): Promise<SetSessionModeResponse>
|
||||
setSessionModel(input: SetSessionModelRequest): Promise<SetSessionModelResponse>
|
||||
prompt(input: PromptRequest): Promise<PromptResponse>
|
||||
cancel(input: CancelNotification): Promise<void>
|
||||
}
|
||||
@@ -98,6 +99,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
const catalogs = new Map<string, Promise<Catalog>>()
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const active = new Map<string, TurnControl>()
|
||||
const capabilities = { writeTextFile: false }
|
||||
|
||||
const catalog = (cwd: string) => {
|
||||
const cached = catalogs.get(cwd)
|
||||
@@ -157,6 +159,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
|
||||
return {
|
||||
initialize: async (params) => {
|
||||
capabilities.writeTextFile = params.clientCapabilities?.fs?.writeTextFile === true
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
name: "Login with opencode",
|
||||
@@ -173,7 +176,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
loadSession: true,
|
||||
mcpCapabilities: { http: true, sse: false },
|
||||
promptCapabilities: { embeddedContext: true, image: true },
|
||||
sessionCapabilities: { close: {}, fork: {}, list: {}, resume: {} },
|
||||
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
||||
@@ -216,6 +219,14 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
|
||||
}
|
||||
},
|
||||
deleteSession: async (params) => {
|
||||
await input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
|
||||
if (!isSessionNotFoundError(error)) throw error
|
||||
})
|
||||
sessions.delete(params.sessionId)
|
||||
registeredMcp.delete(params.sessionId)
|
||||
return {}
|
||||
},
|
||||
resumeSession: async (params) => {
|
||||
const session = await getSession(input.client, params.sessionId)
|
||||
const state = await attach(session, session.location.directory, params.mcpServers ?? [])
|
||||
@@ -270,13 +281,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
await selectMode(input.client, await requireSession(params.sessionId), params.modeId)
|
||||
return {}
|
||||
},
|
||||
setSessionModel: async (params) => {
|
||||
const state = await requireSession(params.sessionId)
|
||||
const selected = requireModel(state.catalog, params.modelId)
|
||||
state.model = selected
|
||||
await input.client.session.switchModel({ sessionID: state.id, model: selected })
|
||||
return {}
|
||||
},
|
||||
prompt: async (params) => {
|
||||
const state = await requireSession(params.sessionId)
|
||||
if (active.has(state.id)) {
|
||||
@@ -295,7 +299,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
|
||||
sessionID: state.id,
|
||||
cwd: state.cwd,
|
||||
start: prepared.start,
|
||||
userMessageID: params.messageId,
|
||||
writeTextFile: capabilities.writeTextFile,
|
||||
control,
|
||||
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
||||
}).finally(() => {
|
||||
@@ -479,6 +483,7 @@ async function registerMcpServers(
|
||||
|
||||
function mcpConfig(server: McpServer) {
|
||||
if ("type" in server) {
|
||||
if (server.type === "acp") throw new Error("MCP-over-ACP is not supported")
|
||||
return {
|
||||
type: "remote" as const,
|
||||
url: server.url,
|
||||
|
||||
@@ -140,11 +140,11 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
replay: Flag.boolean("replay").pipe(
|
||||
Flag.withDescription("Replay session history on resume and after resize"),
|
||||
Flag.withDefault(true),
|
||||
Flag.withDescription("Restore session history on resume and resize (disable with --no-replay)"),
|
||||
Flag.optional,
|
||||
),
|
||||
replayLimit: Flag.integer("replay-limit").pipe(
|
||||
Flag.withDescription("Cap visible replay to the newest N messages"),
|
||||
Flag.withDescription("Limit replay to the newest N messages (default: 200)"),
|
||||
Flag.optional,
|
||||
),
|
||||
model: Flag.string("model").pipe(
|
||||
|
||||
@@ -28,8 +28,8 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
model: Option.getOrUndefined(input.model),
|
||||
agent: Option.getOrUndefined(input.agent),
|
||||
prompt: Option.getOrUndefined(input.prompt),
|
||||
replay: input.replay,
|
||||
replayLimit: Option.getOrUndefined(input.replayLimit),
|
||||
replay: Option.getOrUndefined(input.replay) ?? resolved.mini?.replay ?? true,
|
||||
replayLimit: Option.getOrUndefined(input.replayLimit) ?? resolved.mini?.replay_limit,
|
||||
demo: input.demo,
|
||||
tuiConfig: resolved,
|
||||
config: {
|
||||
|
||||
@@ -439,6 +439,7 @@ describe("acp event behavior", () => {
|
||||
sessionID: "ses_cancel",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "input_cancel" },
|
||||
writeTextFile: false,
|
||||
control,
|
||||
submit: async (signal) => {
|
||||
await fixture.client.session.prompt(
|
||||
@@ -481,6 +482,7 @@ describe("acp event behavior", () => {
|
||||
sessionID: "ses_cancel_admission",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "input_cancel_admission" },
|
||||
writeTextFile: false,
|
||||
control,
|
||||
submit: (signal) =>
|
||||
fixture.client.session.prompt(
|
||||
@@ -566,7 +568,7 @@ function turn(input: {
|
||||
sessionID: input.sessionID,
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: input.inputID },
|
||||
userMessageID: `client_${input.inputID}`,
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: (signal) =>
|
||||
input.fixture.client.session.prompt({ sessionID: input.sessionID, id: input.inputID, text: "hello" }, { signal }),
|
||||
|
||||
@@ -85,7 +85,6 @@ test("acp prompt resolves after ordered turn updates", async () => {
|
||||
|
||||
try {
|
||||
const id = "msg_prompt"
|
||||
const userMessageID = "client-message"
|
||||
const response = await streamTurn({
|
||||
client,
|
||||
connection: {
|
||||
@@ -97,7 +96,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
|
||||
sessionID: "ses_test",
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id },
|
||||
userMessageID,
|
||||
writeTextFile: false,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: () => client.session.prompt({ sessionID: "ses_test", id, text: "hi" }),
|
||||
})
|
||||
@@ -112,7 +111,7 @@ test("acp prompt resolves after ordered turn updates", async () => {
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(response).toMatchObject({ stopReason: "end_turn", userMessageId: userMessageID, usage: { totalTokens: 2 } })
|
||||
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
|
||||
} finally {
|
||||
events?.close()
|
||||
await server.stop(true)
|
||||
|
||||
@@ -14,6 +14,7 @@ describe("acp initialize/auth subprocess", () => {
|
||||
expect(initialized.agentCapabilities?.mcpCapabilities?.sse).toBe(false)
|
||||
expect(initialized.agentCapabilities?.loadSession).toBe(true)
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.close).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.fork).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.list).toEqual({})
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
CloseSessionResponse,
|
||||
DeleteSessionResponse,
|
||||
ListSessionsResponse,
|
||||
LoadSessionResponse,
|
||||
ResumeSessionResponse,
|
||||
@@ -60,6 +61,20 @@ describe("acp lifecycle subprocess", () => {
|
||||
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(true)
|
||||
}, 60_000)
|
||||
|
||||
test("delete capability and delete request", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
const initialized = await initialize(acp)
|
||||
expect(initialized.agentCapabilities?.sessionCapabilities?.delete).toEqual({})
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
expect(
|
||||
expectOk(await acp.request<DeleteSessionResponse>("session/delete", { sessionId: session.sessionId })),
|
||||
).toEqual({})
|
||||
const listed = expectOk(await acp.request<ListSessionsResponse>("session/list", { cwd: fixture.home }))
|
||||
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false)
|
||||
}, 60_000)
|
||||
|
||||
test("resume capability advertisement", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const initialized = await initialize(fixture.spawn())
|
||||
|
||||
@@ -4,6 +4,7 @@ import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { streamTurn } from "../../src/acp/event"
|
||||
import { syncEditedFiles } from "../../src/acp/permission"
|
||||
import { createSseFixture, durableEvent, ephemeralEvent, withTimeout } from "./sse-fixture"
|
||||
|
||||
type SessionUpdateParams = Parameters<AgentSideConnection["sessionUpdate"]>[0]
|
||||
@@ -12,6 +13,27 @@ type Connection = Pick<AgentSideConnection, "sessionUpdate" | "requestPermission
|
||||
type Fixture = ReturnType<typeof createSseFixture>
|
||||
|
||||
describe("acp permission behavior", () => {
|
||||
test("does not sync edits when writeTextFile was not advertised", async () => {
|
||||
const writes: Parameters<AgentSideConnection["writeTextFile"]>[0][] = []
|
||||
|
||||
await syncEditedFiles({
|
||||
connection: {
|
||||
writeTextFile: async (input) => {
|
||||
writes.push(input)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
writeTextFile: false,
|
||||
sessionID: "ses_no_write",
|
||||
cwd: "/workspace",
|
||||
toolName: "edit",
|
||||
toolInput: { filePath: "/workspace/file.ts" },
|
||||
structured: {},
|
||||
})
|
||||
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
test("forwards allow-once and allow-always selections to the generated client", async () => {
|
||||
const permissionRequests: RequestPermissionRequest[] = []
|
||||
const fixture = createSseFixture({
|
||||
@@ -465,6 +487,7 @@ function startTurn(fixture: Fixture, connection: Connection, sessionID: string,
|
||||
sessionID,
|
||||
cwd,
|
||||
start: { type: "input", id: inputID },
|
||||
writeTextFile: true,
|
||||
control: { cancelled: false, admission: new AbortController() },
|
||||
submit: (signal) => fixture.client.session.prompt({ sessionID, id: inputID, text: "hello" }, { signal }),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
|
||||
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
|
||||
|
||||
describe("acp service directory behavior", () => {
|
||||
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
|
||||
@@ -134,7 +134,6 @@ describe("acp service directory behavior", () => {
|
||||
configId: "mode",
|
||||
value: "plan",
|
||||
})
|
||||
await fixture.service.setSessionModel({ sessionId: session.sessionId, modelId: "test/test-model/high" })
|
||||
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
|
||||
|
||||
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
|
||||
@@ -148,7 +147,6 @@ describe("acp service directory behavior", () => {
|
||||
).toEqual([
|
||||
{ model: { providerID: "test", id: secondModel.id } },
|
||||
{ model: { providerID: "test", id: secondModel.id, variant: "medium" } },
|
||||
{ model: { providerID: "test", id: testModel.id, variant: "high" } },
|
||||
])
|
||||
expect(
|
||||
fixture.requests
|
||||
|
||||
@@ -225,6 +225,33 @@ describe("acp service lifecycle", () => {
|
||||
"/api/session/missing/interrupt",
|
||||
])
|
||||
})
|
||||
|
||||
test("deletes sessions from backing and local storage", async () => {
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
if (request.method === "POST" && request.path === "/api/session") {
|
||||
return Response.json({ data: makeSession("ses_delete") })
|
||||
}
|
||||
if (request.method === "DELETE" && request.path === "/api/session/ses_delete") {
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const session = await fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
|
||||
expect(await fixture.service.deleteSession({ sessionId: session.sessionId })).toEqual({})
|
||||
expect(fixture.requests).toContainEqual({
|
||||
method: "DELETE",
|
||||
path: "/api/session/ses_delete",
|
||||
query: {},
|
||||
body: undefined,
|
||||
})
|
||||
const missing = await fixture.service
|
||||
.setSessionConfigOption({ sessionId: session.sessionId, configId: "effort", value: "high" })
|
||||
.catch((error: unknown) => error)
|
||||
expect(missing).toMatchObject({ _tag: "ACPSessionNotFoundError", sessionId: session.sessionId })
|
||||
})
|
||||
})
|
||||
|
||||
function currentValue(result: { readonly configOptions?: readonly SessionConfigOption[] | null }, id: string) {
|
||||
|
||||
@@ -45,17 +45,14 @@ describe("acp service prompt routing and usage", () => {
|
||||
|
||||
const commandResult = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
messageId: "client-command",
|
||||
prompt: [{ type: "text", text: "/review now" }],
|
||||
})
|
||||
const skillResult = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
messageId: "client-skill",
|
||||
prompt: [{ type: "text", text: "/verify" }],
|
||||
})
|
||||
const compactResult = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
messageId: "client-compact",
|
||||
prompt: [{ type: "text", text: "/compact" }],
|
||||
})
|
||||
|
||||
@@ -154,13 +151,11 @@ describe("acp service prompt routing and usage", () => {
|
||||
|
||||
const response = await fixture.service.prompt({
|
||||
sessionId: session.sessionId,
|
||||
messageId: "client-message",
|
||||
prompt: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
|
||||
expect(response).toEqual({
|
||||
stopReason: "end_turn",
|
||||
userMessageId: "client-message",
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 40,
|
||||
|
||||
@@ -214,11 +214,15 @@ describe("mini command", () => {
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("--server string")
|
||||
expect(result.stdout).toContain("--prompt string")
|
||||
expect(result.stdout).toContain("--replay")
|
||||
expect(result.stdout).toContain("disable with --no-replay")
|
||||
expect(result.stdout).toContain("--replay-limit integer")
|
||||
expect(result.stdout).toContain("Limit replay to the newest N messages (default: 200)")
|
||||
expect(result.stdout).not.toContain("SUBCOMMANDS")
|
||||
})
|
||||
|
||||
test("routes local and explicit-server invocations into mini", async () => {
|
||||
for (const args of [["mini"], ["mini", "--server", "http://127.0.0.1:1"]]) {
|
||||
for (const args of [["mini"], ["mini", "--no-replay"], ["mini", "--server", "http://127.0.0.1:1"]]) {
|
||||
const result = await cli(args)
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
|
||||
@@ -116,7 +116,7 @@ const layer = Layer.effect(
|
||||
draft.providers.set(providerID, record)
|
||||
}
|
||||
const model =
|
||||
record.models.get(modelID) ?? (ModelV2.Info.empty(providerID, modelID) as ModelV2.MutableInfo)
|
||||
record.models.get(modelID) ?? (ModelV2.Info.default(providerID, modelID) as ModelV2.MutableInfo)
|
||||
if (!record.models.has(modelID)) record.models.set(modelID, model)
|
||||
fn(model)
|
||||
model.id = modelID
|
||||
|
||||
@@ -2,12 +2,10 @@ export * as Generate from "./generate"
|
||||
|
||||
import { LLM, LLMClient, LLMError } from "@opencode-ai/ai"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Catalog } from "./catalog"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { llmClient } from "./effect/app-node-platform"
|
||||
import { Integration } from "./integration"
|
||||
import { ModelResolver } from "./model-resolver"
|
||||
import { ModelV2 } from "./model"
|
||||
import { SessionRunnerModel } from "./session/runner/model"
|
||||
|
||||
export interface TextInput {
|
||||
readonly prompt: string
|
||||
@@ -19,10 +17,10 @@ export class ModelSelectionError extends Schema.TaggedErrorClass<ModelSelectionE
|
||||
{ message: Schema.String },
|
||||
) {}
|
||||
|
||||
export class UnavailableError extends Schema.TaggedErrorClass<UnavailableError>()(
|
||||
"Generate.UnavailableError",
|
||||
{ message: Schema.String, service: Schema.optional(Schema.String) },
|
||||
) {}
|
||||
export class UnavailableError extends Schema.TaggedErrorClass<UnavailableError>()("Generate.UnavailableError", {
|
||||
message: Schema.String,
|
||||
service: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export type Error = ModelSelectionError | UnavailableError
|
||||
|
||||
@@ -35,56 +33,34 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/v2
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const llm = yield* LLMClient.Service
|
||||
|
||||
const selectModel = Effect.fn("Generate.selectModel")(function* (requested?: ModelV2.Ref) {
|
||||
const selected = requested
|
||||
? yield* catalog.model.get(requested.providerID, requested.id)
|
||||
: yield* catalog.model.default().pipe(
|
||||
Effect.flatMap((model) =>
|
||||
model && SessionRunnerModel.supported(model)
|
||||
? Effect.succeed(model)
|
||||
: Effect.map(catalog.model.available(), (models) => models.find(SessionRunnerModel.supported)),
|
||||
),
|
||||
)
|
||||
if (!selected)
|
||||
return yield* new ModelSelectionError({
|
||||
message: requested
|
||||
? `Model unavailable: ${requested.providerID}/${requested.id}`
|
||||
: "No model specified and no supported model is available",
|
||||
})
|
||||
return yield* SessionRunnerModel.withVariant(selected, requested?.variant).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new ModelSelectionError({
|
||||
message: `Variant unavailable for ${selected.providerID}/${selected.id}: ${requested?.variant}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
const resolver = yield* ModelResolver.Service
|
||||
|
||||
const runText = Effect.fn("Generate.text")(function* (input: TextInput) {
|
||||
const selected = yield* selectModel(input.model)
|
||||
const provider = yield* catalog.provider.get(selected.providerID)
|
||||
const connection = yield* integrations.connection.active(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
const resolved = yield* resolver.resolve(input.model).pipe(
|
||||
Effect.catchTags({
|
||||
"SessionRunnerModel.VariantUnavailableError": (error) =>
|
||||
input.model
|
||||
? new ModelSelectionError({ message: error.message })
|
||||
: new UnavailableError({ message: error.message, service: error.providerID }),
|
||||
"SessionRunnerModel.UnsupportedPackageError": (error) =>
|
||||
input.model
|
||||
? new ModelSelectionError({ message: error.message })
|
||||
: new UnavailableError({ message: error.message, service: error.providerID }),
|
||||
}),
|
||||
)
|
||||
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
|
||||
const model = yield* SessionRunnerModel.fromCatalogModel(selected, credential).pipe(
|
||||
Effect.mapError((error) =>
|
||||
input.model
|
||||
? new ModelSelectionError({ message: error.message })
|
||||
: new UnavailableError({ message: error.message, service: selected.providerID }),
|
||||
),
|
||||
)
|
||||
const response = yield* llm.generate(LLM.request({ model, prompt: input.prompt })).pipe(
|
||||
if (!resolved)
|
||||
return yield* new ModelSelectionError({
|
||||
message: input.model
|
||||
? `Model unavailable: ${input.model.providerID}/${input.model.id}`
|
||||
: "No model specified and no supported model is available",
|
||||
})
|
||||
const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe(
|
||||
Effect.mapError(
|
||||
(error: LLMError) =>
|
||||
new UnavailableError({
|
||||
message: error.message,
|
||||
service: selected.providerID,
|
||||
service: resolved.ref.providerID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -106,4 +82,8 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, Integration.node, llmClient] })
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [ModelResolver.node, llmClient],
|
||||
})
|
||||
|
||||
@@ -135,7 +135,7 @@ function build(id: ModelV2.ID, remote: UsableModel, baseURL: string, previous?:
|
||||
const released = previous?.time.released || Date.parse(version)
|
||||
|
||||
return ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, id),
|
||||
...ModelV2.Info.default(ProviderV2.ID.githubCopilot, id),
|
||||
id,
|
||||
modelID: ModelV2.ID.make(remote.id),
|
||||
providerID: ProviderV2.ID.githubCopilot,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Integration } from "./integration"
|
||||
import { Location } from "./location"
|
||||
import { LocationMutation } from "./location-mutation"
|
||||
import { LocationServiceMap } from "./location-service-map"
|
||||
import { ModelResolver } from "./model-resolver"
|
||||
import { MCP } from "./mcp/index"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { PluginV2 } from "./plugin"
|
||||
@@ -58,6 +59,7 @@ const locationServiceNodes = [
|
||||
Reference.node,
|
||||
Integration.node,
|
||||
Catalog.node,
|
||||
ModelResolver.node,
|
||||
AISDK.node,
|
||||
PluginV2.node,
|
||||
PluginSupervisor.node,
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
export * as ModelResolver from "./model-resolver"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Model } from "@opencode-ai/ai"
|
||||
// ast-grep-ignore: no-star-import
|
||||
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
||||
// ast-grep-ignore: no-star-import
|
||||
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
|
||||
// ast-grep-ignore: no-star-import
|
||||
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
|
||||
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
import { AISDK } from "./aisdk"
|
||||
import { Catalog } from "./catalog"
|
||||
import { Credential } from "./credential"
|
||||
import { Integration } from "./integration"
|
||||
import { ModelV2 } from "./model"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { OpenAICodex } from "./plugin/provider/openai-codex"
|
||||
import { ProviderV2 } from "./provider"
|
||||
|
||||
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
||||
"SessionRunnerModel.VariantUnavailableError",
|
||||
{
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
variant: ModelV2.VariantID,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Variant unavailable for ${this.providerID}/${this.modelID}: ${this.variant}`
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedPackageError extends Schema.TaggedErrorClass<UnsupportedPackageError>()(
|
||||
"SessionRunnerModel.UnsupportedPackageError",
|
||||
{
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
package: Schema.String,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Unsupported package for ${this.providerID}/${this.modelID}: ${this.package}`
|
||||
}
|
||||
}
|
||||
|
||||
export type Error = VariantUnavailableError | UnsupportedPackageError | Integration.AuthorizationError
|
||||
|
||||
export interface Resolved {
|
||||
/** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */
|
||||
readonly model: Model
|
||||
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
|
||||
readonly ref: ModelV2.Ref
|
||||
/** Catalog capabilities used to shape requests before provider lowering. */
|
||||
readonly capabilities: ModelV2.Capabilities
|
||||
/** Catalog pricing in dollars per million tokens. */
|
||||
readonly cost: ModelV2.Info["cost"]
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly resolve: (requested?: ModelV2.Ref) => Effect.Effect<Resolved | undefined, Error>
|
||||
readonly resolveModel: (model: ModelV2.Info, variant?: ModelV2.VariantID) => Effect.Effect<Resolved, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ModelResolver") {}
|
||||
|
||||
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
|
||||
if (credential?.type === "key") return Auth.value(credential.key)
|
||||
if (credential?.type === "oauth") return Auth.value(credential.access)
|
||||
const value = model.settings?.apiKey
|
||||
if (typeof value === "string") return Auth.value(value)
|
||||
return undefined
|
||||
}
|
||||
|
||||
const withDefaults = (model: ModelV2.Info, route: AnyRoute) =>
|
||||
route.with({
|
||||
provider: model.providerID,
|
||||
endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined,
|
||||
headers: providerHeaders(model),
|
||||
providerOptions: providerOptions(model),
|
||||
http: model.body === undefined ? undefined : { body: model.body },
|
||||
limits: { context: model.limit.context, output: model.limit.output },
|
||||
})
|
||||
|
||||
const providerHeaders = (model: ModelV2.Info) => {
|
||||
const packageName = ProviderV2.packageName(model.package)
|
||||
const generated = new Map<string, string>()
|
||||
if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string")
|
||||
generated.set("OpenAI-Organization", model.settings.organization)
|
||||
if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string")
|
||||
generated.set("OpenAI-Project", model.settings.project)
|
||||
if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string")
|
||||
generated.set("Authorization", `Bearer ${model.settings.authToken}`)
|
||||
return ProviderV2.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
|
||||
}
|
||||
|
||||
const providerOptions = (
|
||||
model: ModelV2.Info,
|
||||
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
||||
if (!ProviderV2.isAISDK(model.package) || model.settings === undefined) return undefined
|
||||
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
|
||||
if (Object.keys(settings).length === 0) return undefined
|
||||
const packageName = ProviderV2.packageName(model.package)
|
||||
if (packageName === "@ai-sdk/openai") return { openai: settings }
|
||||
if (packageName === "@ai-sdk/anthropic") return { anthropic: settings }
|
||||
if (packageName === "@ai-sdk/openai-compatible") return { openai: settings }
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const withVariant = (
|
||||
model: ModelV2.Info,
|
||||
variantID: ModelV2.VariantID | undefined,
|
||||
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
|
||||
const id = variantID === "default" ? undefined : variantID
|
||||
const variant = model.variants?.find((item) => item.id === id)
|
||||
if (!variant && variantID !== undefined && variantID !== "default")
|
||||
return Effect.fail(
|
||||
new VariantUnavailableError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
variant: variantID,
|
||||
}),
|
||||
)
|
||||
return Effect.succeed(
|
||||
variant
|
||||
? produce(model, (draft) => {
|
||||
draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings)
|
||||
draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers)
|
||||
draft.body = ProviderV2.mergeOverlay(draft.body, variant.body)
|
||||
})
|
||||
: model,
|
||||
)
|
||||
}
|
||||
|
||||
export interface Dependencies {
|
||||
readonly loadPackage?: (specifier: string) => Effect.Effect<ProviderV2.ProviderPackage, ProviderV2.LoadError>
|
||||
readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect<Model, AISDK.InitError>
|
||||
}
|
||||
|
||||
export const fromCatalogModel = (
|
||||
model: ModelV2.Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
): Effect.Effect<Model, UnsupportedPackageError> => {
|
||||
const resolved = produce(model, (draft) => {
|
||||
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
|
||||
if (credential?.type === "key" && credential.metadata !== undefined)
|
||||
draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata)
|
||||
})
|
||||
const packageName = ProviderV2.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
|
||||
if (OpenAICodex.isChatGPT(credential) && !ProviderV2.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
|
||||
return Effect.succeed(codexModel(resolved, credential, key))
|
||||
}
|
||||
|
||||
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
||||
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
if (
|
||||
ProviderV2.isAISDK(resolved.package) &&
|
||||
packageName === "@ai-sdk/openai-compatible" &&
|
||||
typeof resolved.settings?.baseURL === "string"
|
||||
) {
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
if (ProviderV2.isAISDK(resolved.package)) {
|
||||
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = ProviderV2.mergeOverlay(draft.settings, {
|
||||
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
|
||||
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
|
||||
...credential?.metadata,
|
||||
})
|
||||
})
|
||||
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
}
|
||||
if (!resolved.package) return Effect.fail(unsupported(resolved))
|
||||
|
||||
const specifier = resolved.package
|
||||
return Effect.gen(function* () {
|
||||
const module = yield* (dependencies?.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe(
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const configured = { ...resolved.settings, ...credential?.metadata }
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(configured) : configured),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: resolved.headers,
|
||||
body: resolved.body,
|
||||
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
|
||||
return Model.update(runtime, {
|
||||
provider: resolved.providerID,
|
||||
compatibility: resolved.compatibility
|
||||
? Object.assign({}, runtime.compatibility, resolved.compatibility)
|
||||
: runtime.compatibility,
|
||||
})
|
||||
},
|
||||
catch: () => unsupported(resolved),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const isNativeOpenAI = (packageName: string | undefined) =>
|
||||
packageName === "@opencode-ai/ai/providers/openai" ||
|
||||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
|
||||
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
if (!credential) return {}
|
||||
if (credential.type === "key") return { apiKey: credential.key }
|
||||
if (
|
||||
specifier === "@opencode-ai/ai/providers/anthropic" ||
|
||||
specifier === "@opencode-ai/ai/providers/anthropic-compatible"
|
||||
)
|
||||
return { authToken: credential.access }
|
||||
if (
|
||||
specifier === "@opencode-ai/ai/providers/google-vertex" ||
|
||||
specifier.startsWith("@opencode-ai/ai/providers/google-vertex/")
|
||||
)
|
||||
return { accessToken: credential.access }
|
||||
return { apiKey: credential.access }
|
||||
}
|
||||
|
||||
const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
|
||||
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
|
||||
return rest
|
||||
}
|
||||
|
||||
const codexModel = (
|
||||
model: ModelV2.Info,
|
||||
credential: Credential.Value | undefined,
|
||||
key: ReturnType<typeof Auth.value> | undefined,
|
||||
) => {
|
||||
const account = OpenAICodex.accountID(credential)
|
||||
return withDefaults(model, OpenAIResponses.route)
|
||||
.with({
|
||||
endpoint: { baseURL: OpenAICodex.baseURL },
|
||||
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
|
||||
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
|
||||
),
|
||||
})
|
||||
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
|
||||
}
|
||||
|
||||
const unsupported = (model: ModelV2.Info) =>
|
||||
new UnsupportedPackageError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
package: model.package ?? "unknown",
|
||||
})
|
||||
|
||||
export const resolveModel = (
|
||||
model: ModelV2.Info,
|
||||
variant: ModelV2.VariantID | undefined,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
) => withVariant(model, variant).pipe(Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)))
|
||||
|
||||
export const supported = (model: ModelV2.Info) => Boolean(model.package)
|
||||
|
||||
/** Resolves catalog selections into runtime models for the current Location. */
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const npm = yield* Npm.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const load = Effect.fn("ModelResolver.resolveModel")(function* (
|
||||
selected: ModelV2.Info,
|
||||
variant?: ModelV2.VariantID,
|
||||
) {
|
||||
const provider = yield* catalog.provider.get(selected.providerID)
|
||||
const connection = yield* integrations.connection.active(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
)
|
||||
const model = yield* resolveModel(
|
||||
selected,
|
||||
variant,
|
||||
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
||||
{
|
||||
loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
},
|
||||
)
|
||||
return {
|
||||
model,
|
||||
ref: ModelV2.Ref.make({
|
||||
id: selected.id,
|
||||
providerID: selected.providerID,
|
||||
...(variant === undefined ? {} : { variant }),
|
||||
}),
|
||||
capabilities: selected.capabilities,
|
||||
cost: selected.cost,
|
||||
}
|
||||
})
|
||||
return Service.of({
|
||||
resolve: Effect.fn("ModelResolver.resolve")(function* (requested) {
|
||||
const selected = requested
|
||||
? yield* catalog.model.get(requested.providerID, requested.id)
|
||||
: yield* catalog.model
|
||||
.default()
|
||||
.pipe(
|
||||
Effect.flatMap((model) =>
|
||||
model && supported(model)
|
||||
? Effect.succeed(model)
|
||||
: Effect.map(catalog.model.available(), (models) => models.find(supported)),
|
||||
),
|
||||
)
|
||||
if (!selected) return undefined
|
||||
return yield* load(selected, requested?.variant)
|
||||
}),
|
||||
resolveModel: load,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as OpenAICodex from "./openai-codex"
|
||||
|
||||
// TEMPORARY SEAM (#34765): plugins have no hook into LLM route construction, so
|
||||
// codex routing lives in SessionRunnerModel.fromCatalogModel and catalog filtering
|
||||
// Codex routing lives in ModelResolver and catalog filtering.
|
||||
// in OpenAIPlugin, sharing this module. Once the native provider packages land
|
||||
// (#33689/#33925/#34462) this should collapse into the native OpenAI provider.
|
||||
// The eligibility rules mirror V1's CodexAuthPlugin allowlist; models.dev has no
|
||||
|
||||
@@ -2,30 +2,16 @@ export * as SessionRunnerModel from "./model"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Model } from "@opencode-ai/ai"
|
||||
// ast-grep-ignore: no-star-import
|
||||
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
||||
// ast-grep-ignore: no-star-import
|
||||
import * as OpenAICompatibleChat from "@opencode-ai/ai/protocols/openai-compatible-chat"
|
||||
// ast-grep-ignore: no-star-import
|
||||
import * as OpenAIResponses from "@opencode-ai/ai/protocols/openai-responses"
|
||||
import { Auth, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
import { AISDK } from "../../aisdk"
|
||||
import { Catalog } from "../../catalog"
|
||||
import { Credential } from "../../credential"
|
||||
import { Integration } from "../../integration"
|
||||
import { ModelResolver } from "../../model-resolver"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { OpenAICodex } from "../../plugin/provider/openai-codex"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { SessionSchema } from "../schema"
|
||||
|
||||
export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelectedError>()(
|
||||
"SessionRunnerModel.ModelNotSelectedError",
|
||||
{
|
||||
sessionID: SessionSchema.ID,
|
||||
},
|
||||
{ sessionID: SessionSchema.ID },
|
||||
) {
|
||||
override get message() {
|
||||
return `No model is available for session ${this.sessionID}`
|
||||
@@ -34,59 +20,19 @@ export class ModelNotSelectedError extends Schema.TaggedErrorClass<ModelNotSelec
|
||||
|
||||
export class ModelUnavailableError extends Schema.TaggedErrorClass<ModelUnavailableError>()(
|
||||
"SessionRunnerModel.ModelUnavailableError",
|
||||
{
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
},
|
||||
{ providerID: ProviderV2.ID, modelID: ModelV2.ID },
|
||||
) {
|
||||
override get message() {
|
||||
return `Model unavailable: ${this.providerID}/${this.modelID}`
|
||||
}
|
||||
}
|
||||
export const VariantUnavailableError = ModelResolver.VariantUnavailableError
|
||||
export type VariantUnavailableError = ModelResolver.VariantUnavailableError
|
||||
export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
|
||||
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
|
||||
|
||||
export class VariantUnavailableError extends Schema.TaggedErrorClass<VariantUnavailableError>()(
|
||||
"SessionRunnerModel.VariantUnavailableError",
|
||||
{
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
variant: ModelV2.VariantID,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Variant unavailable for ${this.providerID}/${this.modelID}: ${this.variant}`
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedPackageError extends Schema.TaggedErrorClass<UnsupportedPackageError>()(
|
||||
"SessionRunnerModel.UnsupportedPackageError",
|
||||
{
|
||||
providerID: ProviderV2.ID,
|
||||
modelID: ModelV2.ID,
|
||||
package: Schema.String,
|
||||
},
|
||||
) {
|
||||
override get message() {
|
||||
return `Unsupported package for ${this.providerID}/${this.modelID}: ${this.package}`
|
||||
}
|
||||
}
|
||||
|
||||
export type Error =
|
||||
| ModelNotSelectedError
|
||||
| ModelUnavailableError
|
||||
| VariantUnavailableError
|
||||
| UnsupportedPackageError
|
||||
| Integration.AuthorizationError
|
||||
|
||||
export interface Resolved {
|
||||
/** Route-level model for provider requests; its id is the provider API model id, which may differ from the catalog id. */
|
||||
readonly model: Model
|
||||
/** Selected catalog identity. Durable records and displays must use this, never the API model id. */
|
||||
readonly ref: ModelV2.Ref
|
||||
/** Catalog capabilities used to shape requests before provider lowering. */
|
||||
readonly capabilities: ModelV2.Capabilities
|
||||
/** Catalog pricing in dollars per million tokens. */
|
||||
readonly cost: ModelV2.Info["cost"]
|
||||
}
|
||||
export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error
|
||||
export type Resolved = ModelResolver.Resolved
|
||||
|
||||
export interface Interface {
|
||||
readonly resolve: (session: SessionSchema.Info) => Effect.Effect<Resolved, Error>
|
||||
@@ -94,9 +40,6 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/SessionRunnerModel") {}
|
||||
|
||||
/** Test or embedding seam for supplying a model resolver directly. */
|
||||
export const layerWith = (resolve: Interface["resolve"]) => Layer.succeed(Service, Service.of({ resolve }))
|
||||
|
||||
/** Builds a Resolved whose catalog identity mirrors the route model. Test or embedding seam. */
|
||||
export const resolved = (
|
||||
model: Model,
|
||||
@@ -116,276 +59,31 @@ export const resolved = (
|
||||
cost: options.cost,
|
||||
})
|
||||
|
||||
const apiKey = (model: ModelV2.Info, credential?: Credential.Value) => {
|
||||
if (credential?.type === "key") return Auth.value(credential.key)
|
||||
if (credential?.type === "oauth") return Auth.value(credential.access)
|
||||
const value = model.settings?.apiKey
|
||||
if (typeof value === "string") return Auth.value(value)
|
||||
}
|
||||
|
||||
const withDefaults = (model: ModelV2.Info, route: AnyRoute) =>
|
||||
route.with({
|
||||
provider: model.providerID,
|
||||
endpoint: typeof model.settings?.baseURL === "string" ? { baseURL: model.settings.baseURL } : undefined,
|
||||
headers: providerHeaders(model),
|
||||
providerOptions: providerOptions(model),
|
||||
http: model.body === undefined ? undefined : { body: model.body },
|
||||
limits: { context: model.limit.context, output: model.limit.output },
|
||||
})
|
||||
|
||||
const providerHeaders = (model: ModelV2.Info) => {
|
||||
const packageName = ProviderV2.packageName(model.package)
|
||||
const generated = new Map<string, string>()
|
||||
if (packageName === "@ai-sdk/openai" && typeof model.settings?.organization === "string")
|
||||
generated.set("OpenAI-Organization", model.settings.organization)
|
||||
if (packageName === "@ai-sdk/openai" && typeof model.settings?.project === "string")
|
||||
generated.set("OpenAI-Project", model.settings.project)
|
||||
if (packageName === "@ai-sdk/anthropic" && typeof model.settings?.authToken === "string")
|
||||
generated.set("Authorization", `Bearer ${model.settings.authToken}`)
|
||||
return ProviderV2.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
|
||||
}
|
||||
|
||||
const providerOptions = (
|
||||
model: ModelV2.Info,
|
||||
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
|
||||
if (!ProviderV2.isAISDK(model.package) || model.settings === undefined) return undefined
|
||||
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
|
||||
if (Object.keys(settings).length === 0) return undefined
|
||||
const packageName = ProviderV2.packageName(model.package)
|
||||
if (packageName === "@ai-sdk/openai") return { openai: settings }
|
||||
if (packageName === "@ai-sdk/anthropic") return { anthropic: settings }
|
||||
if (packageName === "@ai-sdk/openai-compatible") return { openai: settings }
|
||||
}
|
||||
|
||||
export const withVariant = (
|
||||
model: ModelV2.Info,
|
||||
variantID: ModelV2.VariantID | undefined,
|
||||
): Effect.Effect<ModelV2.Info, VariantUnavailableError> => {
|
||||
const id = variantID === "default" ? undefined : variantID
|
||||
const variant = model.variants?.find((item) => item.id === id)
|
||||
if (!variant && variantID !== undefined && variantID !== "default")
|
||||
return Effect.fail(
|
||||
new VariantUnavailableError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
variant: variantID,
|
||||
}),
|
||||
)
|
||||
return Effect.succeed(
|
||||
variant
|
||||
? produce(model, (draft) => {
|
||||
draft.settings = ProviderV2.mergeOverlay(draft.settings, variant.settings)
|
||||
draft.headers = ProviderV2.mergeHeaders(draft.headers, variant.headers)
|
||||
draft.body = ProviderV2.mergeOverlay(draft.body, variant.body)
|
||||
})
|
||||
: model,
|
||||
)
|
||||
}
|
||||
|
||||
export interface Dependencies {
|
||||
readonly loadPackage?: (specifier: string) => Effect.Effect<ProviderV2.ProviderPackage, ProviderV2.LoadError>
|
||||
readonly loadAISDK?: (model: ModelV2.Info) => Effect.Effect<Model, AISDK.InitError>
|
||||
}
|
||||
|
||||
export const fromCatalogModel = (
|
||||
model: ModelV2.Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies: Dependencies = {},
|
||||
): Effect.Effect<Model, UnsupportedPackageError> => {
|
||||
const resolved = produce(model, (draft) => {
|
||||
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
|
||||
if (credential?.type === "key" && credential.metadata !== undefined)
|
||||
draft.body = ProviderV2.mergeOverlay(draft.body, credential.metadata)
|
||||
})
|
||||
const packageName = ProviderV2.packageName(resolved.package)
|
||||
const key = apiKey(resolved, credential)
|
||||
|
||||
if (OpenAICodex.isChatGPT(credential) && !ProviderV2.isAISDK(resolved.package) && isNativeOpenAI(resolved.package)) {
|
||||
return Effect.succeed(codexModel(resolved, credential, key))
|
||||
}
|
||||
|
||||
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
|
||||
if (OpenAICodex.isChatGPT(credential)) return Effect.succeed(codexModel(resolved, credential, key))
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAIResponses.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
if (ProviderV2.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, AnthropicMessages.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
if (
|
||||
ProviderV2.isAISDK(resolved.package) &&
|
||||
packageName === "@ai-sdk/openai-compatible" &&
|
||||
typeof resolved.settings?.baseURL === "string"
|
||||
) {
|
||||
return Effect.succeed(
|
||||
withDefaults(resolved, OpenAICompatibleChat.route)
|
||||
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
|
||||
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
|
||||
)
|
||||
}
|
||||
if (ProviderV2.isAISDK(resolved.package)) {
|
||||
if (!dependencies.loadAISDK) return Effect.fail(unsupported(resolved))
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = ProviderV2.mergeOverlay(draft.settings, {
|
||||
...(credential?.type === "key" ? { apiKey: credential.key } : {}),
|
||||
...(credential?.type === "oauth" ? { apiKey: credential.access } : {}),
|
||||
...credential?.metadata,
|
||||
})
|
||||
})
|
||||
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
}
|
||||
if (!resolved.package) return Effect.fail(unsupported(resolved))
|
||||
|
||||
const specifier = resolved.package
|
||||
return Effect.gen(function* () {
|
||||
const module = yield* (dependencies.loadPackage ?? ProviderV2.loadPackage)(specifier).pipe(
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const configured = { ...resolved.settings, ...credential?.metadata }
|
||||
const settings = {
|
||||
...(credential ? withoutNativeAuthSettings(configured) : configured),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: resolved.headers,
|
||||
body: resolved.body,
|
||||
limits: { context: resolved.limit.context, output: resolved.limit.output },
|
||||
}
|
||||
return yield* Effect.try({
|
||||
try: () => {
|
||||
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
|
||||
return Model.update(runtime, {
|
||||
provider: resolved.providerID,
|
||||
compatibility: resolved.compatibility
|
||||
? { ...runtime.compatibility, ...resolved.compatibility }
|
||||
: runtime.compatibility,
|
||||
})
|
||||
},
|
||||
catch: () => unsupported(resolved),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const isNativeOpenAI = (packageName: string | undefined) =>
|
||||
packageName === "@opencode-ai/ai/providers/openai" ||
|
||||
packageName?.startsWith("@opencode-ai/ai/providers/openai/") === true
|
||||
|
||||
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
|
||||
if (!credential) return {}
|
||||
if (credential.type === "key") return { apiKey: credential.key }
|
||||
if (
|
||||
specifier === "@opencode-ai/ai/providers/anthropic" ||
|
||||
specifier === "@opencode-ai/ai/providers/anthropic-compatible"
|
||||
)
|
||||
return { authToken: credential.access }
|
||||
if (
|
||||
specifier === "@opencode-ai/ai/providers/google-vertex" ||
|
||||
specifier.startsWith("@opencode-ai/ai/providers/google-vertex/")
|
||||
)
|
||||
return { accessToken: credential.access }
|
||||
return { apiKey: credential.access }
|
||||
}
|
||||
|
||||
const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
|
||||
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
|
||||
return rest
|
||||
}
|
||||
|
||||
const codexModel = (
|
||||
model: ModelV2.Info,
|
||||
credential: Credential.Value | undefined,
|
||||
key: ReturnType<typeof Auth.value> | undefined,
|
||||
) => {
|
||||
const account = OpenAICodex.accountID(credential)
|
||||
return withDefaults(model, OpenAIResponses.route)
|
||||
.with({
|
||||
endpoint: { baseURL: OpenAICodex.baseURL },
|
||||
auth: (key === undefined ? Auth.none : Auth.bearer(key)).andThen(
|
||||
account === undefined ? Auth.none : Auth.headers({ "chatgpt-account-id": account }),
|
||||
),
|
||||
})
|
||||
.model({ id: model.modelID ?? model.id, compatibility: model.compatibility })
|
||||
}
|
||||
|
||||
const unsupported = (model: ModelV2.Info) =>
|
||||
new UnsupportedPackageError({
|
||||
providerID: model.providerID,
|
||||
modelID: model.id,
|
||||
package: model.package ?? "unknown",
|
||||
})
|
||||
|
||||
export const resolve = (
|
||||
session: SessionSchema.Info,
|
||||
model: ModelV2.Info,
|
||||
credential?: Credential.Value,
|
||||
dependencies?: Dependencies,
|
||||
) =>
|
||||
withVariant(model, session.model?.variant).pipe(
|
||||
Effect.flatMap((model) => fromCatalogModel(model, credential, dependencies)),
|
||||
)
|
||||
|
||||
export const supported = (model: ModelV2.Info) => Boolean(model.package)
|
||||
|
||||
/** Resolves models from the catalog belonging to the current Location runtime. */
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const npm = yield* Npm.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const resolver = yield* ModelResolver.Service
|
||||
return Service.of({
|
||||
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session) {
|
||||
// Location plugins populate and filter the catalog asynchronously during layer startup.
|
||||
const defaultModel = session.model ? undefined : yield* catalog.model.default()
|
||||
const selected = session.model
|
||||
? (yield* catalog.model.available()).find(
|
||||
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
|
||||
)
|
||||
: defaultModel && supported(defaultModel)
|
||||
? defaultModel
|
||||
: (yield* catalog.model.available()).find(supported)
|
||||
if (!selected && session.model)
|
||||
if (!session.model) {
|
||||
const resolved = yield* resolver.resolve()
|
||||
if (resolved) return resolved
|
||||
return yield* new ModelNotSelectedError({ sessionID: session.id })
|
||||
}
|
||||
const selected = (yield* catalog.model.available()).find(
|
||||
(model) => model.providerID === session.model?.providerID && model.id === session.model.id,
|
||||
)
|
||||
if (!selected)
|
||||
return yield* new ModelUnavailableError({
|
||||
providerID: session.model.providerID,
|
||||
modelID: session.model.id,
|
||||
})
|
||||
if (!selected) return yield* new ModelNotSelectedError({ sessionID: session.id })
|
||||
const provider = yield* catalog.provider.get(selected.providerID)
|
||||
const connection = yield* integrations.connection.active(
|
||||
provider?.integrationID ?? Integration.ID.make(selected.providerID),
|
||||
)
|
||||
const model = yield* resolve(
|
||||
session,
|
||||
selected,
|
||||
connection ? yield* integrations.connection.resolve(connection) : undefined,
|
||||
{
|
||||
loadPackage: (specifier) => ProviderV2.loadPackage(specifier, npm),
|
||||
loadAISDK: (model) => aisdk.model(model),
|
||||
},
|
||||
)
|
||||
return {
|
||||
model,
|
||||
ref: ModelV2.Ref.make({
|
||||
id: selected.id,
|
||||
providerID: selected.providerID,
|
||||
...(session.model?.variant === undefined ? {} : { variant: session.model.variant }),
|
||||
}),
|
||||
capabilities: selected.capabilities,
|
||||
cost: selected.cost,
|
||||
}
|
||||
return yield* resolver.resolveModel(selected, session.model.variant)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
|
||||
})
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Catalog.node, ModelResolver.node] })
|
||||
|
||||
@@ -4,7 +4,10 @@ import { Schema, SchemaGetter } from "effect"
|
||||
import { PositiveInt } from "../../schema"
|
||||
import { ConfigPermissionV1 } from "./permission"
|
||||
|
||||
const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
|
||||
const Color = Schema.Union([
|
||||
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
|
||||
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
||||
])
|
||||
|
||||
const AgentSchema = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
@@ -26,7 +29,7 @@ const AgentSchema = Schema.StructWithRest(
|
||||
}),
|
||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
||||
color: Schema.optional(Color).annotate({
|
||||
description: "Hex color code (e.g., #FF5733)",
|
||||
description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
|
||||
}),
|
||||
steps: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum number of agentic iterations before forcing text-only response",
|
||||
|
||||
@@ -161,7 +161,7 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
|
||||
description: info.description,
|
||||
mode: info.mode,
|
||||
hidden: info.hidden,
|
||||
color: info.color,
|
||||
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
|
||||
steps: info.steps,
|
||||
disabled: info.disable,
|
||||
permissions: permissions(info.permission),
|
||||
|
||||
@@ -12,7 +12,7 @@ const it = testEffect(AISDK.locationLayer)
|
||||
|
||||
const model = (packageName: string, settings: Record<string, unknown> = {}) =>
|
||||
ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("catalog-model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("catalog-model")),
|
||||
modelID: ModelV2.ID.make("api-model"),
|
||||
package: ProviderV2.aisdk(packageName),
|
||||
settings,
|
||||
|
||||
@@ -49,6 +49,86 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
||||
const decode = Schema.decodeUnknownSync(Config.Info)
|
||||
|
||||
describe("ConfigProviderPlugin.Plugin", () => {
|
||||
it.effect("defaults custom models to agent capabilities", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("custom")
|
||||
const modelID = ModelV2.ID.make("chat")
|
||||
const config = Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: {
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
models: { chat: {} },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
yield* addPlugin(config)
|
||||
|
||||
const model = required(yield* catalog.model.get(providerID, modelID))
|
||||
expect(model.capabilities).toEqual({ tools: true, input: ["text", "image"], output: ["text"] })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves catalog capabilities unless config overrides them", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const providerID = ProviderV2.ID.make("custom")
|
||||
const inheritedID = ModelV2.ID.make("inherited")
|
||||
const overriddenID = ModelV2.ID.make("overridden")
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.model.update(providerID, inheritedID, (model) => {
|
||||
model.capabilities = { tools: false, input: ["text"], output: ["text"] }
|
||||
})
|
||||
draft.model.update(providerID, overriddenID, (model) => {
|
||||
model.capabilities = { tools: false, input: ["text"], output: ["text"] }
|
||||
})
|
||||
})
|
||||
const config = Config.Service.of({
|
||||
entries: () =>
|
||||
Effect.succeed([
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: {
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
models: {
|
||||
inherited: { name: "Inherited" },
|
||||
overridden: {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
|
||||
yield* addPlugin(config)
|
||||
|
||||
expect((yield* catalog.model.get(providerID, inheritedID))?.capabilities).toEqual({
|
||||
tools: false,
|
||||
input: ["text"],
|
||||
output: ["text"],
|
||||
})
|
||||
expect((yield* catalog.model.get(providerID, overriddenID))?.capabilities).toEqual({
|
||||
tools: true,
|
||||
input: ["text", "image"],
|
||||
output: ["text"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps configured model variant bodies unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LLMClient, LLMEvent, LLMResponse, Model } from "@opencode-ai/ai"
|
||||
import { OpenAIChat } from "@opencode-ai/ai/protocols"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Generate } from "@opencode-ai/core/generate"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const selected = ModelV2.Info.make({
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("test-provider"), ModelV2.ID.make("gemini")),
|
||||
package: ProviderV2.aisdk("@ai-sdk/google"),
|
||||
})
|
||||
const runtime = Model.make({ id: "gemini", provider: "test-provider", route: OpenAIChat.route })
|
||||
|
||||
const catalog = Layer.mock(Catalog.Service, {
|
||||
provider: {
|
||||
get: () => Effect.succeed(undefined),
|
||||
all: () => Effect.die("unused"),
|
||||
available: () => Effect.die("unused"),
|
||||
},
|
||||
model: {
|
||||
get: () => Effect.succeed(selected),
|
||||
all: () => Effect.die("unused"),
|
||||
available: () => Effect.die("unused"),
|
||||
default: () => Effect.die("unused"),
|
||||
small: () => Effect.die("unused"),
|
||||
},
|
||||
})
|
||||
const integrations = Layer.mock(Integration.Service, {
|
||||
connection: {
|
||||
active: () => Effect.succeed(undefined),
|
||||
resolve: () => Effect.die("unused"),
|
||||
key: () => Effect.die("unused"),
|
||||
update: () => Effect.die("unused"),
|
||||
remove: () => Effect.die("unused"),
|
||||
},
|
||||
oauth: {
|
||||
connect: () => Effect.die("unused"),
|
||||
status: () => Effect.die("unused"),
|
||||
complete: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
},
|
||||
command: {
|
||||
connect: () => Effect.die("unused"),
|
||||
status: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
},
|
||||
})
|
||||
const npm = Layer.mock(Npm.Service, {
|
||||
add: () => Effect.die("unused"),
|
||||
install: () => Effect.die("unused"),
|
||||
which: () => Effect.die("unused"),
|
||||
})
|
||||
const aisdk = Layer.mock(AISDK.Service, {
|
||||
hook: {
|
||||
sdk: () => Effect.die("unused"),
|
||||
language: () => Effect.die("unused"),
|
||||
},
|
||||
model: () => Effect.succeed(runtime),
|
||||
})
|
||||
const client = Layer.mock(LLMClient.Service)({
|
||||
prepare: () => Effect.die("unused"),
|
||||
stream: () => Stream.die("unused"),
|
||||
generate: () =>
|
||||
Effect.sync(() => {
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.textStart({ id: "generate" }),
|
||||
LLMEvent.textDelta({ id: "generate", text: "OK" }),
|
||||
LLMEvent.textEnd({ id: "generate" }),
|
||||
LLMEvent.finish({ reason: "stop" }),
|
||||
])
|
||||
if (!response) throw new Error("Incomplete generate response")
|
||||
return response
|
||||
}),
|
||||
})
|
||||
|
||||
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
|
||||
const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client))))
|
||||
const resolverIt = testEffect(resolver)
|
||||
|
||||
it.effect("loads dynamic AI SDK models", () =>
|
||||
Effect.gen(function* () {
|
||||
const generate = yield* Generate.Service
|
||||
const result = yield* generate.text({
|
||||
prompt: "Return exactly OK",
|
||||
model: ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }),
|
||||
})
|
||||
|
||||
expect(result).toBe("OK")
|
||||
}),
|
||||
)
|
||||
|
||||
resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolver = yield* ModelResolver.Service
|
||||
const result = yield* resolver.resolve(ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }))
|
||||
|
||||
expect(result).toEqual({
|
||||
model: runtime,
|
||||
ref: ModelV2.Ref.make({ providerID: selected.providerID, id: selected.id }),
|
||||
capabilities: selected.capabilities,
|
||||
cost: selected.cost,
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -49,12 +49,12 @@ test("defensively syncs advertised Copilot models", async () => {
|
||||
|
||||
try {
|
||||
const existing = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, ModelV2.ID.make("gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.githubCopilot, ModelV2.ID.make("gpt-5")),
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
name: "GPT-5 local",
|
||||
})
|
||||
const stale = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.githubCopilot, ModelV2.ID.make("stale")),
|
||||
modelID: ModelV2.ID.make("stale"),
|
||||
})
|
||||
const models = await CopilotModels.get(server.url.origin, {}, [existing, stale])
|
||||
|
||||
+37
-93
@@ -1,17 +1,13 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { LLM, Model } from "@opencode-ai/ai"
|
||||
import { LLMClient } from "@opencode-ai/ai/route"
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Credential } from "@opencode-ai/core/credential"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { ModelResolver } from "@opencode-ai/core/model-resolver"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
interface ModelOptions {
|
||||
@@ -43,13 +39,13 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
|
||||
limit: { context: 100, output: 20 },
|
||||
})
|
||||
|
||||
describe("SessionRunnerModel", () => {
|
||||
describe("ModelResolver", () => {
|
||||
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
})
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(catalog)
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
|
||||
|
||||
expect(catalog.id).toBe(ModelV2.ID.make("test-model"))
|
||||
expect(resolved).toMatchObject({ id: "api-test-model", provider: "test-provider" })
|
||||
@@ -68,7 +64,7 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("keeps catalog apiKey credentials out of provider JSON", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { apiKey: "secret", baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
@@ -82,7 +78,7 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("treats an empty configured API key as omitted", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { apiKey: "", baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
@@ -101,7 +97,7 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("uses merged API settings for OpenAI-compatible auth and request defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
|
||||
compatibility: { reasoningField: "vendor_reasoning" },
|
||||
settings: {
|
||||
@@ -130,7 +126,7 @@ describe("SessionRunnerModel", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("overlays selected OpenAI Session variant settings and bodies", () =>
|
||||
it.effect("overlays selected OpenAI variant settings and bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
@@ -147,22 +143,7 @@ describe("SessionRunnerModel", () => {
|
||||
},
|
||||
],
|
||||
})
|
||||
const session = SessionV2.Info.make({
|
||||
id: SessionV2.ID.make("ses_model_variant"),
|
||||
projectID: ProjectV2.ID.global,
|
||||
title: "test",
|
||||
model: {
|
||||
id: catalog.id,
|
||||
providerID: catalog.providerID,
|
||||
variant: ModelV2.VariantID.make("high"),
|
||||
},
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
})
|
||||
|
||||
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
|
||||
const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high"))
|
||||
|
||||
expect(resolved.route.defaults.headers).toMatchObject({ "x-test": "header", "x-variant": "high" })
|
||||
expect(resolved.route.defaults.http?.body).toEqual({
|
||||
@@ -177,7 +158,7 @@ describe("SessionRunnerModel", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("overlays selected OpenAI-compatible Session variant bodies", () =>
|
||||
it.effect("overlays selected OpenAI-compatible variant bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai-compatible"), {
|
||||
settings: { baseURL: "https://compatible.example/v1" },
|
||||
@@ -190,18 +171,7 @@ describe("SessionRunnerModel", () => {
|
||||
},
|
||||
],
|
||||
})
|
||||
const session = SessionV2.Info.make({
|
||||
id: SessionV2.ID.make("ses_compatible_variant"),
|
||||
projectID: ProjectV2.ID.global,
|
||||
title: "test",
|
||||
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
})
|
||||
|
||||
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
|
||||
const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high"))
|
||||
|
||||
expect(resolved.route.defaults.http?.body).toEqual({
|
||||
custom_extension: { enabled: true },
|
||||
@@ -211,27 +181,12 @@ describe("SessionRunnerModel", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects an explicit unavailable Session variant during model resolution", () =>
|
||||
it.effect("rejects an explicit unavailable variant during model resolution", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
})
|
||||
const session = SessionV2.Info.make({
|
||||
id: SessionV2.ID.make("ses_model_variant_unavailable"),
|
||||
projectID: ProjectV2.ID.global,
|
||||
title: "test",
|
||||
model: {
|
||||
id: catalog.id,
|
||||
providerID: catalog.providerID,
|
||||
variant: ModelV2.VariantID.make("unknown"),
|
||||
},
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
})
|
||||
|
||||
const failure = yield* SessionRunnerModel.resolve(session, catalog).pipe(Effect.flip)
|
||||
const failure = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("unknown")).pipe(Effect.flip)
|
||||
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "SessionRunnerModel.VariantUnavailableError",
|
||||
@@ -243,7 +198,7 @@ describe("SessionRunnerModel", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("overlays selected Anthropic Session variant settings", () =>
|
||||
it.effect("overlays selected Anthropic variant settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
|
||||
settings: { baseURL: "https://anthropic.example/v1" },
|
||||
@@ -256,18 +211,7 @@ describe("SessionRunnerModel", () => {
|
||||
},
|
||||
],
|
||||
})
|
||||
const session = SessionV2.Info.make({
|
||||
id: SessionV2.ID.make("ses_anthropic_variant"),
|
||||
projectID: ProjectV2.ID.global,
|
||||
title: "test",
|
||||
model: { id: catalog.id, providerID: catalog.providerID, variant: ModelV2.VariantID.make("high") },
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
})
|
||||
|
||||
const resolved = yield* SessionRunnerModel.resolve(session, catalog)
|
||||
const resolved = yield* ModelResolver.resolveModel(catalog, ModelV2.VariantID.make("high"))
|
||||
|
||||
expect(resolved.route.defaults.http?.body).toEqual({
|
||||
custom_extension: { enabled: true },
|
||||
@@ -280,7 +224,7 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("maps catalog Anthropic AI SDK models into native routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/anthropic"), {
|
||||
settings: { baseURL: "https://anthropic.example/v1" },
|
||||
}),
|
||||
@@ -296,7 +240,7 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("uses resolved credentials for bearer auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
@@ -320,7 +264,7 @@ describe("SessionRunnerModel", () => {
|
||||
it.effect("prefers stored credentials over configured auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const credential = Credential.Key.make({ type: "key", key: "stored-secret", metadata: { tenant: "work" } })
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { apiKey: "configured-secret", baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
@@ -343,7 +287,7 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("does not project OAuth account metadata into the request body", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
@@ -365,7 +309,7 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("routes ChatGPT OAuth credentials to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
@@ -400,7 +344,7 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("routes native OpenAI provider packages with ChatGPT credentials to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model("@opencode-ai/ai/providers/openai", {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
@@ -429,7 +373,7 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("does not route native OpenAI-compatible packages to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model("@opencode-ai/ai/providers/openai-compatible", {
|
||||
settings: { baseURL: "https://compatible.example/v1" },
|
||||
}),
|
||||
@@ -450,7 +394,7 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("maps legacy OpenAI organization and project settings to headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { organization: "org_123", project: "proj_123" },
|
||||
}),
|
||||
@@ -465,7 +409,7 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("routes ChatGPT OAuth credentials without an account id to the codex backend", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
@@ -496,7 +440,7 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("keeps non-ChatGPT OAuth credentials on the configured endpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
headers: {},
|
||||
@@ -528,12 +472,12 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("loads dynamic native provider packages through the injected package loader", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const native = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
)
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model("@opencode-ai/ai/providers/custom", {
|
||||
settings: { region: "test" },
|
||||
headers: { "x-package": "header" },
|
||||
@@ -565,7 +509,7 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("maps OAuth credentials to native provider auth settings", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const native = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
@@ -588,7 +532,7 @@ describe("SessionRunnerModel", () => {
|
||||
] as const
|
||||
|
||||
yield* Effect.forEach(packages, ([specifier, key]) =>
|
||||
SessionRunnerModel.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, {
|
||||
ModelResolver.fromCatalogModel(model(specifier, { settings: { apiKey: "configured-key" } }), credential, {
|
||||
loadPackage: () =>
|
||||
Effect.succeed({
|
||||
model: (modelID, settings) => {
|
||||
@@ -604,12 +548,12 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("loads arbitrary AISDK packages through the injected AISDK loader", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const native = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
)
|
||||
const resolved = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const resolved = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/google"), {
|
||||
modelID: "gemini-api-model",
|
||||
settings: { project: "test" },
|
||||
@@ -644,7 +588,7 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("rejects AISDK packages without an available loader", () =>
|
||||
Effect.gen(function* () {
|
||||
const failure = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const failure = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/google"), {
|
||||
settings: { baseURL: "https://google.example/v1" },
|
||||
}),
|
||||
@@ -662,12 +606,12 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("drops an empty API key before loading an AISDK package", () =>
|
||||
Effect.gen(function* () {
|
||||
const native = yield* SessionRunnerModel.fromCatalogModel(
|
||||
const native = yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/openai"), {
|
||||
settings: { baseURL: "https://openai.example/v1" },
|
||||
}),
|
||||
)
|
||||
yield* SessionRunnerModel.fromCatalogModel(
|
||||
yield* ModelResolver.fromCatalogModel(
|
||||
model(ProviderV2.aisdk("@ai-sdk/google"), {
|
||||
settings: { apiKey: "", baseURL: "https://google.example/v1" },
|
||||
}),
|
||||
@@ -685,9 +629,9 @@ describe("SessionRunnerModel", () => {
|
||||
|
||||
it.effect("reports whether a catalog model declares a provider package", () =>
|
||||
Effect.sync(() => {
|
||||
expect(SessionRunnerModel.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true)
|
||||
expect(SessionRunnerModel.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true)
|
||||
expect(SessionRunnerModel.supported(model(undefined))).toBe(false)
|
||||
expect(ModelResolver.supported(model(ProviderV2.aisdk("@ai-sdk/openai")))).toBe(true)
|
||||
expect(ModelResolver.supported(model("@opencode-ai/ai/providers/custom"))).toBe(true)
|
||||
expect(ModelResolver.supported(model(undefined))).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -108,7 +108,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -134,7 +134,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -169,7 +169,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -190,7 +190,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -210,7 +210,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -230,7 +230,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -251,7 +251,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -281,7 +281,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -310,7 +310,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
|
||||
modelID: ModelV2.ID.make("openai.gpt-5.5"),
|
||||
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"),
|
||||
}),
|
||||
@@ -338,7 +338,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-5.5")),
|
||||
modelID: ModelV2.ID.make("openai.gpt-5.5"),
|
||||
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"),
|
||||
}),
|
||||
@@ -347,7 +347,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("openai.gpt-oss-safeguard-120b")),
|
||||
modelID: ModelV2.ID.make("openai.gpt-oss-safeguard-120b"),
|
||||
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/mantle"),
|
||||
}),
|
||||
@@ -365,7 +365,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("@ai-sdk/amazon-bedrock/anthropic"),
|
||||
}),
|
||||
@@ -393,7 +393,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -425,7 +425,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -434,7 +434,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -443,7 +443,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("global.anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("global.anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -452,7 +452,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -461,7 +461,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -487,7 +487,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -574,7 +574,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
for (const item of cases) {
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)),
|
||||
...ModelV2.Info.default(ProviderV2.ID.amazonBedrock, ModelV2.ID.make(item.modelID)),
|
||||
modelID: ModelV2.ID.make(item.modelID),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -594,7 +594,7 @@ describe("AmazonBedrockPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("anthropic.claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("anthropic.claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("AnthropicPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom-anthropic"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("@ai-sdk/anthropic"),
|
||||
}),
|
||||
@@ -81,7 +81,7 @@ describe("AnthropicPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.anthropic, ModelV2.ID.make("claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
package: ProviderV2.aisdk("@ai-sdk/anthropic"),
|
||||
}),
|
||||
|
||||
@@ -121,7 +121,7 @@ describe("AzureCognitiveServicesPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -140,7 +140,7 @@ describe("AzureCognitiveServicesPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("deployment")),
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -149,7 +149,7 @@ describe("AzureCognitiveServicesPlugin", () => {
|
||||
})
|
||||
const ignored = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -170,7 +170,7 @@ describe("AzureCognitiveServicesPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("messages-deployment")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("messages-deployment")),
|
||||
modelID: ModelV2.ID.make("messages-deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -179,7 +179,7 @@ describe("AzureCognitiveServicesPlugin", () => {
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("chat-deployment")),
|
||||
modelID: ModelV2.ID.make("chat-deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -188,7 +188,7 @@ describe("AzureCognitiveServicesPlugin", () => {
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("language-deployment")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("azure-cognitive-services"), ModelV2.ID.make("language-deployment")),
|
||||
modelID: ModelV2.ID.make("language-deployment"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
|
||||
@@ -148,7 +148,7 @@ describe("AzurePlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -168,7 +168,7 @@ describe("AzurePlugin", () => {
|
||||
const exit = yield* aisdk
|
||||
.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -189,7 +189,7 @@ describe("AzurePlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -208,7 +208,7 @@ describe("AzurePlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -227,7 +227,7 @@ describe("AzurePlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
body: { useCompletionUrls: true },
|
||||
@@ -247,7 +247,7 @@ describe("AzurePlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("deployment")),
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -256,7 +256,7 @@ describe("AzurePlugin", () => {
|
||||
})
|
||||
const ignored = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("deployment")),
|
||||
modelID: ModelV2.ID.make("deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -280,7 +280,7 @@ describe("AzurePlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("messages-deployment")),
|
||||
modelID: ModelV2.ID.make("messages-deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -289,7 +289,7 @@ describe("AzurePlugin", () => {
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.azure, ModelV2.ID.make("language-deployment")),
|
||||
modelID: ModelV2.ID.make("language-deployment"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("CerebrasPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(
|
||||
...ModelV2.Info.default(
|
||||
ProviderV2.ID.make("custom-cerebras"),
|
||||
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||
),
|
||||
@@ -88,7 +88,7 @@ describe("CerebrasPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(
|
||||
...ModelV2.Info.default(
|
||||
ProviderV2.ID.make("custom-cerebras"),
|
||||
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||
),
|
||||
@@ -110,7 +110,7 @@ describe("CerebrasPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(
|
||||
...ModelV2.Info.default(
|
||||
ProviderV2.ID.make("custom-cerebras"),
|
||||
ModelV2.ID.make("llama-4-scout-17b-16e-instruct"),
|
||||
),
|
||||
|
||||
@@ -117,7 +117,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -139,7 +139,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
|
||||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -184,7 +184,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
|
||||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -214,7 +214,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
|
||||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -252,7 +252,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
|
||||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -284,7 +284,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
|
||||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -307,7 +307,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -331,7 +331,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -361,7 +361,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -385,7 +385,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(
|
||||
...ModelV2.Info.default(
|
||||
ProviderV2.ID.make("cloudflare-ai-gateway"),
|
||||
ModelV2.ID.make("anthropic/claude-sonnet-4-5"),
|
||||
),
|
||||
@@ -417,7 +417,7 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-ai-gateway"), ModelV2.ID.make("openai/gpt-5")),
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
|
||||
@@ -94,7 +94,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai")))
|
||||
const sdk = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
modelID: ModelV2.ID.make("@cf/model"),
|
||||
package: provider.package,
|
||||
settings: provider.settings,
|
||||
@@ -138,7 +138,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
modelID: ModelV2.ID.make("@cf/model"),
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://proxy.example/v1" },
|
||||
@@ -178,7 +178,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
modelID: ModelV2.ID.make("@cf/model"),
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://proxy.example/v1" },
|
||||
@@ -207,7 +207,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
modelID: ModelV2.ID.make("@cf/model"),
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/v1" },
|
||||
@@ -233,7 +233,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("alias")),
|
||||
modelID: ModelV2.ID.make("@cf/api-model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -253,7 +253,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("@cf/model")),
|
||||
modelID: ModelV2.ID.make("@cf/model"),
|
||||
package: "aisdk:@ai-sdk/anthropic",
|
||||
settings: { baseURL: "https://proxy.example/v1" },
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("DynamicProviderPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
|
||||
modelID: ModelV2.ID.make("test-model"),
|
||||
package: ProviderV2.aisdk(fixtureProvider),
|
||||
}),
|
||||
@@ -72,7 +72,7 @@ describe("DynamicProviderPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("test-model")),
|
||||
modelID: ModelV2.ID.make("test-model"),
|
||||
package: ProviderV2.aisdk(fixtureProvider),
|
||||
}),
|
||||
@@ -90,7 +90,7 @@ describe("DynamicProviderPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("test-model")),
|
||||
modelID: ModelV2.ID.make("test-model"),
|
||||
package: ProviderV2.aisdk(fixtureProvider),
|
||||
}),
|
||||
@@ -107,7 +107,7 @@ describe("DynamicProviderPlugin", () => {
|
||||
yield* addPlugin(npmEntrypoint(fixtureProviderPath))
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("npm-provider"), ModelV2.ID.make("test-model")),
|
||||
modelID: ModelV2.ID.make("test-model"),
|
||||
package: "aisdk:fixture-provider",
|
||||
}),
|
||||
@@ -125,7 +125,7 @@ describe("DynamicProviderPlugin", () => {
|
||||
const exit = yield* aisdk
|
||||
.language(
|
||||
ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("missing-entrypoint"), ModelV2.ID.make("alias")),
|
||||
modelID: ModelV2.ID.make("alias"),
|
||||
package: "aisdk:fixture-provider",
|
||||
}),
|
||||
@@ -143,7 +143,7 @@ describe("DynamicProviderPlugin", () => {
|
||||
const exit = yield* aisdk
|
||||
.language(
|
||||
ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("bad-import"), ModelV2.ID.make("alias")),
|
||||
modelID: ModelV2.ID.make("alias"),
|
||||
package: "aisdk:file:///missing/provider-factory.js",
|
||||
}),
|
||||
@@ -163,7 +163,7 @@ describe("DynamicProviderPlugin", () => {
|
||||
const exit = yield* aisdk
|
||||
.language(
|
||||
ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("missing-factory"), ModelV2.ID.make("alias")),
|
||||
modelID: ModelV2.ID.make("alias"),
|
||||
package: "aisdk:fixture-provider",
|
||||
}),
|
||||
@@ -181,7 +181,7 @@ describe("DynamicProviderPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const language = yield* aisdk.language(
|
||||
ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("alias")),
|
||||
modelID: ModelV2.ID.make("test-model-api"),
|
||||
package: ProviderV2.aisdk(fixtureProvider),
|
||||
}),
|
||||
|
||||
@@ -41,7 +41,7 @@ providers.forEach((item) =>
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* item.plugin.effect(host)
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make(item.id), modelID),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make(item.id), modelID),
|
||||
modelID,
|
||||
package: ProviderV2.aisdk(item.package),
|
||||
})
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const ignored = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -108,7 +108,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
})
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -128,7 +128,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")),
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -147,7 +147,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")),
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -166,7 +166,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -175,7 +175,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")),
|
||||
modelID: ModelV2.ID.make("gpt-5.1-codex"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -184,7 +184,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")),
|
||||
modelID: ModelV2.ID.make("gpt-4o"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -193,7 +193,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")),
|
||||
modelID: ModelV2.ID.make("gpt-5-mini"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -202,7 +202,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")),
|
||||
modelID: ModelV2.ID.make("gpt-5-mini-2025-08-07"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -227,7 +227,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("mai-code-1-flash-picker")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("mai-code-1-flash-picker")),
|
||||
modelID: ModelV2.ID.make("mai-code-1-flash-picker"),
|
||||
package: "aisdk:test-provider",
|
||||
settings: { endpoint: "responses" },
|
||||
@@ -237,7 +237,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
settings: { endpoint: "chat" },
|
||||
@@ -257,7 +257,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")),
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -266,7 +266,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")),
|
||||
modelID: ModelV2.ID.make("gpt-5-mini"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -275,7 +275,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")),
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -324,7 +324,7 @@ describe("GithubCopilotPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")),
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("GitLabPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
modelID: ModelV2.ID.make("claude"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -107,7 +107,7 @@ describe("GitLabPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
modelID: ModelV2.ID.make("claude"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -133,7 +133,7 @@ describe("GitLabPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
modelID: ModelV2.ID.make("claude"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -175,7 +175,7 @@ describe("GitLabPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
modelID: ModelV2.ID.make("claude"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -195,7 +195,7 @@ describe("GitLabPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
|
||||
modelID: ModelV2.ID.make("duo-workflow-custom"),
|
||||
package: "aisdk:test-provider",
|
||||
headers: {},
|
||||
@@ -229,7 +229,7 @@ describe("GitLabPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-exact")),
|
||||
modelID: ModelV2.ID.make("duo-workflow-exact"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -257,7 +257,7 @@ describe("GitLabPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("duo-workflow-custom")),
|
||||
modelID: ModelV2.ID.make("duo-workflow-custom"),
|
||||
package: "aisdk:test-provider",
|
||||
headers: {},
|
||||
@@ -284,7 +284,7 @@ describe("GitLabPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("gitlab"), ModelV2.ID.make("claude")),
|
||||
modelID: ModelV2.ID.make("claude"),
|
||||
package: "aisdk:test-provider",
|
||||
headers: { h: "v" },
|
||||
|
||||
@@ -116,7 +116,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(
|
||||
...ModelV2.Info.default(
|
||||
ProviderV2.ID.make("google-vertex-anthropic"),
|
||||
ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
),
|
||||
@@ -143,7 +143,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(
|
||||
...ModelV2.Info.default(
|
||||
ProviderV2.ID.make("google-vertex-anthropic"),
|
||||
ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
),
|
||||
@@ -167,7 +167,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -187,7 +187,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -206,7 +206,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const sdkResult = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
|
||||
modelID: ModelV2.ID.make(" claude-sonnet-4-5 "),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -215,7 +215,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
})
|
||||
const languageResult = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
|
||||
modelID: ModelV2.ID.make(" claude-sonnet-4-5 "),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -238,7 +238,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex-anthropic"), ModelV2.ID.make(" claude-sonnet-4-5 ")),
|
||||
modelID: ModelV2.ID.make(" claude-sonnet-4-5 "),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -257,7 +257,7 @@ describe("GoogleVertexAnthropicPlugin", () => {
|
||||
yield* addPlugin(GoogleVertexAnthropicPlugin)
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("claude-sonnet-4-5")),
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-5"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
|
||||
@@ -172,7 +172,7 @@ describe("GoogleVertexPlugin", () => {
|
||||
const provider = required(yield* catalog.provider.get(ProviderV2.ID.make("google-vertex")))
|
||||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
|
||||
modelID: ModelV2.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/google-vertex",
|
||||
}),
|
||||
@@ -294,7 +294,7 @@ describe("GoogleVertexPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
|
||||
modelID: ModelV2.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/google-vertex",
|
||||
}),
|
||||
@@ -339,7 +339,7 @@ describe("GoogleVertexPlugin", () => {
|
||||
() =>
|
||||
aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make("gemini")),
|
||||
modelID: ModelV2.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/openai-compatible",
|
||||
}),
|
||||
@@ -367,7 +367,7 @@ describe("GoogleVertexPlugin", () => {
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("google-vertex"), ModelV2.ID.make(" gemini-2.5-pro ")),
|
||||
modelID: ModelV2.ID.make(" gemini-2.5-pro "),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("GooglePlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("gemini")),
|
||||
modelID: ModelV2.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/google",
|
||||
}),
|
||||
@@ -45,7 +45,7 @@ describe("GooglePlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("google"), ModelV2.ID.make("gemini")),
|
||||
modelID: ModelV2.ID.make("gemini"),
|
||||
package: "aisdk:@ai-sdk/google",
|
||||
}),
|
||||
@@ -63,7 +63,7 @@ describe("GooglePlugin", () => {
|
||||
yield* addPlugin()
|
||||
const sdkEvent = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
|
||||
modelID: ModelV2.ID.make("gemini-api"),
|
||||
package: "aisdk:@ai-sdk/google",
|
||||
}),
|
||||
@@ -88,7 +88,7 @@ describe("GooglePlugin", () => {
|
||||
|
||||
const resolved = yield* aisdk.model(
|
||||
ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom-google"), ModelV2.ID.make("alias")),
|
||||
modelID: ModelV2.ID.make("gemini-api"),
|
||||
package: "aisdk:@ai-sdk/google",
|
||||
settings: { apiKey: "test" },
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("OpenAICompatiblePlugin", () => {
|
||||
yield* addPlugin()
|
||||
const defaulted = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -35,7 +35,7 @@ describe("OpenAICompatiblePlugin", () => {
|
||||
})
|
||||
const disabled = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -54,7 +54,7 @@ describe("OpenAICompatiblePlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("model")),
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -78,7 +78,7 @@ describe("OpenAICompatiblePlugin", () => {
|
||||
)
|
||||
yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom-provider"), ModelV2.ID.make("model")),
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -99,7 +99,7 @@ describe("OpenAICompatiblePlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("cloudflare-workers-ai"), ModelV2.ID.make("model")),
|
||||
modelID: ModelV2.ID.make("model"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("OpenAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5")),
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -86,7 +86,7 @@ describe("OpenAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5")),
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -105,7 +105,7 @@ describe("OpenAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("alias")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("alias")),
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -125,7 +125,7 @@ describe("OpenAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.anthropic, ModelV2.ID.make("gpt-5")),
|
||||
modelID: ModelV2.ID.make("gpt-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
|
||||
@@ -293,7 +293,7 @@ describe("OpencodePlugin", () => {
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
})
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||
...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")),
|
||||
modelID: ModelV2.ID.make("paid"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
cost: cost(1),
|
||||
@@ -320,7 +320,7 @@ describe("OpencodePlugin", () => {
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
})
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("free")),
|
||||
...ModelV2.Info.default(provider.id, ModelV2.ID.make("free")),
|
||||
modelID: ModelV2.ID.make("free"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
cost: cost(0),
|
||||
@@ -347,7 +347,7 @@ describe("OpencodePlugin", () => {
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
})
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("output-only")),
|
||||
...ModelV2.Info.default(provider.id, ModelV2.ID.make("output-only")),
|
||||
modelID: ModelV2.ID.make("output-only"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
cost: cost(0, 1),
|
||||
@@ -376,7 +376,7 @@ describe("OpencodePlugin", () => {
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
})
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||
...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")),
|
||||
modelID: ModelV2.ID.make("paid"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
cost: cost(1),
|
||||
@@ -410,7 +410,7 @@ describe("OpencodePlugin", () => {
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
})
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||
...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")),
|
||||
modelID: ModelV2.ID.make("paid"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
cost: cost(1),
|
||||
@@ -438,7 +438,7 @@ describe("OpencodePlugin", () => {
|
||||
settings: { apiKey: "configured" },
|
||||
})
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||
...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")),
|
||||
modelID: ModelV2.ID.make("paid"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
cost: cost(1),
|
||||
@@ -468,7 +468,7 @@ describe("OpencodePlugin", () => {
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
})
|
||||
const model = ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(provider.id, ModelV2.ID.make("paid")),
|
||||
...ModelV2.Info.default(provider.id, ModelV2.ID.make("paid")),
|
||||
modelID: ModelV2.ID.make("paid"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
cost: cost(1),
|
||||
|
||||
@@ -54,7 +54,7 @@ describe("OpenRouterPlugin", () => {
|
||||
|
||||
const ignored = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.openrouter, ModelV2.ID.make("openai/gpt-5")),
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
@@ -65,7 +65,7 @@ describe("OpenRouterPlugin", () => {
|
||||
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom"), ModelV2.ID.make("openai/gpt-5")),
|
||||
modelID: ModelV2.ID.make("openai/gpt-5"),
|
||||
package: ProviderV2.aisdk("test-provider"),
|
||||
}),
|
||||
|
||||
@@ -48,7 +48,7 @@ function withEnv<A, E, R>(vars: Record<string, string | undefined>, effect: () =
|
||||
|
||||
function model(providerID: string) {
|
||||
return ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make(providerID), ModelV2.ID.make("sap-model")),
|
||||
modelID: ModelV2.ID.make("sap-model"),
|
||||
package: ProviderV2.aisdk(fixtureProvider),
|
||||
})
|
||||
|
||||
@@ -58,7 +58,7 @@ describe("SnowflakeCortexPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-4")),
|
||||
modelID: ModelV2.ID.make("gpt-4"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -77,7 +77,7 @@ describe("SnowflakeCortexPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -97,7 +97,7 @@ describe("SnowflakeCortexPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -121,7 +121,7 @@ describe("SnowflakeCortexPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -141,7 +141,7 @@ describe("SnowflakeCortexPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
@@ -165,7 +165,7 @@ describe("SnowflakeCortexPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("snowflake-cortex"), ModelV2.ID.make("claude-sonnet-4-6")),
|
||||
modelID: ModelV2.ID.make("claude-sonnet-4-6"),
|
||||
package: "aisdk:test-provider",
|
||||
}),
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("VercelPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const event = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom-vercel"), ModelV2.ID.make("v0-1.0-md")),
|
||||
modelID: ModelV2.ID.make("v0-1.0-md"),
|
||||
package: "aisdk:@ai-sdk/vercel",
|
||||
}),
|
||||
|
||||
@@ -62,7 +62,7 @@ describe("XAIPlugin", () => {
|
||||
|
||||
const ignored = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
|
||||
modelID: ModelV2.ID.make("grok-4"),
|
||||
package: "aisdk:@ai-sdk/xai",
|
||||
}),
|
||||
@@ -72,7 +72,7 @@ describe("XAIPlugin", () => {
|
||||
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("xai"), ModelV2.ID.make("grok-4")),
|
||||
modelID: ModelV2.ID.make("grok-4"),
|
||||
package: "aisdk:@ai-sdk/xai",
|
||||
}),
|
||||
@@ -92,7 +92,7 @@ describe("XAIPlugin", () => {
|
||||
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("custom-xai"), ModelV2.ID.make("grok-4")),
|
||||
modelID: ModelV2.ID.make("grok-4"),
|
||||
package: "aisdk:@ai-sdk/xai",
|
||||
}),
|
||||
@@ -112,7 +112,7 @@ describe("XAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.make("xai"), ModelV2.ID.make("alias")),
|
||||
modelID: ModelV2.ID.make("grok-4"),
|
||||
package: "aisdk:@ai-sdk/xai",
|
||||
}),
|
||||
@@ -133,7 +133,7 @@ describe("XAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")),
|
||||
...ModelV2.Info.default(ProviderV2.ID.openai, ModelV2.ID.make("grok-4")),
|
||||
modelID: ModelV2.ID.make("grok-4"),
|
||||
package: "aisdk:@ai-sdk/xai",
|
||||
}),
|
||||
|
||||
@@ -49,14 +49,15 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
generate: () => Effect.die("unused"),
|
||||
})
|
||||
const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
|
||||
const models = SessionRunnerModel.layerWith(() =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
}),
|
||||
),
|
||||
)
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
}),
|
||||
),
|
||||
})
|
||||
const locations = Layer.effect(
|
||||
LocationServiceMap.Service,
|
||||
LayerMap.make(
|
||||
|
||||
@@ -66,14 +66,15 @@ const client = Layer.mock(LLMClient.Service)({
|
||||
return response
|
||||
}),
|
||||
})
|
||||
const models = SessionRunnerModel.layerWith(() =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
}),
|
||||
),
|
||||
)
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
}),
|
||||
),
|
||||
})
|
||||
const builtins = Layer.mock(InstructionBuiltIns.Service, {
|
||||
load: () =>
|
||||
Effect.succeed(
|
||||
|
||||
@@ -73,14 +73,15 @@ const model = OpenAIChat.route
|
||||
generation: { maxTokens: 20, temperature: 0 },
|
||||
})
|
||||
.model({ id: "gpt-4o-mini" })
|
||||
const models = SessionRunnerModel.layerWith(() =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
}),
|
||||
),
|
||||
)
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: () =>
|
||||
Effect.succeed(
|
||||
SessionRunnerModel.resolved(model, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
}),
|
||||
),
|
||||
})
|
||||
const systemContext = Layer.mock(InstructionBuiltIns.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const instructionContext = Layer.mock(InstructionDiscovery.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
const skillInstructions = Layer.mock(SkillInstructions.Service, { load: () => Effect.succeed(Instructions.empty) })
|
||||
|
||||
@@ -280,17 +280,18 @@ const echo = Layer.effectDiscard(
|
||||
const echoNode = makeLocationNode({ name: "test/session-runner-tools", layer: echo, deps: [ToolRegistry.node] })
|
||||
let modelResolveHook = Effect.void
|
||||
let currentModel = model
|
||||
const models = SessionRunnerModel.layerWith((session) =>
|
||||
modelResolveHook.pipe(
|
||||
Effect.as(
|
||||
SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
variant: session.model?.variant,
|
||||
}),
|
||||
const models = Layer.mock(SessionRunnerModel.Service)({
|
||||
resolve: (session) =>
|
||||
modelResolveHook.pipe(
|
||||
Effect.as(
|
||||
SessionRunnerModel.resolved(session.model?.id === "replacement" ? replacementModel : currentModel, {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
cost: [],
|
||||
variant: session.model?.variant,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
const systemContextKey = Instructions.Key.make("test/context")
|
||||
let systemBaseline = "Initial context"
|
||||
let systemRemoved = false
|
||||
|
||||
@@ -170,8 +170,8 @@ test("Core reuses the canonical shared schemas", async () => {
|
||||
for (const [core, shared] of schemas) expect(core).toBe(shared)
|
||||
|
||||
expect(Agent.Info.empty(Agent.ID.make("test"))).toEqual(AgentV2.Info.empty(AgentV2.ID.make("test")))
|
||||
expect(Model.Info.empty(Provider.ID.make("test"), Model.ID.make("model"))).toEqual(
|
||||
ModelV2.Info.empty(ProviderV2.ID.make("test"), ModelV2.ID.make("model")),
|
||||
expect(Model.Info.default(Provider.ID.make("test"), Model.ID.make("model"))).toEqual(
|
||||
ModelV2.Info.default(ProviderV2.ID.make("test"), ModelV2.ID.make("model")),
|
||||
)
|
||||
expect(Provider.Info.empty(Provider.ID.make("test"))).toEqual(ProviderV2.Info.empty(ProviderV2.ID.make("test")))
|
||||
expect(Skill.Source.key(Skill.DirectorySource.make({ type: "directory", path: AbsolutePath.make("/tmp") }))).toBe(
|
||||
|
||||
@@ -88,8 +88,8 @@ describe("search tools", () => {
|
||||
|
||||
expect(glob.output?.structured).toEqual({ count: FileSystem.DEFAULT_SEARCH_LIMIT })
|
||||
expect(grep.output?.structured).toEqual({ matches: FileSystem.DEFAULT_SEARCH_LIMIT })
|
||||
expect(glob.output?.content).toEqual([{ type: "text", text: glob.result.value }])
|
||||
expect(grep.output?.content).toEqual([{ type: "text", text: grep.result.value }])
|
||||
expect(glob.output?.content).toEqual([{ type: "text", text: String(glob.result.value) }])
|
||||
expect(grep.output?.content).toEqual([{ type: "text", text: String(grep.result.value) }])
|
||||
expect(String(glob.result.value).split("\n")).toHaveLength(FileSystem.DEFAULT_SEARCH_LIMIT)
|
||||
expect(grep.result.value).toStartWith(`Found ${FileSystem.DEFAULT_SEARCH_LIMIT} matches\n`)
|
||||
}),
|
||||
|
||||
@@ -94,9 +94,10 @@ You can also map a friendly catalog ID to a different API model ID with `modelID
|
||||
}
|
||||
```
|
||||
|
||||
Here `openai/coding-default` is the selectable catalog reference, while `gpt-5.2` is sent to the provider. When adding a
|
||||
model that is not already in the catalog, set accurate `capabilities` and `limit` values so OpenCode can expose tools and
|
||||
enforce the correct context limits. Set `disabled: true` on a model entry to hide it from the available catalog.
|
||||
Here `openai/coding-default` is the selectable catalog reference, while `gpt-5.2` is sent to the provider. A model that is
|
||||
not already in the catalog defaults to tool support, text and image input, and text output. Set accurate `capabilities`
|
||||
and `limit` values when those defaults do not match the model or OpenCode needs to enforce its context limits. Set
|
||||
`disabled: true` on a model entry to hide it from the available catalog.
|
||||
|
||||
OpenAI-compatible models that stream reasoning through a custom assistant-message field can set
|
||||
`compatibility.reasoningField`:
|
||||
@@ -193,8 +194,9 @@ For an OpenAI-compatible server, define a provider package, endpoint, and at lea
|
||||
}
|
||||
```
|
||||
|
||||
Use the server's real model name, limits, modalities, and tool support. OpenCode cannot infer these for a model you add
|
||||
manually. If the endpoint requires a key, add `apiKey` to provider `settings` using an environment substitution such as
|
||||
Use the server's real model name, limits, modalities, and tool support. OpenCode applies the custom-model capability
|
||||
defaults described above but cannot infer the server's actual limits or whether those defaults are accurate. If the
|
||||
endpoint requires a key, add `apiKey` to provider `settings` using an environment substitution such as
|
||||
`"apiKey": "{env:LOCAL_API_KEY}"`; do not commit secrets.
|
||||
|
||||
### Model references
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Schema, SchemaAST } from "effect"
|
||||
import { format } from "prettier"
|
||||
import { ThemeDefinition, ThemeFile } from "../../tui/src/theme/v2/schema"
|
||||
import { ThemeDefinition, ThemeDocument } from "../../tui/src/theme/v2/schema"
|
||||
|
||||
const target = import.meta.dir + "/../snippets/generated/theme-tokens.mdx"
|
||||
const root = requireObject(ThemeDefinition.ast)
|
||||
@@ -52,8 +52,8 @@ const example = {
|
||||
default: "#101014",
|
||||
},
|
||||
},
|
||||
} satisfies ThemeFile
|
||||
Schema.decodeUnknownSync(ThemeFile)(example)
|
||||
} satisfies ThemeDocument
|
||||
Schema.decodeUnknownSync(ThemeDocument)(example)
|
||||
const output = await format(
|
||||
`{/* Generated by packages/docs/script/generate-theme-tokens.ts. Do not edit. */}
|
||||
|
||||
|
||||
@@ -106,13 +106,13 @@ export const Info = Schema.Struct({
|
||||
.annotate({ identifier: "Model.Info" })
|
||||
.pipe(
|
||||
statics(() => ({
|
||||
empty: (providerID: Provider.ID, id: ID) =>
|
||||
default: (providerID: Provider.ID, id: ID) =>
|
||||
({
|
||||
id,
|
||||
modelID: id,
|
||||
providerID,
|
||||
name: id,
|
||||
capabilities: { tools: false, input: [], output: [] },
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
variants: [],
|
||||
time: { released: 0 },
|
||||
cost: [],
|
||||
|
||||
@@ -83,7 +83,7 @@ describe("contract hygiene", () => {
|
||||
|
||||
test("model defaults and provider overlays preserve public invariants", () => {
|
||||
const id = Model.ID.make("model")
|
||||
expect(Model.Info.empty(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] })
|
||||
expect(Model.Info.default(Provider.ID.make("provider"), id)).toMatchObject({ modelID: id, variants: [] })
|
||||
expect(() =>
|
||||
Schema.decodeUnknownSync(Provider.Info)({
|
||||
id: "provider",
|
||||
|
||||
@@ -32,6 +32,7 @@ export function CommentCardV2(props: {
|
||||
return (
|
||||
<TooltipV2
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={props.title ?? props.comment}
|
||||
disabled={!props.tooltip || !truncated()}
|
||||
class={props.wide ? "w-full" : undefined}
|
||||
|
||||
@@ -385,7 +385,12 @@ export function PromptInputV2Attachments(props: {
|
||||
<For each={props.comments ?? []}>
|
||||
{(comment) => (
|
||||
<div class="relative group shrink-0">
|
||||
<TooltipV2 value={comment.comment} placement="top" contentClass="max-w-[300px] break-words">
|
||||
<TooltipV2
|
||||
value={comment.comment}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
contentClass="max-w-[300px] break-words"
|
||||
>
|
||||
<CommentCardV2
|
||||
comment={comment.comment ?? ""}
|
||||
path={comment.path}
|
||||
|
||||
@@ -214,6 +214,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
</Show>
|
||||
<div class="flex items-center">
|
||||
<TooltipV2
|
||||
openDelay={2000}
|
||||
inactive={!prev()}
|
||||
value={
|
||||
<>
|
||||
@@ -233,6 +234,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
/>
|
||||
</TooltipV2>
|
||||
<TooltipV2
|
||||
openDelay={2000}
|
||||
inactive={!next()}
|
||||
value={
|
||||
<>
|
||||
@@ -266,12 +268,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
|
||||
aria-label={i18n.t("ui.sessionReviewV2.expandMode")}
|
||||
>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||
<SegmentedControlItemV2 value="expand" aria-label={i18n.t("ui.sessionReviewV2.showAllLines")}>
|
||||
<Icon name="expand" />
|
||||
</SegmentedControlItemV2>
|
||||
</TooltipV2>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||
<SegmentedControlItemV2 value="collapse" aria-label={i18n.t("ui.sessionReviewV2.hideNonDiffLines")}>
|
||||
<Icon name="collapse" />
|
||||
</SegmentedControlItemV2>
|
||||
@@ -287,12 +289,12 @@ export function SessionReviewV2(props: SessionReviewV2Props) {
|
||||
class="session-review-v2-segmented-control session-review-v2-segmented-control--icon"
|
||||
aria-label={i18n.t("ui.sessionReviewV2.diffView")}
|
||||
>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||
<SegmentedControlItemV2 value="unified" aria-label={i18n.t("ui.sessionReviewV2.unifiedDiff")}>
|
||||
<Icon name="unified" />
|
||||
</SegmentedControlItemV2>
|
||||
</TooltipV2>
|
||||
<TooltipV2 value={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||
<TooltipV2 openDelay={2000} value={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||
<SegmentedControlItemV2 value="split" aria-label={i18n.t("ui.sessionReviewV2.splitDiff")}>
|
||||
<Icon name="split" />
|
||||
</SegmentedControlItemV2>
|
||||
|
||||
@@ -65,6 +65,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
import { DialogSessionList } from "./component/dialog-session-list"
|
||||
import { ThemeErrorToast } from "./component/theme-error-toast"
|
||||
import { ThemeProvider, useTheme } from "./context/theme"
|
||||
import { Home } from "./routes/home"
|
||||
import { Session } from "./routes/session"
|
||||
@@ -337,6 +338,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
|
||||
@@ -59,6 +59,7 @@ export function DevToolsBar() {
|
||||
const canSwitchMode = () => supports(nextMode())
|
||||
const runtime = createMemo(() => runtimeStatus(frontendSamples()))
|
||||
const timing = () => config.data.debug?.timing ?? false
|
||||
const turnTokens = () => config.data.debug?.turn_tokens ?? false
|
||||
|
||||
const offEscape = keymap.intercept(
|
||||
"key",
|
||||
@@ -352,6 +353,16 @@ export function DevToolsBar() {
|
||||
>
|
||||
{timing() ? "[x]" : "[ ]"} Time to first draw
|
||||
</Action>
|
||||
<Action
|
||||
onClick={() =>
|
||||
void config.update((draft) => {
|
||||
draft.debug = { ...draft.debug, turn_tokens: !turnTokens() }
|
||||
})
|
||||
}
|
||||
hoverBackground
|
||||
>
|
||||
{turnTokens() ? "[x]" : "[ ]"} Turn token usage
|
||||
</Action>
|
||||
</box>
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
@@ -403,7 +414,7 @@ function PanelBox(props: ParentProps) {
|
||||
position="absolute"
|
||||
zIndex={2600}
|
||||
bottom={1}
|
||||
left={0}
|
||||
left={-1}
|
||||
width={42}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
|
||||
@@ -222,14 +222,6 @@ const settings: Setting[] = [
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
{
|
||||
title: "DevTools: Timing",
|
||||
category: "Debug",
|
||||
path: ["debug", "timing"],
|
||||
default: true,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
},
|
||||
]
|
||||
|
||||
export function DialogConfig() {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { onCleanup } from "solid-js"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useToast } from "../ui/toast"
|
||||
|
||||
export function ThemeErrorToast() {
|
||||
const theme = useTheme()
|
||||
const toast = useToast()
|
||||
|
||||
onCleanup(
|
||||
theme.onError(({ name, error }) =>
|
||||
toast.show({
|
||||
variant: "error",
|
||||
title: `Failed to load theme: ${name}`,
|
||||
message: error.message,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -142,6 +142,12 @@ export const Info = Schema.Struct({
|
||||
mono: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Use monochrome ASCII output",
|
||||
}),
|
||||
replay: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Restore session history on resume and terminal resize",
|
||||
}),
|
||||
replay_limit: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))).annotate({
|
||||
description: "Maximum number of newest messages restored during replay",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Mini transcript presentation settings" }),
|
||||
hints: Schema.optional(
|
||||
@@ -153,6 +159,7 @@ export const Info = Schema.Struct({
|
||||
Schema.Struct({
|
||||
devtools: Schema.optional(Schema.Boolean).annotate({ description: "Show the DevTools debug bar" }),
|
||||
timing: Schema.optional(Schema.Boolean).annotate({ description: "Show time-to-first-draw diagnostics" }),
|
||||
turn_tokens: Schema.optional(Schema.Boolean).annotate({ description: "Show per-turn token usage diagnostics" }),
|
||||
}),
|
||||
).annotate({ description: "Debugging settings" }),
|
||||
animations: Schema.optional(Schema.Boolean).annotate({ description: "Enable interface animations" }),
|
||||
|
||||
@@ -4,23 +4,21 @@ import {
|
||||
DEFAULT_THEMES,
|
||||
addTheme,
|
||||
allThemes,
|
||||
generateSyntax,
|
||||
hasTheme,
|
||||
isTheme,
|
||||
resolveTheme,
|
||||
parseTheme,
|
||||
selectedForeground,
|
||||
setCustomThemes,
|
||||
setSystemTheme,
|
||||
subscribeThemes,
|
||||
upsertTheme,
|
||||
type Theme,
|
||||
type ThemeJson,
|
||||
type ThemeDocumentSource,
|
||||
} from "../theme"
|
||||
import { generateSyntax } from "../theme/v2/syntax"
|
||||
import { generateSystem, terminalMode } from "../theme/system"
|
||||
import { discoverThemes, themeDirectories } from "../theme/discovery"
|
||||
import { createComponentTheme, type ComponentTheme } from "../theme/v2/component"
|
||||
import { resolveThemeFile } from "../theme/v2/resolve"
|
||||
import { migrateV1 } from "../theme/v2/v1-migrate"
|
||||
import { resolveThemeDocument } from "../theme/v2/resolve"
|
||||
import { themeModes } from "../theme/v2/select"
|
||||
import { createEffect, createMemo, onCleanup, onMount, type Accessor, type ParentProps } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
@@ -30,6 +28,36 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { DevTools } from "../devtools"
|
||||
|
||||
const themePerformance = DevTools.register({ id: "theme-performance", title: "Theme performance" })
|
||||
export type ThemeError = { name: string; error: Error }
|
||||
type ThemeErrorHandler = (event: ThemeError) => void
|
||||
|
||||
function createThemeErrors() {
|
||||
let handler: ThemeErrorHandler | undefined
|
||||
let pending: ThemeError | undefined
|
||||
|
||||
return {
|
||||
emit(name: string, cause: unknown) {
|
||||
const event = { name, error: cause instanceof Error ? cause : new Error(String(cause)) }
|
||||
if (handler) {
|
||||
handler(event)
|
||||
return
|
||||
}
|
||||
pending = event
|
||||
},
|
||||
onError(next: ThemeErrorHandler) {
|
||||
handler = next
|
||||
if (pending) {
|
||||
next(pending)
|
||||
pending = undefined
|
||||
}
|
||||
return () => {
|
||||
if (handler === next) handler = undefined
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const themeErrors = createThemeErrors()
|
||||
|
||||
export type ThemeSource = Readonly<{
|
||||
discover(): Promise<Record<string, unknown>>
|
||||
@@ -62,7 +90,7 @@ export {
|
||||
const THEME_REFRESH_DELAYS = [250, 1000] as const
|
||||
|
||||
type State = {
|
||||
themes: Record<string, ThemeJson>
|
||||
themes: Record<string, ThemeDocumentSource>
|
||||
mode: "dark" | "light"
|
||||
lock: "dark" | "light" | undefined
|
||||
active: string
|
||||
@@ -85,6 +113,7 @@ type ThemeService = {
|
||||
unlock(): void
|
||||
setMode(mode?: "dark" | "light", persist?: boolean): boolean
|
||||
set(theme: string): boolean
|
||||
onError(handler: ThemeErrorHandler): () => void
|
||||
readonly ready: boolean
|
||||
}
|
||||
|
||||
@@ -140,12 +169,7 @@ const themeContext = createSimpleContext({
|
||||
return themes
|
||||
.discover()
|
||||
.then((themes) => {
|
||||
setCustomThemes(
|
||||
Object.entries(themes).reduce<Record<string, ThemeJson>>((result, [name, theme]) => {
|
||||
if (isTheme(theme)) result[name] = theme
|
||||
return result
|
||||
}, {}),
|
||||
)
|
||||
setCustomThemes(themes)
|
||||
})
|
||||
.catch(() => setStore("active", "opencode"))
|
||||
}
|
||||
@@ -270,36 +294,31 @@ const themeContext = createSimpleContext({
|
||||
})
|
||||
|
||||
const initStarted = performance.now()
|
||||
const source = createMemo(() => store.themes[store.active] ?? store.themes.opencode)
|
||||
const sourceName = createMemo(() => (store.themes[store.active] ? store.active : "opencode"))
|
||||
const file = createMemo(() => migrateV1(source()))
|
||||
const modes = createMemo(() => themeModes(file()))
|
||||
const mode = () => {
|
||||
const supported = modes()
|
||||
if (supported.includes(store.mode)) return store.mode
|
||||
return supported[0] ?? store.mode
|
||||
}
|
||||
const legacySyntaxTheme = createMemo(() => resolveTheme(source(), mode()))
|
||||
const valuesV2 = createMemo(() => resolveThemeFile(file(), mode(), sourceName()))
|
||||
const selected = createMemo(() => {
|
||||
const name = store.themes[store.active] ? store.active : "opencode"
|
||||
try {
|
||||
return loadTheme(store.themes[name], name, store.mode)
|
||||
} catch (error) {
|
||||
if (name === "opencode") throw error
|
||||
themeErrors.emit(name, error)
|
||||
setStore("active", "opencode")
|
||||
return loadTheme(store.themes.opencode, "opencode", store.mode)
|
||||
}
|
||||
})
|
||||
const modes = () => selected().modes
|
||||
const mode = () => selected().mode
|
||||
const valuesV2 = () => selected().theme
|
||||
valuesV2()
|
||||
themePerformance.set("Init", `${(performance.now() - initStarted).toFixed(2)} ms`)
|
||||
const themeV2 = createComponentTheme(valuesV2, mode)
|
||||
const contextsV2 = {
|
||||
elevated: createComponentTheme(() => {
|
||||
const theme = valuesV2().contexts["@context:elevated"]
|
||||
if (!theme) throw new Error("Theme context is not defined: elevated")
|
||||
return theme
|
||||
}, mode),
|
||||
overlay: createComponentTheme(() => {
|
||||
const theme = valuesV2().contexts["@context:overlay"]
|
||||
if (!theme) throw new Error("Theme context is not defined: overlay")
|
||||
return theme
|
||||
}, mode),
|
||||
elevated: createComponentTheme(() => valuesV2().contexts["@context:elevated"] ?? valuesV2(), mode),
|
||||
overlay: createComponentTheme(() => valuesV2().contexts["@context:overlay"] ?? valuesV2(), mode),
|
||||
}
|
||||
|
||||
createEffect(() => renderer.setBackgroundColor(valuesV2().background.default))
|
||||
|
||||
const syntax = createSyntaxStyleMemo(() => generateSyntax(legacySyntaxTheme()))
|
||||
const syntax = createSyntaxStyleMemo(() => generateSyntax(valuesV2(), mode()))
|
||||
function contextual(context: ContextName) {
|
||||
return contextualServices[context]
|
||||
}
|
||||
@@ -333,6 +352,7 @@ const themeContext = createSimpleContext({
|
||||
.catch(() => {})
|
||||
return true
|
||||
},
|
||||
onError: themeErrors.onError,
|
||||
get ready() {
|
||||
return store.ready
|
||||
},
|
||||
@@ -356,6 +376,14 @@ export function ThemeContextProvider(props: ParentProps<{ context: ContextName }
|
||||
</themeContext.context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function loadTheme(source: ThemeDocumentSource, name: string, requested: "dark" | "light") {
|
||||
const document = parseTheme(source, name)
|
||||
const modes = themeModes(document)
|
||||
const mode = modes.includes(requested) ? requested : (modes[0] ?? requested)
|
||||
return { modes, mode, theme: resolveThemeDocument(document, mode) }
|
||||
}
|
||||
|
||||
export function createSyntaxStyleMemo(factory: () => SyntaxStyle) {
|
||||
const renderer = useRenderer()
|
||||
const retained = new Set<SyntaxStyle>()
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui"
|
||||
import { ansiToRgba } from "../theme/color"
|
||||
import { resolveThemeColors } from "../theme/resolve"
|
||||
import { terminalMode } from "../theme/system"
|
||||
import type { ThemeJson } from "../theme/v1"
|
||||
import type { ThemeV1Json } from "../theme/v1"
|
||||
import type { EntryKind, RunTuiConfig } from "./types"
|
||||
|
||||
type Tone = {
|
||||
@@ -184,7 +184,7 @@ function splashShadow(indexed: RGBA[], base: RGBA, overlay: RGBA, value: number)
|
||||
return nearestIndexed(indexed, mixed)
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, pick: "dark" | "light"): TuiThemeCurrent {
|
||||
export function resolveTheme(theme: ThemeV1Json, pick: "dark" | "light"): TuiThemeCurrent {
|
||||
const resolved = resolveThemeColors(theme, pick, (code) => RGBA.fromIndex(code, ansiToRgba(code)))
|
||||
return {
|
||||
...resolved.theme,
|
||||
@@ -246,7 +246,7 @@ function generateMutedTextColor(bg: RGBA, isDark: boolean, map: (rgba: RGBA) =>
|
||||
return map(RGBA.fromInts(gray, gray, gray))
|
||||
}
|
||||
|
||||
export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeJson {
|
||||
export function generateSystem(colors: TerminalColors, pick: "dark" | "light"): ThemeV1Json {
|
||||
const bg_snapshot = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
|
||||
const fg_snapshot = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
|
||||
const bg = RGBA.defaultBackground(bg_snapshot)
|
||||
|
||||
@@ -1029,11 +1029,13 @@ export function Session() {
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRowView(props: {
|
||||
type SessionRowViewProps = {
|
||||
row: SessionRow
|
||||
message: (messageID: string) => SessionMessageInfo | undefined
|
||||
boundaryID?: string
|
||||
}) {
|
||||
}
|
||||
|
||||
function SessionRowView(props: SessionRowViewProps) {
|
||||
return (
|
||||
<box id={props.boundaryID} marginTop={1} flexShrink={0}>
|
||||
<Switch>
|
||||
@@ -1072,11 +1074,109 @@ function SessionRowView(props: {
|
||||
</Show>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={props.row.type === "turn-usage" ? props.row : undefined}>
|
||||
{(row) => (
|
||||
<TurnTokenUsage
|
||||
messageIDs={row().messageIDs}
|
||||
previousCacheRead={row().previousCacheRead}
|
||||
message={props.message}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function TurnTokenUsage(props: {
|
||||
messageIDs: string[]
|
||||
previousCacheRead?: number
|
||||
message: (messageID: string) => SessionMessageInfo | undefined
|
||||
}) {
|
||||
const config = useConfig()
|
||||
const { themeV2 } = useTheme()
|
||||
const steps = createMemo(() => {
|
||||
let previousCacheRead = props.previousCacheRead
|
||||
return props.messageIDs.flatMap((messageID) => {
|
||||
const message = props.message(messageID)
|
||||
if (message?.type !== "assistant" || !message.tokens) return []
|
||||
const total =
|
||||
message.tokens.input +
|
||||
message.tokens.output +
|
||||
message.tokens.reasoning +
|
||||
message.tokens.cache.read +
|
||||
message.tokens.cache.write
|
||||
if (total === 0) return []
|
||||
const newTokens = total - message.tokens.cache.read
|
||||
const cacheBust =
|
||||
previousCacheRead !== undefined && message.tokens.cache.read < previousCacheRead
|
||||
? previousCacheRead - message.tokens.cache.read
|
||||
: undefined
|
||||
previousCacheRead = message.tokens.cache.read
|
||||
return [
|
||||
{
|
||||
finish: message.finish === "tool-calls" ? "tool-call" : (message.finish ?? "unknown"),
|
||||
newTokens,
|
||||
cached: message.tokens.cache.read,
|
||||
total,
|
||||
cacheBust,
|
||||
},
|
||||
]
|
||||
})
|
||||
})
|
||||
const columns = createMemo(() => ({
|
||||
step: Math.max("Step".length, ...steps().map((item) => item.finish.length)),
|
||||
newTokens: Math.max("New".length, ...steps().map((item) => item.newTokens.toLocaleString().length)),
|
||||
cached: Math.max("Cached".length, ...steps().map((item) => item.cached.toLocaleString().length)),
|
||||
total: Math.max("Total".length, ...steps().map((item) => item.total.toLocaleString().length)),
|
||||
}))
|
||||
return (
|
||||
<Show when={config.data.debug?.turn_tokens === true && steps().length > 0}>
|
||||
<box paddingLeft={3} flexDirection="column">
|
||||
<box flexDirection="row">
|
||||
<text width={INLINE_TOOL_ICON_WIDTH} fg={themeV2.text.subdued}>
|
||||
◈
|
||||
</text>
|
||||
<text fg={themeV2.text.subdued} attributes={TextAttributes.BOLD}>
|
||||
Tokens
|
||||
</text>
|
||||
</box>
|
||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH}>
|
||||
<text fg={themeV2.text.subdued} attributes={TextAttributes.ITALIC}>
|
||||
{"Step".padEnd(columns().step + 2)}
|
||||
{"New".padStart(columns().newTokens)}
|
||||
{" "}
|
||||
{"Cached".padStart(columns().cached)}
|
||||
{" "}
|
||||
{"Total".padStart(columns().total)}
|
||||
</text>
|
||||
</box>
|
||||
<For each={steps()}>
|
||||
{(item) => (
|
||||
<box paddingLeft={INLINE_TOOL_ICON_WIDTH} flexDirection="column">
|
||||
<text fg={themeV2.text.subdued}>
|
||||
{item.finish.padEnd(columns().step + 2)}
|
||||
<span style={{ attributes: TextAttributes.BOLD }}>
|
||||
{item.newTokens.toLocaleString().padStart(columns().newTokens)}
|
||||
</span>
|
||||
{" "}
|
||||
{item.cached.toLocaleString().padStart(columns().cached)}
|
||||
{" "}
|
||||
{item.total.toLocaleString().padStart(columns().total)}
|
||||
</text>
|
||||
<Show when={item.cacheBust !== undefined}>
|
||||
<text fg={themeV2.text.feedback.error.default}>
|
||||
! Cache bust: {item.cacheBust?.toLocaleString()} fewer cached tokens than the previous step
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
|
||||
const { themeV2 } = useTheme()
|
||||
const shortcut = Keymap.useShortcut("session.background")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client"
|
||||
import { createEffect, on, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { useConfig } from "../../config"
|
||||
import { useData } from "../../context/data"
|
||||
import { useClient } from "../../context/client"
|
||||
|
||||
@@ -27,18 +28,25 @@ export type SessionRow =
|
||||
completed: boolean
|
||||
}
|
||||
| { type: "assistant-footer"; messageID: string }
|
||||
| { type: "turn-usage"; messageIDs: string[]; previousCacheRead?: number }
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>) {
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
const config = useConfig()
|
||||
const [rows, setRows] = createStore<SessionRow[]>([])
|
||||
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
|
||||
const turnTokens = () => config.data.debug?.turn_tokens === true
|
||||
|
||||
function reduce() {
|
||||
const messages = data.session.message.list(sessionID())
|
||||
const inputs = new Set(data.session.input.list(sessionID()))
|
||||
const boundary = revertBoundary()
|
||||
const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs)
|
||||
const rows = reduceSessionRows(
|
||||
boundary ? messages.filter((message) => message.id < boundary) : messages,
|
||||
inputs,
|
||||
turnTokens(),
|
||||
)
|
||||
partitionPending(rows, pendingPermissions())
|
||||
const position = rows.findIndex((row) => row.type === "message" && inputs.has(row.messageID))
|
||||
rows.splice(
|
||||
@@ -127,6 +135,10 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(turnTokens, () => setRows(reconcile(reduce()))),
|
||||
)
|
||||
|
||||
const appendMessage = (messageID: string) =>
|
||||
setRows(
|
||||
produce((draft) => {
|
||||
@@ -246,9 +258,12 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
data.on("session.step.ended", (event) => {
|
||||
if (event.data.sessionID !== sessionID() || ["tool-calls", "unknown"].includes(event.data.finish)) return
|
||||
appendFooter(event.data.assistantMessageID)
|
||||
if (turnTokens()) setRows(reconcile(reduce()))
|
||||
}),
|
||||
data.on("session.step.failed", (event) => {
|
||||
if (event.data.sessionID === sessionID()) appendFooter(event.data.assistantMessageID)
|
||||
if (event.data.sessionID !== sessionID()) return
|
||||
appendFooter(event.data.assistantMessageID)
|
||||
if (turnTokens()) setRows(reconcile(reduce()))
|
||||
}),
|
||||
]
|
||||
onCleanup(() => subscriptions.forEach((unsubscribe) => unsubscribe()))
|
||||
@@ -256,10 +271,17 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
||||
return rows
|
||||
}
|
||||
|
||||
export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new Set<string>()) {
|
||||
export function reduceSessionRows(
|
||||
messages: SessionMessageInfo[],
|
||||
inputs = new Set<string>(),
|
||||
turnTokens = false,
|
||||
) {
|
||||
const isInput = (message: SessionMessageInfo) => inputs.has(message.id)
|
||||
const pendingCompactions = messages.filter((message) => message.type === "compaction" && message.status === "running")
|
||||
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
|
||||
const usage = turnTokens
|
||||
? { steps: [] as SessionMessageAssistant[], previousTurnCacheRead: undefined as number | undefined }
|
||||
: undefined
|
||||
return [
|
||||
...messages.filter((message) => !pending.has(message.id)),
|
||||
...pendingCompactions,
|
||||
@@ -271,20 +293,47 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
|
||||
rows.push({ type: "message", messageID: message.id })
|
||||
return rows
|
||||
}
|
||||
usage?.steps.push(message)
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
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
|
||||
append(rows, { messageID: message.id, partID }, part)
|
||||
})
|
||||
if ((message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error || message.retry) {
|
||||
const terminal = (message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error
|
||||
if (terminal || message.retry) {
|
||||
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({
|
||||
type: "turn-usage",
|
||||
messageIDs: stepsWithUsage.map((step) => step.id),
|
||||
...(usage.previousTurnCacheRead === undefined
|
||||
? {}
|
||||
: { previousCacheRead: usage.previousTurnCacheRead }),
|
||||
})
|
||||
usage.previousTurnCacheRead = last.tokens.cache.read
|
||||
}
|
||||
usage.steps.length = 0
|
||||
}
|
||||
return rows
|
||||
}, [])
|
||||
}
|
||||
|
||||
function hasTokenUsage(
|
||||
message: SessionMessageAssistant,
|
||||
): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } {
|
||||
return message.tokens !== undefined && tokenTotal(message.tokens) > 0
|
||||
}
|
||||
|
||||
function tokenTotal(tokens: NonNullable<SessionMessageAssistant["tokens"]>) {
|
||||
return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
|
||||
}
|
||||
|
||||
export function messageBoundaryIDs(rows: SessionRow[], messages: SessionMessageInfo[]) {
|
||||
const byID = new Map(messages.map((message) => [message.id, message]))
|
||||
const seen = new Set<string>()
|
||||
@@ -309,6 +358,8 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMess
|
||||
? row.refs[0]?.messageID
|
||||
: row.type === "assistant-footer"
|
||||
? row.messageID
|
||||
: row.type === "turn-usage"
|
||||
? row.messageIDs[0]
|
||||
: undefined
|
||||
if (!messageID) return undefined
|
||||
const message = messages.get(messageID)
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { Schema } from "effect"
|
||||
import { resolveThemeColors } from "./resolve"
|
||||
import { DEFAULT_THEMES, type Theme, type ThemeJson } from "./v1"
|
||||
import { DEFAULT_THEMES_V1, type Theme, type ThemeV1Json } from "./v1"
|
||||
import { DEFAULT_THEMES_V2 } from "./v2/defaults"
|
||||
import { resolveThemeDocument, themeDecodeError } from "./v2/resolve"
|
||||
import { ThemeDocument } from "./v2/schema"
|
||||
import { migrateV1 } from "./v2/v1-migrate"
|
||||
|
||||
export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeJson } from "./v1"
|
||||
export { DEFAULT_THEMES, DEFAULT_THEMES_V1, generateSyntax, selectedForeground, type Theme, type ThemeV1Json } from "./v1"
|
||||
export { DEFAULT_THEMES_V2 } from "./v2/defaults"
|
||||
export { resolveThemeDocument, type ThemeDocument }
|
||||
|
||||
const pluginThemes: Record<string, ThemeJson> = {}
|
||||
let customThemes: Record<string, ThemeJson> = {}
|
||||
let systemTheme: ThemeJson | undefined
|
||||
const listeners = new Set<(themes: Record<string, ThemeJson>) => void>()
|
||||
export type ThemeDocumentSource = Record<string, unknown>
|
||||
|
||||
const pluginThemes: Record<string, ThemeDocumentSource> = {}
|
||||
let customThemes: Record<string, ThemeDocumentSource> = {}
|
||||
let systemTheme: ThemeDocumentSource | undefined
|
||||
const listeners = new Set<(themes: Record<string, ThemeDocumentSource>) => void>()
|
||||
const parsed = new WeakMap<object, ThemeDocument>()
|
||||
const decodeThemeDocument = Schema.decodeUnknownSync(ThemeDocument)
|
||||
|
||||
function listThemes() {
|
||||
// Priority: defaults < plugin installs < custom files < generated system.
|
||||
const themes = {
|
||||
...DEFAULT_THEMES,
|
||||
const themes: Record<string, ThemeDocumentSource> = {
|
||||
...DEFAULT_THEMES_V1,
|
||||
...DEFAULT_THEMES_V2,
|
||||
...pluginThemes,
|
||||
...customThemes,
|
||||
}
|
||||
@@ -31,23 +43,40 @@ export function allThemes() {
|
||||
return listThemes()
|
||||
}
|
||||
|
||||
export function isTheme(theme: unknown): theme is ThemeJson {
|
||||
if (typeof theme !== "object" || theme === null || Array.isArray(theme)) return false
|
||||
const value = Reflect.get(theme, "theme")
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
export function isThemeSource(source: unknown): source is ThemeDocumentSource {
|
||||
if (typeof source !== "object" || source === null || Array.isArray(source)) return false
|
||||
return "theme" in source || "version" in source
|
||||
}
|
||||
|
||||
export function subscribeThemes(listener: (themes: Record<string, ThemeJson>) => void) {
|
||||
export function parseTheme(source: ThemeDocumentSource, name = "theme") {
|
||||
const cached = parsed.get(source)
|
||||
if (cached) return cached
|
||||
|
||||
const version = source.version ?? 1
|
||||
const document =
|
||||
version === 1
|
||||
? migrateV1(source as ThemeV1Json)
|
||||
: version === 2
|
||||
? decodeV2Theme(source, name)
|
||||
: unsupportedThemeVersion(version)
|
||||
|
||||
parsed.set(source, document)
|
||||
return document
|
||||
}
|
||||
|
||||
export function subscribeThemes(listener: (themes: Record<string, ThemeDocumentSource>) => void) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
export function setCustomThemes(themes: Record<string, ThemeJson>) {
|
||||
customThemes = themes
|
||||
export function setCustomThemes(themes: Record<string, unknown>) {
|
||||
customThemes = Object.fromEntries(
|
||||
Object.entries(themes).filter((entry): entry is [string, ThemeDocumentSource] => isThemeSource(entry[1])),
|
||||
)
|
||||
syncThemes()
|
||||
}
|
||||
|
||||
export function setSystemTheme(theme: ThemeJson | undefined) {
|
||||
export function setSystemTheme(theme: ThemeDocumentSource | undefined) {
|
||||
systemTheme = theme
|
||||
syncThemes()
|
||||
}
|
||||
@@ -59,7 +88,7 @@ export function hasTheme(name: string) {
|
||||
|
||||
export function addTheme(name: string, theme: unknown) {
|
||||
if (!name) return false
|
||||
if (!isTheme(theme)) return false
|
||||
if (!isThemeSource(theme)) return false
|
||||
if (hasTheme(name)) return false
|
||||
pluginThemes[name] = theme
|
||||
syncThemes()
|
||||
@@ -68,7 +97,7 @@ export function addTheme(name: string, theme: unknown) {
|
||||
|
||||
export function upsertTheme(name: string, theme: unknown) {
|
||||
if (!name) return false
|
||||
if (!isTheme(theme)) return false
|
||||
if (!isThemeSource(theme)) return false
|
||||
if (customThemes[name] !== undefined) {
|
||||
customThemes[name] = theme
|
||||
} else {
|
||||
@@ -78,7 +107,7 @@ export function upsertTheme(name: string, theme: unknown) {
|
||||
return true
|
||||
}
|
||||
|
||||
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme {
|
||||
export function resolveTheme(theme: ThemeV1Json, mode: "dark" | "light"): Theme {
|
||||
const resolved = resolveThemeColors(theme, mode)
|
||||
return {
|
||||
...resolved.theme,
|
||||
@@ -86,3 +115,15 @@ export function resolveTheme(theme: ThemeJson, mode: "dark" | "light"): Theme {
|
||||
thinkingOpacity: resolved.thinkingOpacity,
|
||||
}
|
||||
}
|
||||
|
||||
function decodeV2Theme(source: ThemeDocumentSource, name: string) {
|
||||
try {
|
||||
return decodeThemeDocument(source)
|
||||
} catch (error) {
|
||||
throw themeDecodeError(error, name)
|
||||
}
|
||||
}
|
||||
|
||||
function unsupportedThemeVersion(version: unknown): never {
|
||||
throw new Error(`Unsupported theme version: ${String(version)}`)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { ansiToRgba } from "./color"
|
||||
import type { ColorValue, Theme, ThemeColor, ThemeJson } from "./v1"
|
||||
import type { ColorValue, Theme, ThemeColor, ThemeV1Json } from "./v1"
|
||||
|
||||
export function resolveThemeColors(
|
||||
theme: ThemeJson,
|
||||
theme: ThemeV1Json,
|
||||
mode: "dark" | "light",
|
||||
resolveAnsi: (code: number) => RGBA = ansiToRgba,
|
||||
) {
|
||||
@@ -43,7 +43,9 @@ export function resolveThemeColors(
|
||||
? resolveColor(theme.theme.selectedListItemText!)
|
||||
: resolved.background!,
|
||||
backgroundMenu:
|
||||
theme.theme.backgroundMenu === undefined ? resolved.backgroundElement! : resolveColor(theme.theme.backgroundMenu),
|
||||
theme.theme.backgroundMenu === undefined
|
||||
? resolved.backgroundElement!
|
||||
: resolveColor(theme.theme.backgroundMenu),
|
||||
} satisfies Omit<Theme, "_hasSelectedListItemText" | "thinkingOpacity">,
|
||||
hasSelectedListItemText,
|
||||
thinkingOpacity: theme.theme.thinkingOpacity ?? 0.6,
|
||||
|
||||
@@ -98,7 +98,7 @@ export type Variant = {
|
||||
light: HexColor | RefName
|
||||
}
|
||||
export type ColorValue = HexColor | RefName | Variant | RGBA | number
|
||||
export type ThemeJson = {
|
||||
export type ThemeV1Json = {
|
||||
$schema?: string
|
||||
defs?: Record<string, HexColor | RefName>
|
||||
theme: Omit<Record<ThemeColor, ColorValue>, "selectedListItemText" | "backgroundMenu"> & {
|
||||
@@ -108,7 +108,7 @@ export type ThemeJson = {
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_THEMES: Record<string, ThemeJson> = {
|
||||
export const DEFAULT_THEMES_V1: Record<string, ThemeV1Json> = {
|
||||
aura,
|
||||
ayu,
|
||||
catppuccin,
|
||||
@@ -144,6 +144,8 @@ export const DEFAULT_THEMES: Record<string, ThemeJson> = {
|
||||
carbonfox,
|
||||
}
|
||||
|
||||
export const DEFAULT_THEMES = DEFAULT_THEMES_V1
|
||||
|
||||
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
|
||||
if (theme._hasSelectedListItemText) return theme.selectedListItemText
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HueName, ThemeFile } from "./schema"
|
||||
import type { HueName, ThemeDocument } from "./schema"
|
||||
|
||||
export const DEFAULT_CATEGORICAL = [
|
||||
"blue",
|
||||
@@ -437,4 +437,8 @@ export const DEFAULT_THEME = {
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies ThemeFile
|
||||
} satisfies ThemeDocument
|
||||
|
||||
export const DEFAULT_THEMES_V2 = {
|
||||
"opencode-v2": DEFAULT_THEME,
|
||||
} as const satisfies Record<string, ThemeDocument>
|
||||
|
||||
@@ -16,7 +16,7 @@ export {
|
||||
SyntaxDefinition,
|
||||
SyntaxToken,
|
||||
ThemeDefinition,
|
||||
ThemeFile,
|
||||
ThemeDocument,
|
||||
type BackgroundDefinition,
|
||||
type DiffDefinition,
|
||||
type FileThemeDefinition,
|
||||
@@ -43,6 +43,6 @@ export type {
|
||||
ResolvedThemeView,
|
||||
StatefulColor,
|
||||
} from "./types"
|
||||
export { DEFAULT_CATEGORICAL } from "./defaults"
|
||||
export { DEFAULT_CATEGORICAL, DEFAULT_THEME, DEFAULT_THEMES_V2 } from "./defaults"
|
||||
export { migrateV1 } from "./v1-migrate"
|
||||
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
HueAlias,
|
||||
HueStep,
|
||||
ThemeDefinition,
|
||||
ThemeFile,
|
||||
ThemeDocument,
|
||||
} from "./schema"
|
||||
import type {
|
||||
ActionStateKey,
|
||||
@@ -26,7 +26,6 @@ import type {
|
||||
import { selectTheme, selectThemeMode } from "./select"
|
||||
|
||||
const decodeThemeDefinitionSchema = Schema.decodeUnknownSync(ThemeDefinition)
|
||||
const decodeThemeFileSchema = Schema.decodeUnknownSync(ThemeFile)
|
||||
|
||||
function decodeThemeDefinition(input: unknown) {
|
||||
try {
|
||||
@@ -36,27 +35,18 @@ function decodeThemeDefinition(input: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
function decodeThemeFile(input: unknown, name: string) {
|
||||
try {
|
||||
return decodeThemeFileSchema(input)
|
||||
} catch (error) {
|
||||
throw themeDecodeError(error, name)
|
||||
}
|
||||
}
|
||||
|
||||
function themeDecodeError(error: unknown, name: string) {
|
||||
export function themeDecodeError(error: unknown, name: string) {
|
||||
const message = Schema.isSchemaError(error) ? error.message : String(error)
|
||||
const value = /got ("[^"]*"|\S+)/.exec(message)?.[1] ?? "value"
|
||||
return new Error(`Invalid theme: ${name} ${value} is an invalid value`, { cause: error })
|
||||
}
|
||||
|
||||
export function resolveThemeFile(file: ThemeFile, mode?: "light" | "dark", name = "theme") {
|
||||
const decoded = decodeThemeFile(file, name)
|
||||
const selected = selectThemeMode(decoded, mode)
|
||||
export function resolveThemeDocument(document: ThemeDocument, mode?: "light" | "dark") {
|
||||
const selected = selectThemeMode(document, mode)
|
||||
const definition = selected.expanded ? selected.theme : expandTheme(selected.theme)
|
||||
const defaults = expandTheme(selectTheme(DEFAULT_THEME, selected.mode))
|
||||
const core = expandTokens(fallback())
|
||||
const merged = decoded.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
|
||||
const merged = document.standalone ? mergeTheme(core, definition) : mergeTheme(core, defaults, definition)
|
||||
if (!merged["hue"]) throw new Error("Standalone themes must provide hues")
|
||||
return resolveExpandedTheme({
|
||||
...merged,
|
||||
|
||||
@@ -251,8 +251,8 @@ const FileMetadata = {
|
||||
version: Schema.Literal(2),
|
||||
standalone: Schema.optional(Schema.Boolean),
|
||||
}
|
||||
export const ThemeFile = Schema.Union([
|
||||
export const ThemeDocument = Schema.Union([
|
||||
Schema.Struct({ ...FileMetadata, light: ModeDefinition, dark: Schema.optional(ModeDefinition) }),
|
||||
Schema.Struct({ ...FileMetadata, light: Schema.optional(ModeDefinition), dark: ModeDefinition }),
|
||||
])
|
||||
export type ThemeFile = Schema.Schema.Type<typeof ThemeFile>
|
||||
export type ThemeDocument = Schema.Schema.Type<typeof ThemeDocument>
|
||||
|
||||
@@ -5,45 +5,45 @@ import type {
|
||||
Mode,
|
||||
ModeDefinition,
|
||||
ThemeDefinition,
|
||||
ThemeFile,
|
||||
ThemeDocument,
|
||||
} from "./index"
|
||||
|
||||
export function selectTheme(
|
||||
file: ThemeFile & { light: ThemeDefinition; dark: ThemeDefinition },
|
||||
document: ThemeDocument & { light: ThemeDefinition; dark: ThemeDefinition },
|
||||
mode?: Mode,
|
||||
): ThemeDefinition
|
||||
export function selectTheme(file: ThemeFile, mode?: Mode): FileThemeDefinition
|
||||
export function selectTheme(file: ThemeFile, mode?: Mode) {
|
||||
return selectThemeMode(file, mode).theme
|
||||
export function selectTheme(document: ThemeDocument, mode?: Mode): FileThemeDefinition
|
||||
export function selectTheme(document: ThemeDocument, mode?: Mode) {
|
||||
return selectThemeMode(document, mode).theme
|
||||
}
|
||||
|
||||
export function selectThemeMode(
|
||||
file: ThemeFile,
|
||||
document: ThemeDocument,
|
||||
mode: Mode = "light",
|
||||
): { theme: FileThemeDefinition; mode: Mode; expanded: boolean } {
|
||||
const modes = themeModes(file)
|
||||
const modes = themeModes(document)
|
||||
const selectedMode = modes.includes(mode) ? mode : modes[0]
|
||||
const selected = file[selectedMode]
|
||||
const selected = document[selectedMode]
|
||||
if (!selected) throw new Error("Theme must provide at least one mode")
|
||||
if (merges(file.light) && merges(file.dark)) throw new Error("Light and dark themes cannot both merge modes")
|
||||
if (merges(document.light) && merges(document.dark)) throw new Error("Light and dark themes cannot both merge modes")
|
||||
if (!merges(selected)) return { theme: selected, mode: selectedMode, expanded: false }
|
||||
|
||||
const otherMode = selectedMode === "light" ? "dark" : "light"
|
||||
const other = file[otherMode]
|
||||
const other = document[otherMode]
|
||||
if (!other) throw new Error(`The ${selectedMode} theme cannot merge without a ${otherMode} theme`)
|
||||
const merged = mergeTheme(expandTheme(other), expandTheme(selected))
|
||||
if (!merged["hue"]) throw new Error(`The ${otherMode} theme must provide hues when ${selectedMode} merges modes`)
|
||||
return { theme: merged as FileThemeDefinition, mode: selectedMode, expanded: true }
|
||||
}
|
||||
|
||||
export function themeModes(file: ThemeFile): readonly Mode[] {
|
||||
if (merges(file.light) && !file.dark) throw new Error("The light theme cannot merge without a dark theme")
|
||||
if (merges(file.dark) && !file.light) throw new Error("The dark theme cannot merge without a light theme")
|
||||
return (["light", "dark"] as const).filter((mode) => file[mode] !== undefined)
|
||||
export function themeModes(document: ThemeDocument): readonly Mode[] {
|
||||
if (merges(document.light) && !document.dark) throw new Error("The light theme cannot merge without a dark theme")
|
||||
if (merges(document.dark) && !document.light) throw new Error("The dark theme cannot merge without a light theme")
|
||||
return (["light", "dark"] as const).filter((mode) => document[mode] !== undefined)
|
||||
}
|
||||
|
||||
export function supportsThemeMode(file: ThemeFile, mode: Mode) {
|
||||
return themeModes(file).includes(mode)
|
||||
export function supportsThemeMode(document: ThemeDocument, mode: Mode) {
|
||||
return themeModes(document).includes(mode)
|
||||
}
|
||||
|
||||
function merges(definition: ModeDefinition | undefined): definition is MergeModeDefinition {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { SyntaxStyle, type RGBA, type ThemeTokenStyle } from "@opentui/core"
|
||||
import type { Mode, ResolvedThemeView } from "./index"
|
||||
|
||||
export function generateSyntax(theme: ResolvedThemeView, mode: Mode) {
|
||||
const step = mode === "light" ? 800 : 200
|
||||
const syntax = theme.syntax
|
||||
const markdown = theme.markdown
|
||||
const feedback = theme.text.feedback
|
||||
|
||||
return SyntaxStyle.fromTheme([
|
||||
rule(["default"], theme.text.default),
|
||||
rule(["prompt"], theme.hue.accent[step]),
|
||||
rule(["extmark.file"], feedback.warning.default, { bold: true }),
|
||||
rule(["extmark.agent"], theme.categorical[0][step], { bold: true }),
|
||||
// V1 migration preserves its selected/inverse foreground in this action state.
|
||||
rule(["extmark.paste"], theme.text.action.primary.focused, {
|
||||
background: feedback.warning.default,
|
||||
bold: true,
|
||||
}),
|
||||
rule(["comment", "comment.documentation"], syntax.comment, { italic: true }),
|
||||
rule(["string", "symbol", "character.special", "character"], syntax.string),
|
||||
rule(["number", "boolean", "constant", "float"], syntax.number),
|
||||
rule(["keyword.return", "keyword.conditional", "keyword.repeat", "keyword.coroutine"], syntax.keyword, {
|
||||
italic: true,
|
||||
}),
|
||||
rule(["keyword.type"], syntax.type, { bold: true, italic: true }),
|
||||
rule(["keyword.function", "function.method"], syntax.function),
|
||||
rule(["keyword"], syntax.keyword, { italic: true }),
|
||||
rule(["keyword.import", "string.escape", "string.regexp", "tag.attribute", "keyword.export"], syntax.keyword),
|
||||
rule(["operator", "keyword.operator", "punctuation.delimiter", "keyword.conditional.ternary"], syntax.operator),
|
||||
rule(
|
||||
["variable", "variable.parameter", "function.method.call", "function.call", "property", "parameter", "field"],
|
||||
syntax.variable,
|
||||
),
|
||||
rule(["variable.member", "function", "constructor"], syntax.function),
|
||||
rule(["type", "module", "class", "namespace"], syntax.type),
|
||||
rule(["type.definition"], syntax.type, { bold: true }),
|
||||
rule(["punctuation", "punctuation.bracket"], syntax.punctuation),
|
||||
rule(
|
||||
["variable.builtin", "type.builtin", "function.builtin", "module.builtin", "constant.builtin", "variable.super"],
|
||||
feedback.error.default,
|
||||
),
|
||||
rule(["keyword.directive", "keyword.modifier", "keyword.exception"], syntax.keyword, { italic: true }),
|
||||
rule(["punctuation.special", "tag.delimiter"], syntax.operator),
|
||||
rule(
|
||||
[
|
||||
"markup.heading",
|
||||
"markup.heading.2",
|
||||
"markup.heading.3",
|
||||
"markup.heading.4",
|
||||
"markup.heading.5",
|
||||
"markup.heading.6",
|
||||
],
|
||||
markdown.heading,
|
||||
{ bold: true },
|
||||
),
|
||||
rule(["markup.heading.1"], markdown.heading, { bold: true, underline: true }),
|
||||
rule(["markup.bold", "markup.strong"], markdown.strong, { bold: true }),
|
||||
rule(["markup.italic"], markdown.emphasis, { italic: true }),
|
||||
rule(["markup.list"], markdown.listItem),
|
||||
rule(["markup.quote"], markdown.blockQuote, { italic: true }),
|
||||
rule(["markup.raw", "markup.raw.block"], markdown.code),
|
||||
rule(["markup.raw.inline"], markdown.code, { background: theme.background.default }),
|
||||
rule(["markup.link", "markup.link.url", "string.special", "string.special.url"], markdown.link, {
|
||||
underline: true,
|
||||
}),
|
||||
rule(["markup.link.label"], markdown.linkText, { underline: true }),
|
||||
rule(["label"], markdown.linkText),
|
||||
rule(["spell", "nospell"], theme.text.default),
|
||||
rule(["markup.underline"], theme.text.default, { underline: true }),
|
||||
rule(["comment.error"], feedback.error.default, { italic: true, bold: true }),
|
||||
rule(["comment.warning"], feedback.warning.default, { italic: true, bold: true }),
|
||||
rule(["comment.todo", "comment.note"], feedback.info.default, { italic: true, bold: true }),
|
||||
rule(["attribute", "annotation"], feedback.warning.default),
|
||||
rule(["tag"], feedback.error.default),
|
||||
rule(["markup.strikethrough", "markup.list.unchecked", "debug"], theme.text.subdued),
|
||||
rule(["markup.list.checked"], feedback.success.default),
|
||||
rule(["diff.plus"], theme.diff.text.added, { background: theme.diff.background.added }),
|
||||
rule(["diff.minus"], theme.diff.text.removed, { background: theme.diff.background.removed }),
|
||||
rule(["diff.delta"], theme.diff.text.context, { background: theme.diff.background.context }),
|
||||
rule(["error"], feedback.error.default, { bold: true }),
|
||||
rule(["warning"], feedback.warning.default, { bold: true }),
|
||||
rule(["info"], feedback.info.default),
|
||||
])
|
||||
}
|
||||
|
||||
function rule(
|
||||
scope: string[],
|
||||
foreground: RGBA,
|
||||
style: Omit<ThemeTokenStyle["style"], "foreground"> = {},
|
||||
): ThemeTokenStyle {
|
||||
return { scope, style: { foreground, ...style } }
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { oklchToHex, rgbToOklch } from "@opencode-ai/ui/theme/color"
|
||||
import type { Theme, ThemeJson } from "../index"
|
||||
import type { Theme, ThemeV1Json } from "../v1"
|
||||
import { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults"
|
||||
import type { FileThemeDefinition, Mode, ThemeFile } from "./index"
|
||||
import type { FileThemeDefinition, Mode, ThemeDocument } from "./index"
|
||||
import { HueStep } from "./schema"
|
||||
|
||||
type ThemeColor = Exclude<keyof Theme, "thinkingOpacity" | "_hasSelectedListItemText">
|
||||
@@ -14,7 +14,7 @@ const categoricalTokens: readonly V1HueToken[] = ["secondary", "accent", "succes
|
||||
const minimumChroma = 0.03
|
||||
const lightThreshold = 0.6
|
||||
|
||||
export function migrateV1(theme: ThemeJson): ThemeFile {
|
||||
export function migrateV1(theme: ThemeV1Json): ThemeDocument {
|
||||
const light = resolveV1(theme, "light")
|
||||
const dark = resolveV1(theme, "dark")
|
||||
if (light.background.a > 0 && dark.background.a > 0 && light.background.equals(dark.background)) {
|
||||
@@ -234,7 +234,7 @@ function ambiguous(color: RGBA, chroma = toOklch(color).c) {
|
||||
return color.toInts()[3] === 0 || chroma < minimumChroma
|
||||
}
|
||||
|
||||
function resolveV1(theme: ThemeJson, mode: "dark" | "light"): Theme {
|
||||
function resolveV1(theme: ThemeV1Json, mode: "dark" | "light"): Theme {
|
||||
const defs = theme.defs ?? {}
|
||||
|
||||
function resolveColor(value: unknown, chain: string[] = []): RGBA {
|
||||
|
||||
@@ -5,12 +5,14 @@ import type { OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { createEffect, onMount, type ParentProps } from "solid-js"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ClientProvider, useClient } from "../../../src/context/client"
|
||||
import { DataProvider as DataProviderBase, useData } from "../../../src/context/data"
|
||||
import { LocationProvider, useLocation } from "../../../src/context/location"
|
||||
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||
import { createApi, createEventStream, createFetch, directory, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
const formFields = [{ key: "authorization", type: "external", url: "https://example.com" }] satisfies [
|
||||
{
|
||||
@@ -32,14 +34,18 @@ function emitEvent(events: ReturnType<typeof createEventStream>, event: OpenCode
|
||||
events.emit({ ...event, location: { directory } })
|
||||
}
|
||||
|
||||
const config = createTuiResolvedConfig()
|
||||
|
||||
function DataProvider(props: ParentProps) {
|
||||
return (
|
||||
<DataProviderBase>
|
||||
<LocationProvider>
|
||||
<SyncLocation />
|
||||
{props.children}
|
||||
</LocationProvider>
|
||||
</DataProviderBase>
|
||||
<ConfigProvider config={config}>
|
||||
<DataProviderBase>
|
||||
<LocationProvider>
|
||||
<SyncLocation />
|
||||
{props.children}
|
||||
</LocationProvider>
|
||||
</DataProviderBase>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
import { DEFAULT_THEMES } from "../../../src/theme"
|
||||
import { DEFAULT_THEME } from "../../../src/theme/v2/defaults"
|
||||
import { selectTheme } from "../../../src/theme/v2/select"
|
||||
import { ConfigProvider } from "../../../src/config"
|
||||
import { ThemeProvider, useTheme } from "../../../src/context/theme"
|
||||
import { ThemeProvider, useTheme, type ThemeError } from "../../../src/context/theme"
|
||||
|
||||
async function wait(fn: () => boolean) {
|
||||
const started = Date.now()
|
||||
@@ -24,6 +27,7 @@ test("uses an available mode while retaining the pinned preference", async () =>
|
||||
const darkOnly = structuredClone(DEFAULT_THEMES.opencode)
|
||||
darkOnly.theme.background = "#111111"
|
||||
darkOnly.theme.text = "#eeeeee"
|
||||
const native = { version: 2, dark: { text: { default: "#abcdef" } } } as const
|
||||
let theme: ReturnType<typeof useTheme> | undefined
|
||||
|
||||
function Probe() {
|
||||
@@ -42,7 +46,7 @@ test("uses an available mode while retaining the pinned preference", async () =>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "light-only", mode: "dark" } })}>
|
||||
<ThemeProvider
|
||||
mode="dark"
|
||||
source={{ discover: () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual }) }}
|
||||
source={{ discover: () => Promise.resolve({ "light-only": lightOnly, "dark-only": darkOnly, dual, native }) }}
|
||||
>
|
||||
<Probe />
|
||||
</ThemeProvider>
|
||||
@@ -66,6 +70,85 @@ test("uses an available mode while retaining the pinned preference", async () =>
|
||||
expect(current().set("dual")).toBeTrue()
|
||||
await wait(() => current().mode() === "dark")
|
||||
expect(current().modes()).toEqual(["light", "dark"])
|
||||
expect(current().set("native")).toBeTrue()
|
||||
await wait(() => current().selected === "native")
|
||||
expect(current().modes()).toEqual(["dark"])
|
||||
expect(current().themeV2.text.default.equals(RGBA.fromHex("#abcdef"))).toBeTrue()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
["schema", { version: 2, light: { categorical: [] } }],
|
||||
["mode merging", { version: 2, light: { mergeMode: true } }],
|
||||
["token reference", { version: 2, light: { text: { default: "$missing" } } }],
|
||||
] as const)("falls back to OpenCode when configured V2 theme %s is invalid", async (_label, source) => {
|
||||
let theme: ReturnType<typeof useTheme> | undefined
|
||||
let failure: ThemeError | undefined
|
||||
let unsubscribe: (() => void) | undefined
|
||||
|
||||
function Probe() {
|
||||
const value = useTheme()
|
||||
theme = value
|
||||
unsubscribe = value.onError((error) => (failure = error))
|
||||
return <text>{value.selected}</text>
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "invalid" } })}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({ invalid: source }) }}>
|
||||
<Probe />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
{ width: 20, height: 2 },
|
||||
)
|
||||
app.renderer.start()
|
||||
|
||||
try {
|
||||
await wait(() => theme?.ready === true)
|
||||
expect(theme?.selected).toBe("opencode")
|
||||
expect(failure?.name).toBe("invalid")
|
||||
expect(failure?.error).toBeInstanceOf(Error)
|
||||
expect(failure?.error.message.length).toBeGreaterThan(0)
|
||||
} finally {
|
||||
unsubscribe?.()
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("contextual themes fall back to a standalone theme's base view", async () => {
|
||||
const standalone = {
|
||||
version: 2,
|
||||
standalone: true,
|
||||
dark: { hue: selectTheme(DEFAULT_THEME, "dark").hue },
|
||||
} as const
|
||||
let theme: ReturnType<typeof useTheme> | undefined
|
||||
|
||||
function Probe() {
|
||||
theme = useTheme()
|
||||
return <text>{theme.selected}</text>
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<ConfigProvider config={createTuiResolvedConfig({ theme: { name: "standalone", mode: "dark" } })}>
|
||||
<ThemeProvider mode="dark" source={{ discover: () => Promise.resolve({ standalone }) }}>
|
||||
<Probe />
|
||||
</ThemeProvider>
|
||||
</ConfigProvider>
|
||||
),
|
||||
{ width: 20, height: 2 },
|
||||
)
|
||||
app.renderer.start()
|
||||
|
||||
try {
|
||||
await wait(() => theme?.ready === true)
|
||||
if (!theme) throw new Error("Theme provider is not mounted")
|
||||
expect(theme.contextual("elevated").themeV2.text.default).toBe(theme.themeV2.text.default)
|
||||
expect(theme.contextual("overlay").themeV2.background.default).toBe(theme.themeV2.background.default)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user