Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d43db8772 | ||
|
|
fd2c3f3f13 | ||
|
|
b396b71c6f | ||
|
|
d8efc575fa | ||
|
|
e1fbed8fb6 | ||
|
|
12ae22378f | ||
|
|
a88b436eec | ||
|
|
dbe36851bc | ||
|
|
ff9d7cab5c | ||
|
|
88681d389b | ||
|
|
ae2ecd1ed3 | ||
|
|
159d271e1e | ||
|
|
762850dfe5 |
@@ -421,6 +421,7 @@
|
|||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@openauthjs/openauth": "catalog:",
|
"@openauthjs/openauth": "catalog:",
|
||||||
|
"@opencode-ai/llm": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -489,6 +490,7 @@
|
|||||||
"@babel/core": "7.28.4",
|
"@babel/core": "7.28.4",
|
||||||
"@octokit/webhooks-types": "7.6.1",
|
"@octokit/webhooks-types": "7.6.1",
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
|
"@opencode-ai/http-recorder": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-qAkjcbc1nJqOnCrNQ0bnsM4WG2ii5K1JWS9ohAYdjus=",
|
"x86_64-linux": "sha256-FI1mX42vJuYdUDdWevlfHz+OcYkDn/I/HUbHE/jdQvs=",
|
||||||
"aarch64-linux": "sha256-Nb+F0e3CvQv+uLzHzj9JKp5hV78mCnlSqFXzgIvgR24=",
|
"aarch64-linux": "sha256-3CQzzKnh/4Zf5vyn56yR5P3ULsW7K7Fr8/RQpekEJDk=",
|
||||||
"aarch64-darwin": "sha256-BIXALWWrjEZLUKZrY6l6+scjZmKFscFxX26TvWOvXGQ=",
|
"aarch64-darwin": "sha256-XPDVHMxlPpXlf43BRqNnwF809unk6iE8tvd0o92d0/w=",
|
||||||
"x86_64-darwin": "sha256-3uaFXl/n6je7AzIfsY1pvt3Ln/U1Oshx3z7ohuVPEs8="
|
"x86_64-darwin": "sha256-dFXTi13RSgL62lMsep1EoE/KSEPF7Oh31PVdxW1tkzg="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export const TextDelta = Schema.Struct({
|
|||||||
type: Schema.tag("text-delta"),
|
type: Schema.tag("text-delta"),
|
||||||
id: ContentBlockID,
|
id: ContentBlockID,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
}).annotate({ identifier: "LLM.Event.TextDelta" })
|
||||||
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
export type TextDelta = Schema.Schema.Type<typeof TextDelta>
|
||||||
|
|
||||||
@@ -112,6 +113,7 @@ export const ReasoningDelta = Schema.Struct({
|
|||||||
type: Schema.tag("reasoning-delta"),
|
type: Schema.tag("reasoning-delta"),
|
||||||
id: ContentBlockID,
|
id: ContentBlockID,
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
|
providerMetadata: Schema.optional(ProviderMetadata),
|
||||||
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
|
}).annotate({ identifier: "LLM.Event.ReasoningDelta" })
|
||||||
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
export type ReasoningDelta = Schema.Schema.Type<typeof ReasoningDelta>
|
||||||
|
|
||||||
|
|||||||
@@ -39,8 +39,9 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.28.4",
|
"@babel/core": "7.28.4",
|
||||||
"@octokit/webhooks-types": "7.6.1",
|
"@octokit/webhooks-types": "7.6.1",
|
||||||
"@opencode-ai/script": "workspace:*",
|
|
||||||
"@opencode-ai/core": "workspace:*",
|
"@opencode-ai/core": "workspace:*",
|
||||||
|
"@opencode-ai/http-recorder": "workspace:*",
|
||||||
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||||
@@ -101,6 +102,7 @@
|
|||||||
"@octokit/graphql": "9.0.2",
|
"@octokit/graphql": "9.0.2",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
"@openauthjs/openauth": "catalog:",
|
"@openauthjs/openauth": "catalog:",
|
||||||
|
"@opencode-ai/llm": "workspace:*",
|
||||||
"@opencode-ai/plugin": "workspace:*",
|
"@opencode-ai/plugin": "workspace:*",
|
||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { createMemo, createResource } from "solid-js"
|
import { createMemo, createResource } from "solid-js"
|
||||||
import { DialogSelect } from "@tui/ui/dialog-select"
|
import { DialogSelect } from "@tui/ui/dialog-select"
|
||||||
import { useDialog } from "@tui/ui/dialog"
|
import { useDialog } from "@tui/ui/dialog"
|
||||||
|
import { useProject } from "@tui/context/project"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
|
|
||||||
export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
const project = useProject()
|
||||||
|
|
||||||
const [store] = createStore({
|
const [store] = createStore({
|
||||||
filter: "",
|
filter: "",
|
||||||
@@ -17,6 +19,7 @@ export function DialogTag(props: { onSelect?: (value: string) => void }) {
|
|||||||
async () => {
|
async () => {
|
||||||
const result = await sdk.client.find.files({
|
const result = await sdk.client.find.files({
|
||||||
query: store.filter,
|
query: store.filter,
|
||||||
|
workspace: project.workspace.current(),
|
||||||
})
|
})
|
||||||
if (result.error) return []
|
if (result.error) return []
|
||||||
const sliced = (result.data ?? []).slice(0, 5)
|
const sliced = (result.data ?? []).slice(0, 5)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { firstBy } from "remeda"
|
|||||||
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
|
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useEditorContext } from "@tui/context/editor"
|
import { useEditorContext } from "@tui/context/editor"
|
||||||
|
import { useProject } from "@tui/context/project"
|
||||||
import { useSDK } from "@tui/context/sdk"
|
import { useSDK } from "@tui/context/sdk"
|
||||||
import { useSync } from "@tui/context/sync"
|
import { useSync } from "@tui/context/sync"
|
||||||
import { getScrollAcceleration } from "../../util/scroll"
|
import { getScrollAcceleration } from "../../util/scroll"
|
||||||
@@ -85,6 +86,7 @@ export function Autocomplete(props: {
|
|||||||
const editor = useEditorContext()
|
const editor = useEditorContext()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const project = useProject()
|
||||||
const command = useCommandPalette()
|
const command = useCommandPalette()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
@@ -382,6 +384,7 @@ export function Autocomplete(props: {
|
|||||||
// Get files from SDK
|
// Get files from SDK
|
||||||
const result = await sdk.client.find.files({
|
const result = await sdk.client.find.files({
|
||||||
query: baseQuery,
|
query: baseQuery,
|
||||||
|
workspace: project.workspace.current(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const options: AutocompleteOption[] = []
|
const options: AutocompleteOption[] = []
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export class Service extends ConfigService.Service<Service>()("@opencode/Runtime
|
|||||||
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
experimentalIconDiscovery: enabledByExperimental("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY"),
|
||||||
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
outputTokenMax: positiveInteger("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX"),
|
||||||
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
bashDefaultTimeoutMs: positiveInteger("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"),
|
||||||
|
experimentalNativeLlm: enabledByExperimental("OPENCODE_EXPERIMENTAL_NATIVE_LLM"),
|
||||||
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
|
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
|
||||||
}) {}
|
}) {}
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,11 @@ interface TokenResponse {
|
|||||||
expires_in?: number
|
expires_in?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CodexAuthPluginOptions {
|
||||||
|
issuer?: string
|
||||||
|
codexApiEndpoint?: string
|
||||||
|
}
|
||||||
|
|
||||||
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
|
async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: PkceCodes): Promise<TokenResponse> {
|
||||||
const response = await fetch(`${ISSUER}/oauth/token`, {
|
const response = await fetch(`${ISSUER}/oauth/token`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -136,8 +141,8 @@ async function exchangeCodeForTokens(code: string, redirectUri: string, pkce: Pk
|
|||||||
return response.json()
|
return response.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAccessToken(refreshToken: string): Promise<TokenResponse> {
|
async function refreshAccessToken(refreshToken: string, issuer = ISSUER): Promise<TokenResponse> {
|
||||||
const response = await fetch(`${ISSUER}/oauth/token`, {
|
const response = await fetch(`${issuer}/oauth/token`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
@@ -364,7 +369,10 @@ function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise<TokenResp
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
export async function CodexAuthPlugin(input: PluginInput, options: CodexAuthPluginOptions = {}): Promise<Hooks> {
|
||||||
|
const issuer = options.issuer ?? ISSUER
|
||||||
|
const codexApiEndpoint = options.codexApiEndpoint ?? CODEX_API_ENDPOINT
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: {
|
provider: {
|
||||||
id: "openai",
|
id: "openai",
|
||||||
@@ -405,6 +413,13 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
const auth = await getAuth()
|
const auth = await getAuth()
|
||||||
if (auth.type !== "oauth") return {}
|
if (auth.type !== "oauth") return {}
|
||||||
|
|
||||||
|
let refreshPromise:
|
||||||
|
| Promise<{
|
||||||
|
access: string
|
||||||
|
accountId: string | undefined
|
||||||
|
}>
|
||||||
|
| undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
apiKey: OAUTH_DUMMY_KEY,
|
apiKey: OAUTH_DUMMY_KEY,
|
||||||
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
async fetch(requestInput: RequestInfo | URL, init?: RequestInit) {
|
||||||
@@ -429,9 +444,11 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
|
|
||||||
// Check if token needs refresh
|
// Check if token needs refresh
|
||||||
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
if (!currentAuth.access || currentAuth.expires < Date.now()) {
|
||||||
|
if (!refreshPromise) {
|
||||||
log.info("refreshing codex access token")
|
log.info("refreshing codex access token")
|
||||||
const tokens = await refreshAccessToken(currentAuth.refresh)
|
refreshPromise = refreshAccessToken(currentAuth.refresh, issuer)
|
||||||
const newAccountId = extractAccountId(tokens) || authWithAccount.accountId
|
.then(async (tokens) => {
|
||||||
|
const accountId = extractAccountId(tokens) || authWithAccount.accountId
|
||||||
await input.client.auth.set({
|
await input.client.auth.set({
|
||||||
path: { id: "openai" },
|
path: { id: "openai" },
|
||||||
body: {
|
body: {
|
||||||
@@ -439,11 +456,22 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
refresh: tokens.refresh_token,
|
refresh: tokens.refresh_token,
|
||||||
access: tokens.access_token,
|
access: tokens.access_token,
|
||||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||||
...(newAccountId && { accountId: newAccountId }),
|
...(accountId && { accountId }),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
currentAuth.access = tokens.access_token
|
return {
|
||||||
authWithAccount.accountId = newAccountId
|
access: tokens.access_token,
|
||||||
|
accountId,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
refreshPromise = undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshed = await refreshPromise
|
||||||
|
currentAuth.access = refreshed.access
|
||||||
|
authWithAccount.accountId = refreshed.accountId
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build headers
|
// Build headers
|
||||||
@@ -477,7 +505,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
: new URL(typeof requestInput === "string" ? requestInput : requestInput.url)
|
: new URL(typeof requestInput === "string" ? requestInput : requestInput.url)
|
||||||
const url =
|
const url =
|
||||||
parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions")
|
parsed.pathname.includes("/v1/responses") || parsed.pathname.includes("/chat/completions")
|
||||||
? new URL(CODEX_API_ENDPOINT)
|
? new URL(codexApiEndpoint)
|
||||||
: parsed
|
: parsed
|
||||||
|
|
||||||
return fetch(url, {
|
return fetch(url, {
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { Provider } from "@/provider/provider"
|
|||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { Context, Effect, Layer, Record } from "effect"
|
import { Context, Effect, Layer, Record } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai"
|
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool as aiTool, jsonSchema } from "ai"
|
||||||
|
import type { LLMEvent } from "@opencode-ai/llm"
|
||||||
|
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||||
|
import type { LLMClientService } from "@opencode-ai/llm/route"
|
||||||
import { mergeDeep } from "remeda"
|
import { mergeDeep } from "remeda"
|
||||||
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
||||||
import { ProviderTransform } from "@/provider/transform"
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
@@ -23,10 +26,11 @@ import { EffectBridge } from "@/effect/bridge"
|
|||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import * as Option from "effect/Option"
|
import * as Option from "effect/Option"
|
||||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||||
|
import { LLMAISDK } from "./llm/ai-sdk"
|
||||||
|
import { LLMNativeRuntime } from "./llm/native-runtime"
|
||||||
|
|
||||||
const log = Log.create({ service: "llm" })
|
const log = Log.create({ service: "llm" })
|
||||||
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
|
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
|
||||||
type Result = Awaited<ReturnType<typeof streamText>>
|
|
||||||
|
|
||||||
// Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep.
|
// Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep.
|
||||||
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
|
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
|
||||||
@@ -51,10 +55,8 @@ export type StreamRequest = StreamInput & {
|
|||||||
abort: AbortSignal
|
abort: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Event = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly stream: (input: StreamInput) => Stream.Stream<Event, unknown>
|
readonly stream: (input: StreamInput) => Stream.Stream<LLMEvent, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/LLM") {}
|
||||||
@@ -62,7 +64,13 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/LL
|
|||||||
const live: Layer.Layer<
|
const live: Layer.Layer<
|
||||||
Service,
|
Service,
|
||||||
never,
|
never,
|
||||||
Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service | RuntimeFlags.Service
|
| Auth.Service
|
||||||
|
| Config.Service
|
||||||
|
| Provider.Service
|
||||||
|
| Plugin.Service
|
||||||
|
| Permission.Service
|
||||||
|
| LLMClientService
|
||||||
|
| RuntimeFlags.Service
|
||||||
> = Layer.effect(
|
> = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -71,6 +79,7 @@ const live: Layer.Layer<
|
|||||||
const provider = yield* Provider.Service
|
const provider = yield* Provider.Service
|
||||||
const plugin = yield* Plugin.Service
|
const plugin = yield* Plugin.Service
|
||||||
const perm = yield* Permission.Service
|
const perm = yield* Permission.Service
|
||||||
|
const llmClient = yield* LLMClient.Service
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
|
|
||||||
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
|
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
|
||||||
@@ -202,7 +211,7 @@ const live: Layer.Layer<
|
|||||||
Object.keys(tools).length === 0 &&
|
Object.keys(tools).length === 0 &&
|
||||||
hasToolCalls(input.messages)
|
hasToolCalls(input.messages)
|
||||||
) {
|
) {
|
||||||
tools["_noop"] = tool({
|
tools["_noop"] = aiTool({
|
||||||
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
|
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
|
||||||
inputSchema: jsonSchema({
|
inputSchema: jsonSchema({
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -322,7 +331,77 @@ const live: Layer.Layer<
|
|||||||
? (yield* InstanceState.context).project.id
|
? (yield* InstanceState.context).project.id
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
return streamText({
|
const requestHeaders = {
|
||||||
|
...(input.model.providerID.startsWith("opencode")
|
||||||
|
? {
|
||||||
|
...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}),
|
||||||
|
"x-opencode-session": input.sessionID,
|
||||||
|
"x-opencode-request": input.user.id,
|
||||||
|
"x-opencode-client": flags.client,
|
||||||
|
"User-Agent": `opencode/${InstallationVersion}`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
"x-session-affinity": input.sessionID,
|
||||||
|
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
|
||||||
|
"User-Agent": `opencode/${InstallationVersion}`,
|
||||||
|
}),
|
||||||
|
...input.model.headers,
|
||||||
|
...headers,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flags.experimentalNativeLlm) {
|
||||||
|
const native = LLMNativeRuntime.stream({
|
||||||
|
model: input.model,
|
||||||
|
provider: item,
|
||||||
|
auth: info,
|
||||||
|
llmClient,
|
||||||
|
isOpenaiOauth,
|
||||||
|
system,
|
||||||
|
messages,
|
||||||
|
tools: sortedTools,
|
||||||
|
toolChoice: input.toolChoice,
|
||||||
|
temperature: params.temperature,
|
||||||
|
topP: params.topP,
|
||||||
|
topK: params.topK,
|
||||||
|
maxOutputTokens: params.maxOutputTokens,
|
||||||
|
providerOptions: params.options,
|
||||||
|
headers: requestHeaders,
|
||||||
|
abort: input.abort,
|
||||||
|
})
|
||||||
|
if (native.type === "supported") {
|
||||||
|
yield* Effect.logInfo("llm runtime selected").pipe(
|
||||||
|
Effect.annotateLogs({
|
||||||
|
"llm.runtime": "native",
|
||||||
|
"llm.provider": input.model.providerID,
|
||||||
|
"llm.model": input.model.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
type: "native" as const,
|
||||||
|
stream: native.stream,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield* Effect.logInfo("llm runtime selected").pipe(
|
||||||
|
Effect.annotateLogs({
|
||||||
|
"llm.runtime": "ai-sdk",
|
||||||
|
"llm.provider": input.model.providerID,
|
||||||
|
"llm.model": input.model.id,
|
||||||
|
"llm.native_unsupported_reason": native.reason,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
l.info("native runtime unavailable; falling back to ai-sdk", { reason: native.reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* Effect.logInfo("llm runtime selected").pipe(
|
||||||
|
Effect.annotateLogs({
|
||||||
|
"llm.runtime": "ai-sdk",
|
||||||
|
"llm.provider": input.model.providerID,
|
||||||
|
"llm.model": input.model.id,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
type: "ai-sdk" as const,
|
||||||
|
result: streamText({
|
||||||
onError(error) {
|
onError(error) {
|
||||||
l.error("stream error", {
|
l.error("stream error", {
|
||||||
error,
|
error,
|
||||||
@@ -358,23 +437,7 @@ const live: Layer.Layer<
|
|||||||
toolChoice: input.toolChoice,
|
toolChoice: input.toolChoice,
|
||||||
maxOutputTokens: params.maxOutputTokens,
|
maxOutputTokens: params.maxOutputTokens,
|
||||||
abortSignal: input.abort,
|
abortSignal: input.abort,
|
||||||
headers: {
|
headers: requestHeaders,
|
||||||
...(input.model.providerID.startsWith("opencode")
|
|
||||||
? {
|
|
||||||
"x-opencode-project": opencodeProjectID,
|
|
||||||
"x-opencode-session": input.sessionID,
|
|
||||||
"x-opencode-request": input.user.id,
|
|
||||||
"x-opencode-client": flags.client,
|
|
||||||
"User-Agent": `opencode/${InstallationVersion}`,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
"x-session-affinity": input.sessionID,
|
|
||||||
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
|
|
||||||
"User-Agent": `opencode/${InstallationVersion}`,
|
|
||||||
}),
|
|
||||||
...input.model.headers,
|
|
||||||
...headers,
|
|
||||||
},
|
|
||||||
maxRetries: input.retries ?? 0,
|
maxRetries: input.retries ?? 0,
|
||||||
messages,
|
messages,
|
||||||
model: wrapLanguageModel({
|
model: wrapLanguageModel({
|
||||||
@@ -401,7 +464,8 @@ const live: Layer.Layer<
|
|||||||
sessionId: input.sessionID,
|
sessionId: input.sessionID,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const stream: Interface["stream"] = (input) =>
|
const stream: Interface["stream"] = (input) =>
|
||||||
@@ -415,7 +479,15 @@ const live: Layer.Layer<
|
|||||||
|
|
||||||
const result = yield* run({ ...input, abort: ctrl.signal })
|
const result = yield* run({ ...input, abort: ctrl.signal })
|
||||||
|
|
||||||
return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e))))
|
if (result.type === "native") return result.stream
|
||||||
|
|
||||||
|
const state = LLMAISDK.adapterState()
|
||||||
|
return Stream.fromAsyncIterable(result.result.fullStream, (e) =>
|
||||||
|
e instanceof Error ? e : new Error(String(e)),
|
||||||
|
).pipe(
|
||||||
|
Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)),
|
||||||
|
Stream.flatMap((events) => Stream.fromIterable(events)),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -432,6 +504,7 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Provider.defaultLayer),
|
||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
|
Layer.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer))),
|
||||||
Layer.provide(RuntimeFlags.defaultLayer),
|
Layer.provide(RuntimeFlags.defaultLayer),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Session LLM Runtime Boundaries
|
||||||
|
|
||||||
|
`../llm.ts` is the opencode session LLM service. It owns opencode concerns: auth, config, model/provider resolution, plugins, permissions, telemetry headers, and runtime selection.
|
||||||
|
|
||||||
|
This folder contains adapters behind that service boundary:
|
||||||
|
|
||||||
|
- `ai-sdk.ts` converts AI SDK `fullStream` parts into `@opencode-ai/llm` `LLMEvent`s. This is the default runtime path.
|
||||||
|
- `native-request.ts` converts opencode's normalized session input into a native `@opencode-ai/llm` `LLMRequest`. It does not execute requests.
|
||||||
|
- `native-runtime.ts` is the opt-in native runtime adapter. It decides whether a selected model is supported, builds the native request, bridges opencode tools into native executable tools, and delegates transport to `LLMClient` / `RequestExecutor`.
|
||||||
|
|
||||||
|
## Runtime selection
|
||||||
|
|
||||||
|
Both runtimes converge on the same `LLMEvent` stream consumed by the session processor. The gate is per-request: a single session can route some calls through native and fall back for others.
|
||||||
|
|
||||||
|
```txt
|
||||||
|
╭───────────────────╮
|
||||||
|
╭───────────────────────────▶│ session processor │
|
||||||
|
│ ╰─────────┬─────────╯
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ╭─────────────────────────╮
|
||||||
|
│ │ LLM.Service (../llm.ts) │
|
||||||
|
│ ╰────────────┬────────────╯
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ ▼
|
||||||
|
│ ╭───────────╮
|
||||||
|
│ ╭─╯ ╰─╮
|
||||||
|
│ │ native gate │
|
||||||
|
│ ╰─╮ ╭─╯
|
||||||
|
│ ╰─────┬─────╯
|
||||||
|
│ │
|
||||||
|
│ ╭────── no ──────┴─────── yes ────────╮
|
||||||
|
│ │ │
|
||||||
|
│ ▼ ▼
|
||||||
|
│ ╭───────────────────────────╮ ╭───────────────────╮
|
||||||
|
│ │ AI SDK │ │ native-runtime.ts │
|
||||||
|
│ │ streamText / generateText │ ╰────────┬──────────╯
|
||||||
|
│ ╰─────────────┬─────────────╯ │
|
||||||
|
│ │ │
|
||||||
|
│ ╭───╯ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ ▼
|
||||||
|
│ ╭───────────────────────╮ ╭────────────────────────────╮
|
||||||
|
│ │ ai-sdk.ts │ │ native-request.ts │
|
||||||
|
│ │ fullStream → LLMEvent │ │ session input → LLMRequest │
|
||||||
|
│ ╰──────────┬────────────╯ ╰──────────────┬─────────────╯
|
||||||
|
│ │ │
|
||||||
|
│ │ ╭───╯
|
||||||
|
│ │ │
|
||||||
|
│ ▼ ▼
|
||||||
|
│ ╭─────────────────╮ ╭─────────────────────────────╮
|
||||||
|
╰───────┤ LLMEvent stream │◀────────────┤ LLMClient · RequestExecutor │
|
||||||
|
╰─────────────────╯ ╰─────────────────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
`native-runtime.ts` evaluates the gate and either bridges into `@opencode-ai/llm` or returns control so `llm.ts` can take the AI SDK path. Tool execution stays opencode-owned in both branches; only request lowering and transport differ.
|
||||||
|
|
||||||
|
Safety boundary:
|
||||||
|
|
||||||
|
- AI SDK remains the default.
|
||||||
|
- `OPENCODE_EXPERIMENTAL_NATIVE_LLM=true` or the umbrella `OPENCODE_EXPERIMENTAL=true` opts in. Native is not a global replacement.
|
||||||
|
- Native execution currently runs only for OpenAI-compatible Responses models exposed through `@ai-sdk/openai`: direct `openai` API-key auth and console-managed `opencode`/Zen API-key config.
|
||||||
|
- Unsupported providers, OpenAI OAuth, and missing API-key cases fall back to AI SDK.
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import { FinishReason, LLMEvent, ProviderMetadata, ToolResultValue } from "@opencode-ai/llm"
|
||||||
|
import { Effect, Schema } from "effect"
|
||||||
|
import { type streamText } from "ai"
|
||||||
|
import { errorMessage } from "@/util/error"
|
||||||
|
|
||||||
|
type Result = Awaited<ReturnType<typeof streamText>>
|
||||||
|
type AISDKEvent = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
|
||||||
|
|
||||||
|
export function adapterState() {
|
||||||
|
return {
|
||||||
|
step: 0,
|
||||||
|
text: 0,
|
||||||
|
reasoning: 0,
|
||||||
|
currentTextID: undefined as string | undefined,
|
||||||
|
currentReasoningID: undefined as string | undefined,
|
||||||
|
toolNames: {} as Record<string, string>,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishReason(value: string | undefined): FinishReason {
|
||||||
|
return Schema.is(FinishReason)(value) ? value : "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerMetadata(value: unknown): ProviderMetadata | undefined {
|
||||||
|
if (value == null) return undefined
|
||||||
|
return Schema.is(ProviderMetadata)(value) ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage(value: unknown) {
|
||||||
|
if (!value || typeof value !== "object") return undefined
|
||||||
|
const item = value as {
|
||||||
|
inputTokens?: number
|
||||||
|
outputTokens?: number
|
||||||
|
totalTokens?: number
|
||||||
|
reasoningTokens?: number
|
||||||
|
cachedInputTokens?: number
|
||||||
|
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
|
||||||
|
outputTokenDetails?: { reasoningTokens?: number }
|
||||||
|
}
|
||||||
|
const entries = Object.entries({
|
||||||
|
inputTokens: item.inputTokens,
|
||||||
|
outputTokens: item.outputTokens,
|
||||||
|
totalTokens: item.totalTokens,
|
||||||
|
reasoningTokens: item.outputTokenDetails?.reasoningTokens ?? item.reasoningTokens,
|
||||||
|
cacheReadInputTokens: item.inputTokenDetails?.cacheReadTokens ?? item.cachedInputTokens,
|
||||||
|
cacheWriteInputTokens: item.inputTokenDetails?.cacheWriteTokens,
|
||||||
|
}).filter((entry) => entry[1] !== undefined)
|
||||||
|
return entries.length === 0 ? undefined : Object.fromEntries(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentTextID(state: ReturnType<typeof adapterState>, id: string | undefined) {
|
||||||
|
state.currentTextID = id ?? state.currentTextID ?? `text-${state.text++}`
|
||||||
|
return state.currentTextID
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentReasoningID(state: ReturnType<typeof adapterState>, id: string | undefined) {
|
||||||
|
state.currentReasoningID = id ?? state.currentReasoningID ?? `reasoning-${state.reasoning++}`
|
||||||
|
return state.currentReasoningID
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toLLMEvents(
|
||||||
|
state: ReturnType<typeof adapterState>,
|
||||||
|
event: AISDKEvent,
|
||||||
|
): Effect.Effect<ReadonlyArray<LLMEvent>, unknown> {
|
||||||
|
switch (event.type) {
|
||||||
|
case "start":
|
||||||
|
return Effect.succeed([])
|
||||||
|
|
||||||
|
case "start-step":
|
||||||
|
return Effect.succeed([LLMEvent.stepStart({ index: state.step })])
|
||||||
|
|
||||||
|
case "finish-step":
|
||||||
|
return Effect.sync(() => [
|
||||||
|
LLMEvent.stepFinish({
|
||||||
|
index: state.step++,
|
||||||
|
reason: finishReason(event.finishReason),
|
||||||
|
usage: usage(event.usage),
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
case "finish":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
const events = [
|
||||||
|
LLMEvent.finish({
|
||||||
|
reason: finishReason(event.finishReason),
|
||||||
|
usage: usage(event.totalUsage),
|
||||||
|
providerMetadata: "providerMetadata" in event ? providerMetadata(event.providerMetadata) : undefined,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
// Reset so the adapter can be reused for a follow-up stream without leaking
|
||||||
|
// counters or block IDs. adapterState() is the single source of truth for shape.
|
||||||
|
Object.assign(state, adapterState())
|
||||||
|
return events
|
||||||
|
})
|
||||||
|
|
||||||
|
case "text-start":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
state.currentTextID = currentTextID(state, event.id)
|
||||||
|
return [
|
||||||
|
LLMEvent.textStart({
|
||||||
|
id: state.currentTextID,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "text-delta":
|
||||||
|
return Effect.succeed([
|
||||||
|
LLMEvent.textDelta({
|
||||||
|
id: currentTextID(state, event.id),
|
||||||
|
text: event.text,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
case "text-end":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
const id = currentTextID(state, event.id)
|
||||||
|
state.currentTextID = undefined
|
||||||
|
return [
|
||||||
|
LLMEvent.textEnd({
|
||||||
|
id,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "reasoning-start":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
state.currentReasoningID = currentReasoningID(state, event.id)
|
||||||
|
return [
|
||||||
|
LLMEvent.reasoningStart({
|
||||||
|
id: state.currentReasoningID,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "reasoning-delta":
|
||||||
|
return Effect.succeed([
|
||||||
|
LLMEvent.reasoningDelta({
|
||||||
|
id: currentReasoningID(state, event.id),
|
||||||
|
text: event.text,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
case "reasoning-end":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
const id = currentReasoningID(state, event.id)
|
||||||
|
state.currentReasoningID = undefined
|
||||||
|
return [
|
||||||
|
LLMEvent.reasoningEnd({
|
||||||
|
id,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "tool-input-start":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
state.toolNames[event.id] = event.toolName
|
||||||
|
return [
|
||||||
|
LLMEvent.toolInputStart({
|
||||||
|
id: event.id,
|
||||||
|
name: event.toolName,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "tool-input-delta":
|
||||||
|
return Effect.succeed([
|
||||||
|
LLMEvent.toolInputDelta({
|
||||||
|
id: event.id,
|
||||||
|
name: state.toolNames[event.id] ?? "unknown",
|
||||||
|
text: event.delta ?? "",
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
case "tool-input-end":
|
||||||
|
return Effect.succeed([
|
||||||
|
LLMEvent.toolInputEnd({
|
||||||
|
id: event.id,
|
||||||
|
name: state.toolNames[event.id] ?? "unknown",
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
case "tool-call":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
state.toolNames[event.toolCallId] = event.toolName
|
||||||
|
return [
|
||||||
|
LLMEvent.toolCall({
|
||||||
|
id: event.toolCallId,
|
||||||
|
name: event.toolName,
|
||||||
|
input: event.input,
|
||||||
|
providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "tool-result":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
const name = state.toolNames[event.toolCallId] ?? "unknown"
|
||||||
|
delete state.toolNames[event.toolCallId]
|
||||||
|
return [
|
||||||
|
LLMEvent.toolResult({
|
||||||
|
id: event.toolCallId,
|
||||||
|
name,
|
||||||
|
result: ToolResultValue.make(event.output),
|
||||||
|
providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "tool-error":
|
||||||
|
return Effect.sync(() => {
|
||||||
|
const name = state.toolNames[event.toolCallId] ?? ("toolName" in event ? event.toolName : "unknown")
|
||||||
|
delete state.toolNames[event.toolCallId]
|
||||||
|
return [
|
||||||
|
LLMEvent.toolError({
|
||||||
|
id: event.toolCallId,
|
||||||
|
name,
|
||||||
|
message: errorMessage(event.error),
|
||||||
|
error: event.error,
|
||||||
|
providerMetadata: providerMetadata(event.providerMetadata),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
case "error":
|
||||||
|
return Effect.fail(event.error)
|
||||||
|
|
||||||
|
case "abort":
|
||||||
|
case "source":
|
||||||
|
case "file":
|
||||||
|
case "raw":
|
||||||
|
case "tool-output-denied":
|
||||||
|
case "tool-approval-request":
|
||||||
|
return Effect.succeed([])
|
||||||
|
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = event
|
||||||
|
void _exhaustive
|
||||||
|
return Effect.succeed([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as LLMAISDK from "./ai-sdk"
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import type { JsonSchema, LLMRequest, ProviderMetadata } from "@opencode-ai/llm"
|
||||||
|
import { LLM, Message, SystemPart, ToolCallPart, ToolDefinition, ToolResultPart } from "@opencode-ai/llm"
|
||||||
|
import "@opencode-ai/llm/providers"
|
||||||
|
import type { ModelMessage } from "ai"
|
||||||
|
import type { Provider } from "@/provider/provider"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
|
|
||||||
|
type ToolInput = {
|
||||||
|
readonly description?: string
|
||||||
|
readonly inputSchema?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestInput = {
|
||||||
|
readonly model: Provider.Model
|
||||||
|
readonly apiKey?: string
|
||||||
|
readonly baseURL?: string
|
||||||
|
readonly system?: readonly string[]
|
||||||
|
readonly messages: readonly ModelMessage[]
|
||||||
|
readonly tools?: Record<string, ToolInput>
|
||||||
|
readonly toolChoice?: "auto" | "required" | "none"
|
||||||
|
readonly temperature?: number
|
||||||
|
readonly topP?: number
|
||||||
|
readonly topK?: number
|
||||||
|
readonly maxOutputTokens?: number
|
||||||
|
readonly providerOptions?: LLMRequest["providerOptions"]
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL: Record<string, string> = {
|
||||||
|
"@ai-sdk/openai": "https://api.openai.com/v1",
|
||||||
|
"@ai-sdk/anthropic": "https://api.anthropic.com/v1",
|
||||||
|
"@ai-sdk/google": "https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
"@ai-sdk/amazon-bedrock": "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||||
|
"@openrouter/ai-sdk-provider": "https://openrouter.ai/api/v1",
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTE: Record<string, string> = {
|
||||||
|
"@ai-sdk/openai": "openai-responses",
|
||||||
|
"@ai-sdk/azure": "azure-openai-responses",
|
||||||
|
"@ai-sdk/anthropic": "anthropic-messages",
|
||||||
|
"@ai-sdk/google": "gemini",
|
||||||
|
"@ai-sdk/amazon-bedrock": "bedrock-converse",
|
||||||
|
"@ai-sdk/openai-compatible": "openai-compatible-chat",
|
||||||
|
"@openrouter/ai-sdk-provider": "openrouter",
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerMetadata = (value: unknown): ProviderMetadata | undefined => {
|
||||||
|
if (!isRecord(value)) return undefined
|
||||||
|
const result = Object.fromEntries(
|
||||||
|
Object.entries(value).filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1])),
|
||||||
|
)
|
||||||
|
return Object.keys(result).length === 0 ? undefined : result
|
||||||
|
}
|
||||||
|
|
||||||
|
const textPart = (part: Record<string, unknown>) => ({
|
||||||
|
type: "text" as const,
|
||||||
|
text: typeof part.text === "string" ? part.text : "",
|
||||||
|
providerMetadata: providerMetadata(part.providerOptions),
|
||||||
|
})
|
||||||
|
|
||||||
|
const mediaPart = (part: Record<string, unknown>) => {
|
||||||
|
if (typeof part.data !== "string" && !(part.data instanceof Uint8Array))
|
||||||
|
throw new Error("Native LLM request adapter only supports file parts with string or Uint8Array data")
|
||||||
|
return {
|
||||||
|
type: "media" as const,
|
||||||
|
mediaType: typeof part.mediaType === "string" ? part.mediaType : "application/octet-stream",
|
||||||
|
data: part.data,
|
||||||
|
filename: typeof part.filename === "string" ? part.filename : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolResult = (part: Record<string, unknown>) => {
|
||||||
|
const output = isRecord(part.output) ? part.output : { type: "json", value: part.output }
|
||||||
|
const type = output.type === "text" ? "text" : output.type === "error-text" ? "error" : "json"
|
||||||
|
return ToolResultPart.make({
|
||||||
|
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
|
||||||
|
name: typeof part.toolName === "string" ? part.toolName : "",
|
||||||
|
result: "value" in output ? output.value : output,
|
||||||
|
resultType: type,
|
||||||
|
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
|
||||||
|
providerMetadata: providerMetadata(part.providerOptions),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentPart = (part: unknown) => {
|
||||||
|
if (!isRecord(part)) throw new Error("Native LLM request adapter only supports object content parts")
|
||||||
|
if (part.type === "text") return textPart(part)
|
||||||
|
if (part.type === "file") return mediaPart(part)
|
||||||
|
if (part.type === "reasoning")
|
||||||
|
return {
|
||||||
|
type: "reasoning" as const,
|
||||||
|
text: typeof part.text === "string" ? part.text : "",
|
||||||
|
providerMetadata: providerMetadata(part.providerOptions),
|
||||||
|
}
|
||||||
|
if (part.type === "tool-call")
|
||||||
|
return ToolCallPart.make({
|
||||||
|
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
|
||||||
|
name: typeof part.toolName === "string" ? part.toolName : "",
|
||||||
|
input: part.input,
|
||||||
|
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
|
||||||
|
providerMetadata: providerMetadata(part.providerOptions),
|
||||||
|
})
|
||||||
|
if (part.type === "tool-result") return toolResult(part)
|
||||||
|
throw new Error(`Native LLM request adapter does not support ${String(part.type)} content parts`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (value: ModelMessage["content"]) =>
|
||||||
|
typeof value === "string" ? [{ type: "text" as const, text: value }] : value.map(contentPart)
|
||||||
|
|
||||||
|
const messages = (input: readonly ModelMessage[]) => {
|
||||||
|
const system = input.flatMap((message) => (message.role === "system" ? [SystemPart.make(message.content)] : []))
|
||||||
|
const messages = input.flatMap((message) => {
|
||||||
|
if (message.role === "system") return []
|
||||||
|
return [
|
||||||
|
Message.make({
|
||||||
|
role: message.role,
|
||||||
|
content: content(message.content),
|
||||||
|
native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return { system, messages }
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = (value: unknown): JsonSchema => {
|
||||||
|
if (!isRecord(value)) return { type: "object", properties: {} }
|
||||||
|
if (isRecord(value.jsonSchema)) return value.jsonSchema
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = (input: Record<string, ToolInput> | undefined): ToolDefinition[] =>
|
||||||
|
Object.entries(input ?? {}).map(([name, item]) =>
|
||||||
|
ToolDefinition.make({
|
||||||
|
name,
|
||||||
|
description: item.description ?? "",
|
||||||
|
inputSchema: schema(item.inputSchema),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const generation = (input: RequestInput) => {
|
||||||
|
const result = {
|
||||||
|
temperature: input.temperature,
|
||||||
|
topP: input.topP,
|
||||||
|
topK: input.topK,
|
||||||
|
maxTokens: input.maxOutputTokens,
|
||||||
|
}
|
||||||
|
return Object.values(result).some((value) => value !== undefined) ? result : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseURL = (model: Provider.Model) => {
|
||||||
|
if (model.api.url) return model.api.url
|
||||||
|
const fallback = DEFAULT_BASE_URL[model.api.npm]
|
||||||
|
if (fallback) return fallback
|
||||||
|
throw new Error(`Native LLM request adapter requires a base URL for ${model.providerID}/${model.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model = (input: Provider.Model | RequestInput, headers?: Record<string, string>) => {
|
||||||
|
const model = "model" in input ? input.model : input
|
||||||
|
const route = ROUTE[model.api.npm]
|
||||||
|
if (!route) throw new Error(`Native LLM request adapter does not support provider package ${model.api.npm}`)
|
||||||
|
return LLM.model({
|
||||||
|
id: model.api.id,
|
||||||
|
provider: model.providerID,
|
||||||
|
route,
|
||||||
|
baseURL: "model" in input && input.baseURL ? input.baseURL : baseURL(model),
|
||||||
|
apiKey: "model" in input ? input.apiKey : undefined,
|
||||||
|
headers: Object.keys({ ...model.headers, ...headers }).length === 0 ? undefined : { ...model.headers, ...headers },
|
||||||
|
limits: {
|
||||||
|
context: model.limit.context,
|
||||||
|
output: model.limit.output,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const request = (input: RequestInput) => {
|
||||||
|
const converted = messages(input.messages)
|
||||||
|
return LLM.request({
|
||||||
|
model: model(input, input.headers),
|
||||||
|
system: [...(input.system ?? []).map(SystemPart.make), ...converted.system],
|
||||||
|
messages: converted.messages,
|
||||||
|
tools: tools(input.tools),
|
||||||
|
toolChoice: input.toolChoice,
|
||||||
|
generation: generation(input),
|
||||||
|
providerOptions: input.providerOptions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as LLMNative from "./native-request"
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import type { Auth } from "@/auth"
|
||||||
|
import type { Provider } from "@/provider/provider"
|
||||||
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
|
import { errorMessage } from "@/util/error"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
|
import { asSchema, type ModelMessage, type Tool } from "ai"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import * as Stream from "effect/Stream"
|
||||||
|
import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
|
||||||
|
import type { LLMClientShape } from "@opencode-ai/llm/route"
|
||||||
|
import { LLMNative } from "./native-request"
|
||||||
|
|
||||||
|
export type RuntimeStatus =
|
||||||
|
| { readonly type: "supported"; readonly apiKey: string; readonly baseURL?: string }
|
||||||
|
| { readonly type: "unsupported"; readonly reason: string }
|
||||||
|
export type StreamResult =
|
||||||
|
| { readonly type: "supported"; readonly stream: Stream.Stream<LLMEvent, unknown> }
|
||||||
|
| { readonly type: "unsupported"; readonly reason: string }
|
||||||
|
|
||||||
|
type StreamInput = {
|
||||||
|
readonly model: Provider.Model
|
||||||
|
readonly provider: Provider.Info
|
||||||
|
readonly auth: Auth.Info | undefined
|
||||||
|
readonly llmClient: LLMClientShape
|
||||||
|
readonly isOpenaiOauth: boolean
|
||||||
|
readonly system: string[]
|
||||||
|
readonly messages: ModelMessage[]
|
||||||
|
readonly tools: Record<string, Tool>
|
||||||
|
readonly toolChoice?: "auto" | "required" | "none"
|
||||||
|
readonly temperature?: number
|
||||||
|
readonly topP?: number
|
||||||
|
readonly topK?: number
|
||||||
|
readonly maxOutputTokens?: number
|
||||||
|
readonly providerOptions?: Record<string, any>
|
||||||
|
readonly headers: Record<string, string>
|
||||||
|
readonly abort: AbortSignal
|
||||||
|
}
|
||||||
|
|
||||||
|
export function status(input: Pick<StreamInput, "model" | "provider" | "auth">): RuntimeStatus {
|
||||||
|
if (input.model.providerID !== "openai" && !input.model.providerID.startsWith("opencode"))
|
||||||
|
return { type: "unsupported", reason: "provider is not openai or opencode" }
|
||||||
|
if (input.model.api.npm !== "@ai-sdk/openai") return { type: "unsupported", reason: "provider package is not OpenAI" }
|
||||||
|
if (input.auth?.type === "oauth") return { type: "unsupported", reason: "OAuth auth is not supported" }
|
||||||
|
|
||||||
|
const apiKey =
|
||||||
|
input.auth?.type === "api"
|
||||||
|
? input.auth.key
|
||||||
|
: typeof input.provider.options.apiKey === "string"
|
||||||
|
? input.provider.options.apiKey
|
||||||
|
: undefined
|
||||||
|
if (!apiKey) return { type: "unsupported", reason: "OpenAI API key is not configured" }
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "supported",
|
||||||
|
apiKey,
|
||||||
|
baseURL: typeof input.provider.options.baseURL === "string" ? input.provider.options.baseURL : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stream(input: StreamInput): StreamResult {
|
||||||
|
const current = status(input)
|
||||||
|
if (current.type === "unsupported") return current
|
||||||
|
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
stream: input.llmClient.stream({
|
||||||
|
request: LLMNative.request({
|
||||||
|
model: input.model,
|
||||||
|
apiKey: current.apiKey,
|
||||||
|
baseURL: current.baseURL,
|
||||||
|
system: input.isOpenaiOauth ? input.system : [],
|
||||||
|
messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}),
|
||||||
|
toolChoice: input.toolChoice,
|
||||||
|
temperature: input.temperature,
|
||||||
|
topP: input.topP,
|
||||||
|
topK: input.topK,
|
||||||
|
maxOutputTokens: input.maxOutputTokens,
|
||||||
|
providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}),
|
||||||
|
headers: { ...providerHeaders(input.provider.options.headers), ...input.headers },
|
||||||
|
}),
|
||||||
|
tools: nativeTools(input.tools, input),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerHeaders(value: unknown): Record<string, string> | undefined {
|
||||||
|
if (!isRecord(value)) return undefined
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeSchema(value: unknown): JsonSchema {
|
||||||
|
if (!value || typeof value !== "object") return { type: "object", properties: {} }
|
||||||
|
if ("jsonSchema" in value && value.jsonSchema && typeof value.jsonSchema === "object")
|
||||||
|
return value.jsonSchema as JsonSchema
|
||||||
|
return asSchema(value as Parameters<typeof asSchema>[0]).jsonSchema as JsonSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nativeTools(tools: Record<string, Tool>, input: Pick<StreamInput, "messages" | "abort">) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(tools).map(([name, item]) => [
|
||||||
|
name,
|
||||||
|
nativeTool({
|
||||||
|
description: item.description ?? "",
|
||||||
|
jsonSchema: nativeSchema(item.inputSchema),
|
||||||
|
execute: (args: unknown, ctx) =>
|
||||||
|
Effect.tryPromise({
|
||||||
|
try: () => {
|
||||||
|
if (!item.execute) throw new Error(`Tool has no execute handler: ${name}`)
|
||||||
|
return item.execute(args, {
|
||||||
|
toolCallId: ctx?.id ?? name,
|
||||||
|
messages: input.messages,
|
||||||
|
abortSignal: input.abort,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
catch: (error) => new ToolFailure({ message: errorMessage(error), error }),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as LLMNativeRuntime from "./native-runtime"
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect"
|
import { Image } from "@/image/image"
|
||||||
|
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
@@ -9,7 +10,6 @@ import { Snapshot } from "@/snapshot"
|
|||||||
import * as Session from "./session"
|
import * as Session from "./session"
|
||||||
import { LLM } from "./llm"
|
import { LLM } from "./llm"
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
import { Image } from "@/image/image"
|
|
||||||
import { isOverflow } from "./overflow"
|
import { isOverflow } from "./overflow"
|
||||||
import { PartID } from "./schema"
|
import { PartID } from "./schema"
|
||||||
import type { SessionID } from "./schema"
|
import type { SessionID } from "./schema"
|
||||||
@@ -28,14 +28,13 @@ import { ModelV2 } from "@opencode-ai/core/model"
|
|||||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||||
import * as DateTime from "effect/DateTime"
|
import * as DateTime from "effect/DateTime"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
import { Usage, type LLMEvent } from "@opencode-ai/llm"
|
||||||
|
|
||||||
const DOOM_LOOP_THRESHOLD = 3
|
const DOOM_LOOP_THRESHOLD = 3
|
||||||
const log = Log.create({ service: "session.processor" })
|
const log = Log.create({ service: "session.processor" })
|
||||||
|
|
||||||
export type Result = "compact" | "stop" | "continue"
|
export type Result = "compact" | "stop" | "continue"
|
||||||
|
|
||||||
export type Event = LLM.Event
|
|
||||||
|
|
||||||
export interface Handle {
|
export interface Handle {
|
||||||
readonly message: MessageV2.Assistant
|
readonly message: MessageV2.Assistant
|
||||||
readonly updateToolCall: (
|
readonly updateToolCall: (
|
||||||
@@ -69,6 +68,7 @@ type ToolCall = {
|
|||||||
messageID: MessageV2.ToolPart["messageID"]
|
messageID: MessageV2.ToolPart["messageID"]
|
||||||
sessionID: MessageV2.ToolPart["sessionID"]
|
sessionID: MessageV2.ToolPart["sessionID"]
|
||||||
done: Deferred.Deferred<void>
|
done: Deferred.Deferred<void>
|
||||||
|
inputEnded: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessorContext extends Input {
|
interface ProcessorContext extends Input {
|
||||||
@@ -81,7 +81,7 @@ interface ProcessorContext extends Input {
|
|||||||
reasoningMap: Record<string, MessageV2.ReasoningPart>
|
reasoningMap: Record<string, MessageV2.ReasoningPart>
|
||||||
}
|
}
|
||||||
|
|
||||||
type StreamEvent = Event
|
type StreamEvent = LLMEvent
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionProcessor") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionProcessor") {}
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
|
const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) {
|
||||||
const call = ctx.toolcalls[toolCallID]
|
const call = ctx.toolcalls[toolCallID]
|
||||||
if (!call) return
|
if (!call) return undefined
|
||||||
const part = yield* session.getPart({
|
const part = yield* session.getPart({
|
||||||
partID: call.partID,
|
partID: call.partID,
|
||||||
messageID: call.messageID,
|
messageID: call.messageID,
|
||||||
@@ -145,7 +145,7 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
if (!part || part.type !== "tool") {
|
if (!part || part.type !== "tool") {
|
||||||
delete ctx.toolcalls[toolCallID]
|
delete ctx.toolcalls[toolCallID]
|
||||||
return
|
return undefined
|
||||||
}
|
}
|
||||||
return { call, part }
|
return { call, part }
|
||||||
})
|
})
|
||||||
@@ -155,7 +155,7 @@ export const layer = Layer.effect(
|
|||||||
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
|
update: (part: MessageV2.ToolPart) => MessageV2.ToolPart,
|
||||||
) {
|
) {
|
||||||
const match = yield* readToolCall(toolCallID)
|
const match = yield* readToolCall(toolCallID)
|
||||||
if (!match) return
|
if (!match) return undefined
|
||||||
const part = yield* session.updatePart(update(match.part))
|
const part = yield* session.updatePart(update(match.part))
|
||||||
ctx.toolcalls[toolCallID] = {
|
ctx.toolcalls[toolCallID] = {
|
||||||
...match.call,
|
...match.call,
|
||||||
@@ -211,12 +211,98 @@ export const layer = Layer.effect(
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) {
|
||||||
|
if (!(reasoningID in ctx.reasoningMap)) return
|
||||||
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
|
if (flags.experimentalEventSystem) {
|
||||||
|
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
||||||
|
sessionID: ctx.sessionID,
|
||||||
|
reasoningID,
|
||||||
|
text: ctx.reasoningMap[reasoningID].text,
|
||||||
|
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// oxlint-disable-next-line no-self-assign -- reactivity trigger
|
||||||
|
ctx.reasoningMap[reasoningID].text = ctx.reasoningMap[reasoningID].text
|
||||||
|
ctx.reasoningMap[reasoningID].time = { ...ctx.reasoningMap[reasoningID].time, end: Date.now() }
|
||||||
|
yield* session.updatePart(ctx.reasoningMap[reasoningID])
|
||||||
|
delete ctx.reasoningMap[reasoningID]
|
||||||
|
})
|
||||||
|
|
||||||
|
const ensureToolCall = Effect.fn("SessionProcessor.ensureToolCall")(function* (input: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
providerExecuted?: boolean
|
||||||
|
}) {
|
||||||
|
const existing = yield* readToolCall(input.id)
|
||||||
|
if (existing) {
|
||||||
|
if (!input.providerExecuted || existing.part.metadata?.providerExecuted) return existing
|
||||||
|
const part = yield* session.updatePart({
|
||||||
|
...existing.part,
|
||||||
|
metadata: { ...existing.part.metadata, providerExecuted: true },
|
||||||
|
})
|
||||||
|
ctx.toolcalls[input.id] = {
|
||||||
|
...existing.call,
|
||||||
|
partID: part.id,
|
||||||
|
messageID: part.messageID,
|
||||||
|
sessionID: part.sessionID,
|
||||||
|
}
|
||||||
|
return { call: ctx.toolcalls[input.id], part }
|
||||||
|
}
|
||||||
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
|
if (flags.experimentalEventSystem) {
|
||||||
|
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
||||||
|
sessionID: ctx.sessionID,
|
||||||
|
callID: input.id,
|
||||||
|
name: input.name,
|
||||||
|
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const part = yield* session.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: ctx.assistantMessage.id,
|
||||||
|
sessionID: ctx.assistantMessage.sessionID,
|
||||||
|
type: "tool",
|
||||||
|
tool: input.name,
|
||||||
|
callID: input.id,
|
||||||
|
state: { status: "pending", input: {}, raw: "" },
|
||||||
|
metadata: input.providerExecuted ? { providerExecuted: true } : undefined,
|
||||||
|
} satisfies MessageV2.ToolPart)
|
||||||
|
ctx.toolcalls[input.id] = {
|
||||||
|
done: yield* Deferred.make<void>(),
|
||||||
|
partID: part.id,
|
||||||
|
messageID: part.messageID,
|
||||||
|
sessionID: part.sessionID,
|
||||||
|
inputEnded: false,
|
||||||
|
}
|
||||||
|
return { call: ctx.toolcalls[input.id], part }
|
||||||
|
})
|
||||||
|
|
||||||
|
const isFilePart = Schema.is(MessageV2.FilePart)
|
||||||
|
|
||||||
|
const toolResultOutput = (value: Extract<StreamEvent, { type: "tool-result" }>) => {
|
||||||
|
if (isRecord(value.result.value) && typeof value.result.value.output === "string") {
|
||||||
|
return {
|
||||||
|
title: typeof value.result.value.title === "string" ? value.result.value.title : value.name,
|
||||||
|
metadata: isRecord(value.result.value.metadata) ? value.result.value.metadata : {},
|
||||||
|
output: value.result.value.output,
|
||||||
|
attachments: Array.isArray(value.result.value.attachments)
|
||||||
|
? value.result.value.attachments.filter(isFilePart)
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: value.name,
|
||||||
|
metadata: value.result.type === "json" && isRecord(value.result.value) ? value.result.value : {},
|
||||||
|
output:
|
||||||
|
typeof value.result.value === "string" ? value.result.value : (JSON.stringify(value.result.value) ?? ""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolInput = (value: unknown): Record<string, any> => (isRecord(value) ? value : { value })
|
||||||
|
|
||||||
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
|
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
|
||||||
switch (value.type) {
|
switch (value.type) {
|
||||||
case "start":
|
|
||||||
yield* status.set(ctx.sessionID, { type: "busy" })
|
|
||||||
return
|
|
||||||
|
|
||||||
case "reasoning-start":
|
case "reasoning-start":
|
||||||
if (value.id in ctx.reasoningMap) return
|
if (value.id in ctx.reasoningMap) return
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
@@ -240,6 +326,7 @@ export const layer = Layer.effect(
|
|||||||
return
|
return
|
||||||
|
|
||||||
case "reasoning-delta":
|
case "reasoning-delta":
|
||||||
|
// Match dev: silently drop orphan deltas (no preceding reasoning-start).
|
||||||
if (!(value.id in ctx.reasoningMap)) return
|
if (!(value.id in ctx.reasoningMap)) return
|
||||||
ctx.reasoningMap[value.id].text += value.text
|
ctx.reasoningMap[value.id].text += value.text
|
||||||
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
||||||
@@ -253,59 +340,26 @@ export const layer = Layer.effect(
|
|||||||
return
|
return
|
||||||
|
|
||||||
case "reasoning-end":
|
case "reasoning-end":
|
||||||
if (!(value.id in ctx.reasoningMap)) return
|
if (value.providerMetadata && value.id in ctx.reasoningMap) {
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
||||||
if (flags.experimentalEventSystem) {
|
|
||||||
yield* events.publish(SessionEvent.Reasoning.Ended, {
|
|
||||||
sessionID: ctx.sessionID,
|
|
||||||
reasoningID: value.id,
|
|
||||||
text: ctx.reasoningMap[value.id].text,
|
|
||||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
// oxlint-disable-next-line no-self-assign -- reactivity trigger
|
yield* finishReasoning(value.id)
|
||||||
ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text
|
|
||||||
ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() }
|
|
||||||
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
|
|
||||||
yield* session.updatePart(ctx.reasoningMap[value.id])
|
|
||||||
delete ctx.reasoningMap[value.id]
|
|
||||||
return
|
return
|
||||||
|
|
||||||
case "tool-input-start":
|
case "tool-input-start":
|
||||||
if (ctx.assistantMessage.summary) {
|
if (ctx.assistantMessage.summary) {
|
||||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
|
||||||
}
|
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
|
||||||
if (flags.experimentalEventSystem) {
|
|
||||||
yield* events.publish(SessionEvent.Tool.Input.Started, {
|
|
||||||
sessionID: ctx.sessionID,
|
|
||||||
callID: value.id,
|
|
||||||
name: value.toolName,
|
|
||||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const part = yield* session.updatePart({
|
|
||||||
id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(),
|
|
||||||
messageID: ctx.assistantMessage.id,
|
|
||||||
sessionID: ctx.assistantMessage.sessionID,
|
|
||||||
type: "tool",
|
|
||||||
tool: value.toolName,
|
|
||||||
callID: value.id,
|
|
||||||
state: { status: "pending", input: {}, raw: "" },
|
|
||||||
metadata: value.providerExecuted ? { providerExecuted: true } : undefined,
|
|
||||||
} satisfies MessageV2.ToolPart)
|
|
||||||
ctx.toolcalls[value.id] = {
|
|
||||||
done: yield* Deferred.make<void>(),
|
|
||||||
partID: part.id,
|
|
||||||
messageID: part.messageID,
|
|
||||||
sessionID: part.sessionID,
|
|
||||||
}
|
}
|
||||||
|
yield* ensureToolCall(value)
|
||||||
return
|
return
|
||||||
|
|
||||||
case "tool-input-delta":
|
case "tool-input-delta":
|
||||||
|
// AI SDK emits a final `tool-call` with the parsed `input`; accumulating
|
||||||
|
// delta fragments into `state.raw` is redundant work for no current consumer.
|
||||||
return
|
return
|
||||||
|
|
||||||
case "tool-input-end": {
|
case "tool-input-end": {
|
||||||
|
const toolCall = yield* ensureToolCall(value)
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
if (flags.experimentalEventSystem) {
|
if (flags.experimentalEventSystem) {
|
||||||
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||||
@@ -315,35 +369,50 @@ export const layer = Layer.effect(
|
|||||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
ctx.toolcalls[value.id] = { ...toolCall.call, inputEnded: true }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case "tool-call": {
|
case "tool-call": {
|
||||||
if (ctx.assistantMessage.summary) {
|
if (ctx.assistantMessage.summary) {
|
||||||
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
|
throw new Error(`Tool call not allowed while generating summary: ${value.name}`)
|
||||||
|
}
|
||||||
|
const toolCall = yield* ensureToolCall(value)
|
||||||
|
const input = toolInput(value.input)
|
||||||
|
if (!toolCall.call.inputEnded) {
|
||||||
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
|
if (flags.experimentalEventSystem) {
|
||||||
|
yield* events.publish(SessionEvent.Tool.Input.Ended, {
|
||||||
|
sessionID: ctx.sessionID,
|
||||||
|
callID: value.id,
|
||||||
|
text: "",
|
||||||
|
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const toolCall = yield* readToolCall(value.toolCallId)
|
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
if (flags.experimentalEventSystem) {
|
if (flags.experimentalEventSystem) {
|
||||||
yield* events.publish(SessionEvent.Tool.Called, {
|
yield* events.publish(SessionEvent.Tool.Called, {
|
||||||
sessionID: ctx.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
callID: value.toolCallId,
|
callID: value.id,
|
||||||
tool: value.toolName,
|
tool: value.name,
|
||||||
input: value.input,
|
input,
|
||||||
provider: {
|
provider: {
|
||||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
executed: toolCall.part.metadata?.providerExecuted === true,
|
||||||
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
...(value.providerMetadata ? { metadata: value.providerMetadata } : {}),
|
||||||
},
|
},
|
||||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
yield* updateToolCall(value.toolCallId, (match) => ({
|
yield* updateToolCall(value.id, (match) => ({
|
||||||
...match,
|
...match,
|
||||||
tool: value.toolName,
|
tool: value.name,
|
||||||
state: {
|
state:
|
||||||
...match.state,
|
match.state.status === "running"
|
||||||
|
? { ...match.state, input }
|
||||||
|
: {
|
||||||
status: "running",
|
status: "running",
|
||||||
input: value.input,
|
input,
|
||||||
time: { start: Date.now() },
|
time: { start: Date.now() },
|
||||||
},
|
},
|
||||||
metadata: match.metadata?.providerExecuted
|
metadata: match.metadata?.providerExecuted
|
||||||
@@ -359,9 +428,9 @@ export const layer = Layer.effect(
|
|||||||
!recentParts.every(
|
!recentParts.every(
|
||||||
(part) =>
|
(part) =>
|
||||||
part.type === "tool" &&
|
part.type === "tool" &&
|
||||||
part.tool === value.toolName &&
|
part.tool === value.name &&
|
||||||
part.state.status !== "pending" &&
|
part.state.status !== "pending" &&
|
||||||
JSON.stringify(part.state.input) === JSON.stringify(value.input),
|
JSON.stringify(part.state.input) === JSON.stringify(input),
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
@@ -370,27 +439,19 @@ export const layer = Layer.effect(
|
|||||||
const agent = yield* agents.get(ctx.assistantMessage.agent)
|
const agent = yield* agents.get(ctx.assistantMessage.agent)
|
||||||
yield* permission.ask({
|
yield* permission.ask({
|
||||||
permission: "doom_loop",
|
permission: "doom_loop",
|
||||||
patterns: [value.toolName],
|
patterns: [value.name],
|
||||||
sessionID: ctx.assistantMessage.sessionID,
|
sessionID: ctx.assistantMessage.sessionID,
|
||||||
metadata: { tool: value.toolName, input: value.input },
|
metadata: { tool: value.name, input },
|
||||||
always: [value.toolName],
|
always: [value.name],
|
||||||
ruleset: agent.permission,
|
ruleset: agent.permission,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case "tool-result": {
|
case "tool-result": {
|
||||||
const toolCall = yield* readToolCall(value.toolCallId)
|
const toolCall = yield* readToolCall(value.id)
|
||||||
const toolAttachments: MessageV2.FilePart[] = (
|
const rawOutput = toolResultOutput(value)
|
||||||
Array.isArray(value.output.attachments) ? value.output.attachments : []
|
const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) =>
|
||||||
).filter(
|
|
||||||
(attachment: unknown): attachment is MessageV2.FilePart =>
|
|
||||||
isRecord(attachment) &&
|
|
||||||
attachment.type === "file" &&
|
|
||||||
typeof attachment.mime === "string" &&
|
|
||||||
typeof attachment.url === "string",
|
|
||||||
)
|
|
||||||
const normalized = yield* Effect.forEach(toolAttachments, (attachment) =>
|
|
||||||
attachment.mime.startsWith("image/")
|
attachment.mime.startsWith("image/")
|
||||||
? image.normalize(attachment).pipe(
|
? image.normalize(attachment).pipe(
|
||||||
Effect.catchIf(
|
Effect.catchIf(
|
||||||
@@ -404,18 +465,18 @@ export const layer = Layer.effect(
|
|||||||
const omitted = normalized.filter(Exit.isFailure).length
|
const omitted = normalized.filter(Exit.isFailure).length
|
||||||
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
|
const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value)
|
||||||
const output = {
|
const output = {
|
||||||
...value.output,
|
...rawOutput,
|
||||||
output:
|
output:
|
||||||
omitted === 0
|
omitted === 0
|
||||||
? value.output.output
|
? rawOutput.output
|
||||||
: `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`,
|
: `${rawOutput.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`,
|
||||||
attachments: attachments?.length ? attachments : undefined,
|
attachments: attachments.length ? attachments : undefined,
|
||||||
}
|
}
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
if (flags.experimentalEventSystem) {
|
if (flags.experimentalEventSystem) {
|
||||||
yield* events.publish(SessionEvent.Tool.Success, {
|
yield* events.publish(SessionEvent.Tool.Success, {
|
||||||
sessionID: ctx.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
callID: value.toolCallId,
|
callID: value.id,
|
||||||
structured: output.metadata,
|
structured: output.metadata,
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
@@ -423,32 +484,32 @@ export const layer = Layer.effect(
|
|||||||
text: output.output,
|
text: output.output,
|
||||||
},
|
},
|
||||||
...(output.attachments?.map((item: MessageV2.FilePart) => ({
|
...(output.attachments?.map((item: MessageV2.FilePart) => ({
|
||||||
type: "file",
|
type: "file" as const,
|
||||||
uri: item.url,
|
uri: item.url,
|
||||||
mime: item.mime,
|
mime: item.mime,
|
||||||
name: item.filename,
|
name: item.filename,
|
||||||
})) ?? []),
|
})) ?? []),
|
||||||
],
|
],
|
||||||
provider: {
|
provider: {
|
||||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true,
|
||||||
},
|
},
|
||||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
yield* completeToolCall(value.toolCallId, output)
|
yield* completeToolCall(value.id, output)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case "tool-error": {
|
case "tool-error": {
|
||||||
const toolCall = yield* readToolCall(value.toolCallId)
|
const toolCall = yield* readToolCall(value.id)
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
if (flags.experimentalEventSystem) {
|
if (flags.experimentalEventSystem) {
|
||||||
yield* events.publish(SessionEvent.Tool.Failed, {
|
yield* events.publish(SessionEvent.Tool.Failed, {
|
||||||
sessionID: ctx.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
callID: value.toolCallId,
|
callID: value.id,
|
||||||
error: {
|
error: {
|
||||||
type: "unknown",
|
type: "unknown",
|
||||||
message: errorMessage(value.error),
|
message: value.message,
|
||||||
},
|
},
|
||||||
provider: {
|
provider: {
|
||||||
executed: toolCall?.part.metadata?.providerExecuted === true,
|
executed: toolCall?.part.metadata?.providerExecuted === true,
|
||||||
@@ -456,14 +517,14 @@ export const layer = Layer.effect(
|
|||||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
yield* failToolCall(value.toolCallId, value.error)
|
yield* failToolCall(value.id, value.error ?? new Error(value.message))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
case "error":
|
case "provider-error":
|
||||||
throw value.error
|
throw new Error(value.message)
|
||||||
|
|
||||||
case "start-step":
|
case "step-start":
|
||||||
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
|
||||||
if (!ctx.assistantMessage.summary) {
|
if (!ctx.assistantMessage.summary) {
|
||||||
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
|
||||||
@@ -490,11 +551,12 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
|
||||||
case "finish-step": {
|
case "step-finish": {
|
||||||
const completedSnapshot = yield* snapshot.track()
|
const completedSnapshot = yield* snapshot.track()
|
||||||
|
yield* Effect.forEach(Object.keys(ctx.reasoningMap), finishReasoning)
|
||||||
const usage = Session.getUsage({
|
const usage = Session.getUsage({
|
||||||
model: ctx.model,
|
model: ctx.model,
|
||||||
usage: value.usage,
|
usage: value.usage ?? new Usage({}),
|
||||||
metadata: value.providerMetadata,
|
metadata: value.providerMetadata,
|
||||||
})
|
})
|
||||||
if (!ctx.assistantMessage.summary) {
|
if (!ctx.assistantMessage.summary) {
|
||||||
@@ -502,7 +564,7 @@ export const layer = Layer.effect(
|
|||||||
if (flags.experimentalEventSystem) {
|
if (flags.experimentalEventSystem) {
|
||||||
yield* events.publish(SessionEvent.Step.Ended, {
|
yield* events.publish(SessionEvent.Step.Ended, {
|
||||||
sessionID: ctx.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
finish: value.finishReason,
|
finish: value.reason,
|
||||||
cost: usage.cost,
|
cost: usage.cost,
|
||||||
tokens: usage.tokens,
|
tokens: usage.tokens,
|
||||||
snapshot: completedSnapshot,
|
snapshot: completedSnapshot,
|
||||||
@@ -510,12 +572,12 @@ export const layer = Layer.effect(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx.assistantMessage.finish = value.finishReason
|
ctx.assistantMessage.finish = value.reason
|
||||||
ctx.assistantMessage.cost += usage.cost
|
ctx.assistantMessage.cost += usage.cost
|
||||||
ctx.assistantMessage.tokens = usage.tokens
|
ctx.assistantMessage.tokens = usage.tokens
|
||||||
yield* session.updatePart({
|
yield* session.updatePart({
|
||||||
id: PartID.ascending(),
|
id: PartID.ascending(),
|
||||||
reason: value.finishReason,
|
reason: value.reason,
|
||||||
snapshot: completedSnapshot,
|
snapshot: completedSnapshot,
|
||||||
messageID: ctx.assistantMessage.id,
|
messageID: ctx.assistantMessage.id,
|
||||||
sessionID: ctx.assistantMessage.sessionID,
|
sessionID: ctx.assistantMessage.sessionID,
|
||||||
@@ -622,10 +684,6 @@ export const layer = Layer.effect(
|
|||||||
|
|
||||||
case "finish":
|
case "finish":
|
||||||
return
|
return
|
||||||
|
|
||||||
default:
|
|
||||||
slog.info("unhandled", { event: value.type, value })
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -727,6 +785,7 @@ export const layer = Layer.effect(
|
|||||||
yield* Effect.gen(function* () {
|
yield* Effect.gen(function* () {
|
||||||
ctx.currentText = undefined
|
ctx.currentText = undefined
|
||||||
ctx.reasoningMap = {}
|
ctx.reasoningMap = {}
|
||||||
|
yield* status.set(ctx.sessionID, { type: "busy" })
|
||||||
const stream = llm.stream(streamInput)
|
const stream = llm.stream(streamInput)
|
||||||
|
|
||||||
yield* stream.pipe(
|
yield* stream.pipe(
|
||||||
|
|||||||
@@ -8,17 +8,15 @@ import * as Session from "./session"
|
|||||||
import { Agent } from "../agent/agent"
|
import { Agent } from "../agent/agent"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { ModelID, ProviderID } from "../provider/schema"
|
import { ModelID, ProviderID } from "../provider/schema"
|
||||||
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
|
import { type Tool as AITool, tool, jsonSchema } from "ai"
|
||||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||||
import { SessionCompaction } from "./compaction"
|
import { SessionCompaction } from "./compaction"
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
import { ProviderTransform } from "@/provider/transform"
|
|
||||||
import { SystemPrompt } from "./system"
|
import { SystemPrompt } from "./system"
|
||||||
import { Instruction } from "./instruction"
|
import { Instruction } from "./instruction"
|
||||||
import { Plugin } from "../plugin"
|
import { Plugin } from "../plugin"
|
||||||
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
||||||
import { ToolRegistry } from "@/tool/registry"
|
import { ToolRegistry } from "@/tool/registry"
|
||||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
|
||||||
import { MCP } from "../mcp"
|
import { MCP } from "../mcp"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
import { ulid } from "ulid"
|
import { ulid } from "ulid"
|
||||||
@@ -48,7 +46,6 @@ import * as EffectLogger from "@opencode-ai/core/effect/logger"
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
import { TaskTool, type TaskPromptOps } from "@/tool/task"
|
||||||
import { SessionRunState } from "./run-state"
|
import { SessionRunState } from "./run-state"
|
||||||
import { EffectBridge } from "@/effect/bridge"
|
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { EventV2 } from "@opencode-ai/core/event"
|
import { EventV2 } from "@opencode-ai/core/event"
|
||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
@@ -63,6 +60,8 @@ import * as Database from "@/storage/db"
|
|||||||
import { SessionTable } from "./session.sql"
|
import { SessionTable } from "./session.sql"
|
||||||
import { referencePromptMetadata, referenceTextPart } from "./prompt/reference"
|
import { referencePromptMetadata, referenceTextPart } from "./prompt/reference"
|
||||||
import { SessionReminders } from "./reminders"
|
import { SessionReminders } from "./reminders"
|
||||||
|
import { SessionTools } from "./tools"
|
||||||
|
import { LLMEvent } from "@opencode-ai/llm"
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
@@ -125,9 +124,6 @@ export const layer = Layer.effect(
|
|||||||
const references = yield* Reference.Service
|
const references = yield* Reference.Service
|
||||||
const events = yield* EventV2Bridge.Service
|
const events = yield* EventV2Bridge.Service
|
||||||
const flags = yield* RuntimeFlags.Service
|
const flags = yield* RuntimeFlags.Service
|
||||||
const runner = Effect.fn("SessionPrompt.runner")(function* () {
|
|
||||||
return yield* EffectBridge.make()
|
|
||||||
})
|
|
||||||
const ops = Effect.fn("SessionPrompt.ops")(function* () {
|
const ops = Effect.fn("SessionPrompt.ops")(function* () {
|
||||||
return {
|
return {
|
||||||
cancel: (sessionID: SessionID) => cancel(sessionID),
|
cancel: (sessionID: SessionID) => cancel(sessionID),
|
||||||
@@ -283,7 +279,7 @@ export const layer = Layer.effect(
|
|||||||
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
|
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
Stream.filter((e): e is Extract<LLM.Event, { type: "text-delta" }> => e.type === "text-delta"),
|
Stream.filter(LLMEvent.is.textDelta),
|
||||||
Stream.map((e) => e.text),
|
Stream.map((e) => e.text),
|
||||||
Stream.mkString,
|
Stream.mkString,
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
@@ -300,186 +296,6 @@ export const layer = Layer.effect(
|
|||||||
.pipe(Effect.catchCause((cause) => elog.error("failed to generate title", { error: Cause.squash(cause) })))
|
.pipe(Effect.catchCause((cause) => elog.error("failed to generate title", { error: Cause.squash(cause) })))
|
||||||
})
|
})
|
||||||
|
|
||||||
const resolveTools = Effect.fn("SessionPrompt.resolveTools")(function* (input: {
|
|
||||||
agent: Agent.Info
|
|
||||||
model: Provider.Model
|
|
||||||
session: Session.Info
|
|
||||||
tools?: Record<string, boolean>
|
|
||||||
processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
|
|
||||||
bypassAgentCheck: boolean
|
|
||||||
messages: MessageV2.WithParts[]
|
|
||||||
}) {
|
|
||||||
using _ = log.time("resolveTools")
|
|
||||||
const tools: Record<string, AITool> = {}
|
|
||||||
const run = yield* runner()
|
|
||||||
const promptOps = yield* ops()
|
|
||||||
|
|
||||||
const context = (args: any, options: ToolExecutionOptions): Tool.Context => ({
|
|
||||||
sessionID: input.session.id,
|
|
||||||
abort: options.abortSignal!,
|
|
||||||
messageID: input.processor.message.id,
|
|
||||||
callID: options.toolCallId,
|
|
||||||
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps },
|
|
||||||
agent: input.agent.name,
|
|
||||||
messages: input.messages,
|
|
||||||
metadata: (val) =>
|
|
||||||
input.processor.updateToolCall(options.toolCallId, (match) => {
|
|
||||||
if (!["running", "pending"].includes(match.state.status)) return match
|
|
||||||
return {
|
|
||||||
...match,
|
|
||||||
state: {
|
|
||||||
title: val.title,
|
|
||||||
metadata: val.metadata,
|
|
||||||
status: "running",
|
|
||||||
input: args,
|
|
||||||
time: { start: Date.now() },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
ask: (req) =>
|
|
||||||
permission
|
|
||||||
.ask({
|
|
||||||
...req,
|
|
||||||
sessionID: input.session.id,
|
|
||||||
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
|
|
||||||
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
|
|
||||||
})
|
|
||||||
.pipe(Effect.orDie),
|
|
||||||
})
|
|
||||||
|
|
||||||
for (const item of yield* registry.tools({
|
|
||||||
modelID: ModelID.make(input.model.api.id),
|
|
||||||
providerID: input.model.providerID,
|
|
||||||
agent: input.agent,
|
|
||||||
})) {
|
|
||||||
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
|
||||||
tools[item.id] = tool({
|
|
||||||
description: item.description,
|
|
||||||
inputSchema: jsonSchema(schema),
|
|
||||||
execute(args, options) {
|
|
||||||
return run.promise(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const ctx = context(args, options)
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"tool.execute.before",
|
|
||||||
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
|
|
||||||
{ args },
|
|
||||||
)
|
|
||||||
const result = yield* item.execute(args, ctx)
|
|
||||||
const output = {
|
|
||||||
...result,
|
|
||||||
attachments: result.attachments?.map((attachment) => ({
|
|
||||||
...attachment,
|
|
||||||
id: PartID.ascending(),
|
|
||||||
sessionID: ctx.sessionID,
|
|
||||||
messageID: input.processor.message.id,
|
|
||||||
})),
|
|
||||||
}
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"tool.execute.after",
|
|
||||||
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
|
|
||||||
output,
|
|
||||||
)
|
|
||||||
if (options.abortSignal?.aborted) {
|
|
||||||
yield* input.processor.completeToolCall(options.toolCallId, output)
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
|
||||||
const execute = item.execute
|
|
||||||
if (!execute) continue
|
|
||||||
|
|
||||||
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
|
|
||||||
const transformed = ProviderTransform.schema(input.model, schema)
|
|
||||||
item.inputSchema = jsonSchema(transformed)
|
|
||||||
item.execute = (args, opts) =>
|
|
||||||
run.promise(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const ctx = context(args, opts)
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"tool.execute.before",
|
|
||||||
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
|
||||||
{ args },
|
|
||||||
)
|
|
||||||
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
|
|
||||||
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
|
|
||||||
return yield* Effect.promise(() => execute(args, opts))
|
|
||||||
}).pipe(
|
|
||||||
Effect.withSpan("Tool.execute", {
|
|
||||||
attributes: {
|
|
||||||
"tool.name": key,
|
|
||||||
"tool.call_id": opts.toolCallId,
|
|
||||||
"session.id": ctx.sessionID,
|
|
||||||
"message.id": input.processor.message.id,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"tool.execute.after",
|
|
||||||
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
|
|
||||||
const textParts: string[] = []
|
|
||||||
const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []
|
|
||||||
for (const contentItem of result.content) {
|
|
||||||
if (contentItem.type === "text") textParts.push(contentItem.text)
|
|
||||||
else if (contentItem.type === "image") {
|
|
||||||
attachments.push({
|
|
||||||
type: "file",
|
|
||||||
mime: contentItem.mimeType,
|
|
||||||
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
|
|
||||||
})
|
|
||||||
} else if (contentItem.type === "resource") {
|
|
||||||
const { resource } = contentItem
|
|
||||||
if (resource.text) textParts.push(resource.text)
|
|
||||||
if (resource.blob) {
|
|
||||||
attachments.push({
|
|
||||||
type: "file",
|
|
||||||
mime: resource.mimeType ?? "application/octet-stream",
|
|
||||||
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
|
|
||||||
filename: resource.uri,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)
|
|
||||||
const metadata = {
|
|
||||||
...result.metadata,
|
|
||||||
truncated: truncated.truncated,
|
|
||||||
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = {
|
|
||||||
title: "",
|
|
||||||
metadata,
|
|
||||||
output: truncated.content,
|
|
||||||
attachments: attachments.map((attachment) => ({
|
|
||||||
...attachment,
|
|
||||||
id: PartID.ascending(),
|
|
||||||
sessionID: ctx.sessionID,
|
|
||||||
messageID: input.processor.message.id,
|
|
||||||
})),
|
|
||||||
content: result.content,
|
|
||||||
}
|
|
||||||
if (opts.abortSignal?.aborted) {
|
|
||||||
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
tools[key] = item
|
|
||||||
}
|
|
||||||
|
|
||||||
return tools
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: {
|
const handleSubtask = Effect.fn("SessionPrompt.handleSubtask")(function* (input: {
|
||||||
task: MessageV2.SubtaskPart
|
task: MessageV2.SubtaskPart
|
||||||
model: Provider.Model
|
model: Provider.Model
|
||||||
@@ -1551,16 +1367,23 @@ export const layer = Layer.effect(
|
|||||||
const outcome: "break" | "continue" = yield* Effect.gen(function* () {
|
const outcome: "break" | "continue" = yield* Effect.gen(function* () {
|
||||||
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
|
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
|
||||||
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
|
const bypassAgentCheck = lastUserMsg?.parts.some((p) => p.type === "agent") ?? false
|
||||||
|
const promptOps = yield* ops()
|
||||||
|
|
||||||
const tools = yield* resolveTools({
|
const tools = yield* SessionTools.resolve({
|
||||||
agent,
|
agent,
|
||||||
session,
|
session,
|
||||||
model,
|
model,
|
||||||
tools: lastUser.tools,
|
|
||||||
processor: handle,
|
processor: handle,
|
||||||
bypassAgentCheck,
|
bypassAgentCheck,
|
||||||
messages: msgs,
|
messages: msgs,
|
||||||
})
|
promptOps,
|
||||||
|
}).pipe(
|
||||||
|
Effect.provideService(Plugin.Service, plugin),
|
||||||
|
Effect.provideService(Permission.Service, permission),
|
||||||
|
Effect.provideService(ToolRegistry.Service, registry),
|
||||||
|
Effect.provideService(MCP.Service, mcp),
|
||||||
|
Effect.provideService(Truncate.Service, truncate),
|
||||||
|
)
|
||||||
|
|
||||||
if (lastUser.format?.type === "json_schema") {
|
if (lastUser.format?.type === "json_schema") {
|
||||||
tools["StructuredOutput"] = createStructuredOutputTool({
|
tools["StructuredOutput"] = createStructuredOutputTool({
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { BackgroundJob } from "@/background/job"
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { Decimal } from "decimal.js"
|
import { Decimal } from "decimal.js"
|
||||||
import { type ProviderMetadata, type LanguageModelUsage } from "ai"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import type { ProviderMetadata, Usage } from "@opencode-ai/llm"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
|
|
||||||
import { Database } from "@/storage/db"
|
import { Database } from "@/storage/db"
|
||||||
@@ -374,21 +375,19 @@ export function plan(input: { slug: string; time: { created: number } }, instanc
|
|||||||
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
|
return path.join(base, [input.time.created, input.slug].join("-") + ".md")
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => {
|
export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?: ProviderMetadata }) => {
|
||||||
const safe = (value: number) => {
|
const safe = (value: number) => {
|
||||||
if (!Number.isFinite(value)) return 0
|
if (!Number.isFinite(value)) return 0
|
||||||
return Math.max(0, value)
|
return Math.max(0, value)
|
||||||
}
|
}
|
||||||
const inputTokens = safe(input.usage.inputTokens ?? 0)
|
const inputTokens = safe(input.usage.inputTokens ?? 0)
|
||||||
const outputTokens = safe(input.usage.outputTokens ?? 0)
|
const outputTokens = safe(input.usage.outputTokens ?? 0)
|
||||||
const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0)
|
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
|
||||||
|
|
||||||
const cacheReadInputTokens = safe(
|
const cacheReadInputTokens = safe(input.usage.cacheReadInputTokens ?? 0)
|
||||||
input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0,
|
|
||||||
)
|
|
||||||
const cacheWriteInputTokens = safe(
|
const cacheWriteInputTokens = safe(
|
||||||
Number(
|
Number(
|
||||||
input.usage.inputTokenDetails?.cacheWriteTokens ??
|
input.usage.cacheWriteInputTokens ??
|
||||||
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
||||||
// google-vertex-anthropic returns metadata under "vertex" key
|
// google-vertex-anthropic returns metadata under "vertex" key
|
||||||
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
|
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { Agent } from "@/agent/agent"
|
||||||
|
import { Provider } from "@/provider/provider"
|
||||||
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
|
import { MCP } from "@/mcp"
|
||||||
|
import { Permission } from "@/permission"
|
||||||
|
import { Tool } from "@/tool/tool"
|
||||||
|
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||||
|
import { ToolRegistry } from "@/tool/registry"
|
||||||
|
import { Truncate } from "@/tool/truncate"
|
||||||
|
import { ModelID } from "@/provider/schema"
|
||||||
|
import { Plugin } from "@/plugin"
|
||||||
|
import type { TaskPromptOps } from "@/tool/task"
|
||||||
|
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { MessageV2 } from "./message-v2"
|
||||||
|
import * as Session from "./session"
|
||||||
|
import { SessionProcessor } from "./processor"
|
||||||
|
import { PartID } from "./schema"
|
||||||
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "session.tools" })
|
||||||
|
|
||||||
|
export const resolve = Effect.fn("SessionTools.resolve")(function* (input: {
|
||||||
|
agent: Agent.Info
|
||||||
|
model: Provider.Model
|
||||||
|
session: Session.Info
|
||||||
|
processor: Pick<SessionProcessor.Handle, "message" | "updateToolCall" | "completeToolCall">
|
||||||
|
bypassAgentCheck: boolean
|
||||||
|
messages: MessageV2.WithParts[]
|
||||||
|
promptOps: TaskPromptOps
|
||||||
|
}) {
|
||||||
|
using _ = log.time("resolveTools")
|
||||||
|
const tools: Record<string, AITool> = {}
|
||||||
|
const run = yield* EffectBridge.make()
|
||||||
|
const plugin = yield* Plugin.Service
|
||||||
|
const permission = yield* Permission.Service
|
||||||
|
const registry = yield* ToolRegistry.Service
|
||||||
|
const mcp = yield* MCP.Service
|
||||||
|
const truncate = yield* Truncate.Service
|
||||||
|
|
||||||
|
const context = (args: Record<string, unknown>, options: ToolExecutionOptions): Tool.Context => ({
|
||||||
|
sessionID: input.session.id,
|
||||||
|
abort: options.abortSignal!,
|
||||||
|
messageID: input.processor.message.id,
|
||||||
|
callID: options.toolCallId,
|
||||||
|
extra: { model: input.model, bypassAgentCheck: input.bypassAgentCheck, promptOps: input.promptOps },
|
||||||
|
agent: input.agent.name,
|
||||||
|
messages: input.messages,
|
||||||
|
metadata: (val) =>
|
||||||
|
input.processor.updateToolCall(options.toolCallId, (match) => {
|
||||||
|
if (!["running", "pending"].includes(match.state.status)) return match
|
||||||
|
return {
|
||||||
|
...match,
|
||||||
|
state: {
|
||||||
|
title: val.title,
|
||||||
|
metadata: val.metadata,
|
||||||
|
status: "running",
|
||||||
|
input: args,
|
||||||
|
time: { start: Date.now() },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
ask: (req) =>
|
||||||
|
permission
|
||||||
|
.ask({
|
||||||
|
...req,
|
||||||
|
sessionID: input.session.id,
|
||||||
|
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
|
||||||
|
ruleset: Permission.merge(input.agent.permission, input.session.permission ?? []),
|
||||||
|
})
|
||||||
|
.pipe(Effect.orDie),
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const item of yield* registry.tools({
|
||||||
|
modelID: ModelID.make(input.model.api.id),
|
||||||
|
providerID: input.model.providerID,
|
||||||
|
agent: input.agent,
|
||||||
|
})) {
|
||||||
|
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
||||||
|
tools[item.id] = tool({
|
||||||
|
description: item.description,
|
||||||
|
inputSchema: jsonSchema(schema),
|
||||||
|
execute(args, options) {
|
||||||
|
return run.promise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const ctx = context(args, options)
|
||||||
|
yield* plugin.trigger(
|
||||||
|
"tool.execute.before",
|
||||||
|
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID },
|
||||||
|
{ args },
|
||||||
|
)
|
||||||
|
const result = yield* item.execute(args, ctx)
|
||||||
|
const output = {
|
||||||
|
...result,
|
||||||
|
attachments: result.attachments?.map((attachment) => ({
|
||||||
|
...attachment,
|
||||||
|
id: PartID.ascending(),
|
||||||
|
sessionID: ctx.sessionID,
|
||||||
|
messageID: input.processor.message.id,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
yield* plugin.trigger(
|
||||||
|
"tool.execute.after",
|
||||||
|
{ tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, args },
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
if (options.abortSignal?.aborted) {
|
||||||
|
yield* input.processor.completeToolCall(options.toolCallId, output)
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, item] of Object.entries(yield* mcp.tools())) {
|
||||||
|
const execute = item.execute
|
||||||
|
if (!execute) continue
|
||||||
|
|
||||||
|
const schema = yield* Effect.promise(() => Promise.resolve(asSchema(item.inputSchema).jsonSchema))
|
||||||
|
const transformed = ProviderTransform.schema(input.model, schema)
|
||||||
|
item.inputSchema = jsonSchema(transformed)
|
||||||
|
item.execute = (args, opts) =>
|
||||||
|
run.promise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const ctx = context(args, opts)
|
||||||
|
yield* plugin.trigger(
|
||||||
|
"tool.execute.before",
|
||||||
|
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId },
|
||||||
|
{ args },
|
||||||
|
)
|
||||||
|
const result: Awaited<ReturnType<NonNullable<typeof execute>>> = yield* Effect.gen(function* () {
|
||||||
|
yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] })
|
||||||
|
return yield* Effect.promise(() => execute(args, opts))
|
||||||
|
}).pipe(
|
||||||
|
Effect.withSpan("Tool.execute", {
|
||||||
|
attributes: {
|
||||||
|
"tool.name": key,
|
||||||
|
"tool.call_id": opts.toolCallId,
|
||||||
|
"session.id": ctx.sessionID,
|
||||||
|
"message.id": input.processor.message.id,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
yield* plugin.trigger(
|
||||||
|
"tool.execute.after",
|
||||||
|
{ tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, args },
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
|
||||||
|
const textParts: string[] = []
|
||||||
|
const attachments: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[] = []
|
||||||
|
for (const contentItem of result.content) {
|
||||||
|
if (contentItem.type === "text") textParts.push(contentItem.text)
|
||||||
|
else if (contentItem.type === "image") {
|
||||||
|
attachments.push({
|
||||||
|
type: "file",
|
||||||
|
mime: contentItem.mimeType,
|
||||||
|
url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
|
||||||
|
})
|
||||||
|
} else if (contentItem.type === "resource") {
|
||||||
|
const { resource } = contentItem
|
||||||
|
if (resource.text) textParts.push(resource.text)
|
||||||
|
if (resource.blob) {
|
||||||
|
attachments.push({
|
||||||
|
type: "file",
|
||||||
|
mime: resource.mimeType ?? "application/octet-stream",
|
||||||
|
url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
|
||||||
|
filename: resource.uri,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const truncated = yield* truncate.output(textParts.join("\n\n"), {}, input.agent)
|
||||||
|
const metadata = {
|
||||||
|
...result.metadata,
|
||||||
|
truncated: truncated.truncated,
|
||||||
|
...(truncated.truncated && { outputPath: truncated.outputPath }),
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = {
|
||||||
|
title: "",
|
||||||
|
metadata,
|
||||||
|
output: truncated.content,
|
||||||
|
attachments: attachments.map((attachment) => ({
|
||||||
|
...attachment,
|
||||||
|
id: PartID.ascending(),
|
||||||
|
sessionID: ctx.sessionID,
|
||||||
|
messageID: input.processor.message.id,
|
||||||
|
})),
|
||||||
|
content: result.content,
|
||||||
|
}
|
||||||
|
if (opts.abortSignal?.aborted) {
|
||||||
|
yield* input.processor.completeToolCall(opts.toolCallId, output)
|
||||||
|
}
|
||||||
|
return output
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
tools[key] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
return tools
|
||||||
|
})
|
||||||
|
|
||||||
|
export * as SessionTools from "./tools"
|
||||||
@@ -7,9 +7,7 @@ import { eq } from "drizzle-orm"
|
|||||||
import { GlobalBus } from "@/bus/global"
|
import { GlobalBus } from "@/bus/global"
|
||||||
import { Bus as ProjectBus } from "@/bus"
|
import { Bus as ProjectBus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import type { InstanceContext } from "@/project/instance-context"
|
|
||||||
import { EventSequenceTable, EventTable } from "./event.sql"
|
import { EventSequenceTable, EventTable } from "./event.sql"
|
||||||
import type { WorkspaceID } from "@/control-plane/schema"
|
|
||||||
import { EventID } from "./schema"
|
import { EventID } from "./schema"
|
||||||
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
|
import { Context, Effect, Layer, Schema as EffectSchema } from "effect"
|
||||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||||
@@ -17,7 +15,7 @@ import { EventV2 } from "@opencode-ai/core/event"
|
|||||||
import { serviceUse } from "@/effect/service-use"
|
import { serviceUse } from "@/effect/service-use"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { attachWith } from "@/effect/run-service"
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
|
|
||||||
// Keep `Event["data"]` mutable because projectors mutate the persisted shape
|
// Keep `Event["data"]` mutable because projectors mutate the persisted shape
|
||||||
// when writing to the database. Bus payloads (`Properties`) stay readonly —
|
// when writing to the database. Bus payloads (`Properties`) stay readonly —
|
||||||
@@ -51,10 +49,6 @@ export type SerializedEvent<Def extends Definition = Definition> = Event<Def> &
|
|||||||
|
|
||||||
type ProjectorFunc = (db: Database.TxOrDb, data: unknown, event: Event) => void
|
type ProjectorFunc = (db: Database.TxOrDb, data: unknown, event: Event) => void
|
||||||
type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown>
|
type ConvertEvent = (type: string, data: Event["data"]) => unknown | Promise<unknown>
|
||||||
type PublishContext = {
|
|
||||||
instance?: InstanceContext
|
|
||||||
workspace?: WorkspaceID
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
readonly run: <Def extends Definition>(
|
readonly run: <Def extends Definition>(
|
||||||
@@ -107,16 +101,14 @@ export const layer = Layer.effect(Service)(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const publish = !!options?.publish
|
const publish = !!options?.publish
|
||||||
const context = publish
|
// Bridge captures handler-fiber refs (InstanceRef/WorkspaceRef) and the
|
||||||
? {
|
// full Effect context, so the forked publish + GlobalBus emit run with
|
||||||
instance: yield* InstanceState.context,
|
// the right state without a per-call attachWith.
|
||||||
workspace: yield* InstanceState.workspaceID,
|
const bridge = yield* EffectBridge.make()
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
process(def, event, {
|
process(def, event, {
|
||||||
bus,
|
bus,
|
||||||
|
bridge,
|
||||||
publish,
|
publish,
|
||||||
context,
|
|
||||||
ownerID: options?.ownerID,
|
ownerID: options?.ownerID,
|
||||||
experimentalWorkspaces: flags.experimentalWorkspaces,
|
experimentalWorkspaces: flags.experimentalWorkspaces,
|
||||||
})
|
})
|
||||||
@@ -154,12 +146,7 @@ export const layer = Layer.effect(Service)(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { publish = true } = options || {}
|
const { publish = true } = options || {}
|
||||||
const context = publish
|
const bridge = yield* EffectBridge.make()
|
||||||
? {
|
|
||||||
instance: yield* InstanceState.context,
|
|
||||||
workspace: yield* InstanceState.workspaceID,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
// Note that this is an "immediate" transaction which is critical.
|
// Note that this is an "immediate" transaction which is critical.
|
||||||
// We need to make sure we can safely read and write with nothing
|
// We need to make sure we can safely read and write with nothing
|
||||||
@@ -175,7 +162,7 @@ export const layer = Layer.effect(Service)(
|
|||||||
const seq = row?.seq != null ? row.seq + 1 : 0
|
const seq = row?.seq != null ? row.seq + 1 : 0
|
||||||
|
|
||||||
const event = { id, seq, aggregateID: agg, data }
|
const event = { id, seq, aggregateID: agg, data }
|
||||||
process(def, event, { bus, publish, context, experimentalWorkspaces: flags.experimentalWorkspaces })
|
process(def, event, { bus, bridge, publish, experimentalWorkspaces: flags.experimentalWorkspaces })
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
behavior: "immediate",
|
behavior: "immediate",
|
||||||
@@ -308,8 +295,8 @@ function process<Def extends Definition>(
|
|||||||
event: Event<Def>,
|
event: Event<Def>,
|
||||||
options: {
|
options: {
|
||||||
bus: ProjectBus.Interface
|
bus: ProjectBus.Interface
|
||||||
|
bridge: EffectBridge.Shape
|
||||||
publish: boolean
|
publish: boolean
|
||||||
context?: PublishContext
|
|
||||||
ownerID?: string
|
ownerID?: string
|
||||||
experimentalWorkspaces: boolean
|
experimentalWorkspaces: boolean
|
||||||
},
|
},
|
||||||
@@ -351,29 +338,22 @@ function process<Def extends Definition>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Database.effect(() => {
|
Database.effect(() => {
|
||||||
if (options?.publish) {
|
if (!options.publish) return
|
||||||
if (!options.context?.instance) {
|
|
||||||
throw new Error("SyncEvent.process: publish requires instance context")
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = convertEvent(def.type, event.data)
|
const result = convertEvent(def.type, event.data)
|
||||||
|
// The bridge was built inside the caller's fiber so it already carries
|
||||||
|
// InstanceRef/WorkspaceRef and the full Effect context. Both the bus
|
||||||
|
// publish and the GlobalBus emit run inside the forked Effect so they
|
||||||
|
// share the same instance/workspace lookup.
|
||||||
const publish = (data: unknown) =>
|
const publish = (data: unknown) =>
|
||||||
Effect.runPromise(
|
options.bridge.fork(
|
||||||
attachWith(options.bus.publish(def, data as Properties<Def>, { id: event.id }), {
|
Effect.gen(function* () {
|
||||||
instance: options.context?.instance,
|
yield* options.bus.publish(def, data as Properties<Def>, { id: event.id })
|
||||||
workspace: options.context?.workspace,
|
const instance = yield* InstanceState.context
|
||||||
}),
|
const workspace = yield* InstanceState.workspaceID
|
||||||
)
|
|
||||||
if (result instanceof Promise) {
|
|
||||||
void result.then(publish)
|
|
||||||
} else {
|
|
||||||
void publish(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: options.context.instance.directory,
|
directory: instance.directory,
|
||||||
project: options.context.instance.project.id,
|
project: instance.project.id,
|
||||||
workspace: options.context.workspace,
|
workspace,
|
||||||
payload: {
|
payload: {
|
||||||
type: "sync",
|
type: "sync",
|
||||||
syncEvent: {
|
syncEvent: {
|
||||||
@@ -382,6 +362,12 @@ function process<Def extends Definition>(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (result instanceof Promise) {
|
||||||
|
void result.then(publish)
|
||||||
|
} else {
|
||||||
|
publish(result)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
|||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Ripgrep } from "../file/ripgrep"
|
||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
import { Question } from "../question"
|
import { Question } from "../question"
|
||||||
import { Todo } from "../session/todo"
|
import { Todo } from "../session/todo"
|
||||||
import { LSP } from "@/lsp/lsp"
|
import { LSP } from "@/lsp/lsp"
|
||||||
@@ -158,9 +159,12 @@ export const layer: Layer.Layer<
|
|||||||
description: def.description,
|
description: def.description,
|
||||||
execute: (args, toolCtx) =>
|
execute: (args, toolCtx) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
// Bridge the host's Effect-based `ask` into a Promise-returning
|
||||||
|
// function for the plugin to make sure context persists
|
||||||
|
const bridge = yield* EffectBridge.make()
|
||||||
const pluginCtx: PluginToolContext = {
|
const pluginCtx: PluginToolContext = {
|
||||||
...toolCtx,
|
...toolCtx,
|
||||||
ask: (req) => toolCtx.ask(req),
|
ask: (req) => bridge.promise(toolCtx.ask(req)),
|
||||||
directory: ctx.directory,
|
directory: ctx.directory,
|
||||||
worktree: ctx.worktree,
|
worktree: ctx.worktree,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ describe("RuntimeFlags", () => {
|
|||||||
expect(flags.experimentalEventSystem).toBe(true)
|
expect(flags.experimentalEventSystem).toBe(true)
|
||||||
expect(flags.experimentalWorkspaces).toBe(true)
|
expect(flags.experimentalWorkspaces).toBe(true)
|
||||||
expect(flags.experimentalIconDiscovery).toBe(true)
|
expect(flags.experimentalIconDiscovery).toBe(true)
|
||||||
|
expect(flags.experimentalNativeLlm).toBe(true)
|
||||||
expect(flags.client).toBe("desktop")
|
expect(flags.client).toBe("desktop")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -80,6 +81,16 @@ describe("RuntimeFlags", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("enables native LLM via dedicated or umbrella flag", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const explicit = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_NATIVE_LLM: "true" })))
|
||||||
|
const umbrella = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL: "true" })))
|
||||||
|
|
||||||
|
expect(explicit.experimentalNativeLlm).toBe(true)
|
||||||
|
expect(umbrella.experimentalNativeLlm).toBe(true)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () =>
|
it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const flags = yield* readFlags.pipe(
|
const flags = yield* readFlags.pipe(
|
||||||
|
|||||||
+31
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import {
|
import {
|
||||||
|
CodexAuthPlugin,
|
||||||
parseJwtClaims,
|
parseJwtClaims,
|
||||||
extractAccountIdFromClaims,
|
extractAccountIdFromClaims,
|
||||||
extractAccountId,
|
extractAccountId,
|
||||||
@@ -120,4 +121,109 @@ describe("plugin.codex", () => {
|
|||||||
).toBe("acc-123")
|
).toBe("acc-123")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("deduplicates concurrent Codex token refreshes", async () => {
|
||||||
|
let auth = {
|
||||||
|
type: "oauth" as const,
|
||||||
|
refresh: "refresh-old",
|
||||||
|
access: "",
|
||||||
|
expires: 0,
|
||||||
|
}
|
||||||
|
const authUpdates: Array<{
|
||||||
|
body: { refresh: string; access: string; expires: number; accountId?: string }
|
||||||
|
}> = []
|
||||||
|
let resolveRefresh: (() => void) | undefined
|
||||||
|
const refreshReady = new Promise<void>((resolve) => {
|
||||||
|
resolveRefresh = resolve
|
||||||
|
})
|
||||||
|
let refreshRequests = 0
|
||||||
|
const apiRequests: { authorization: string | null; accountId: string | null }[] = []
|
||||||
|
|
||||||
|
using server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
async fetch(request) {
|
||||||
|
const url = new URL(request.url)
|
||||||
|
if (url.pathname === "/oauth/token") {
|
||||||
|
expect(await request.text()).toContain("refresh_token=refresh-old")
|
||||||
|
refreshRequests += 1
|
||||||
|
await refreshReady
|
||||||
|
return Response.json({
|
||||||
|
id_token: createTestJwt({ chatgpt_account_id: "acc-123" }),
|
||||||
|
access_token: "access-new",
|
||||||
|
refresh_token: "refresh-new",
|
||||||
|
expires_in: 3600,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === "/backend-api/codex/responses") {
|
||||||
|
apiRequests.push({
|
||||||
|
authorization: request.headers.get("authorization"),
|
||||||
|
accountId: request.headers.get("ChatGPT-Account-Id"),
|
||||||
|
})
|
||||||
|
return new Response("{}", { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response("unexpected request", { status: 500 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const hooks = await CodexAuthPlugin(
|
||||||
|
{
|
||||||
|
client: {
|
||||||
|
auth: {
|
||||||
|
async set(input: { body: { refresh: string; access: string; expires: number; accountId?: string } }) {
|
||||||
|
authUpdates.push(input)
|
||||||
|
auth = {
|
||||||
|
type: "oauth",
|
||||||
|
refresh: input.body.refresh,
|
||||||
|
access: input.body.access,
|
||||||
|
expires: input.body.expires,
|
||||||
|
...(input.body.accountId && { accountId: input.body.accountId }),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
project: {} as never,
|
||||||
|
directory: "",
|
||||||
|
worktree: "",
|
||||||
|
experimental_workspace: {
|
||||||
|
register() {},
|
||||||
|
},
|
||||||
|
serverUrl: new URL("https://example.com"),
|
||||||
|
$: {} as never,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
issuer: server.url.origin,
|
||||||
|
codexApiEndpoint: new URL("/backend-api/codex/responses", server.url).toString(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const loaded = await hooks.auth!.loader!(async () => auth as never, {} as never)
|
||||||
|
|
||||||
|
const first = loaded.fetch!("https://api.openai.com/v1/responses")
|
||||||
|
const second = loaded.fetch!("https://api.openai.com/v1/responses")
|
||||||
|
|
||||||
|
await waitFor(() => refreshRequests === 1)
|
||||||
|
expect(apiRequests).toHaveLength(0)
|
||||||
|
|
||||||
|
resolveRefresh!()
|
||||||
|
await Promise.all([first, second])
|
||||||
|
|
||||||
|
expect(refreshRequests).toBe(1)
|
||||||
|
expect(authUpdates).toHaveLength(1)
|
||||||
|
expect(authUpdates[0]?.body.refresh).toBe("refresh-new")
|
||||||
|
expect(authUpdates[0]?.body.access).toBe("access-new")
|
||||||
|
expect(authUpdates[0]?.body.accountId).toBe("acc-123")
|
||||||
|
expect(apiRequests).toEqual([
|
||||||
|
{ authorization: "Bearer access-new", accountId: "acc-123" },
|
||||||
|
{ authorization: "Bearer access-new", accountId: "acc-123" },
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function waitFor(predicate: () => boolean) {
|
||||||
|
const started = Date.now()
|
||||||
|
while (!predicate()) {
|
||||||
|
if (Date.now() - started > 1_000) throw new Error("timed out waiting for condition")
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ function rememberEnv(k: string) {
|
|||||||
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
|
if (!originalEnv.has(k)) originalEnv.set(k, process.env[k])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setProcessEnv = (k: string, v: string) =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
rememberEnv(k)
|
||||||
|
process.env[k] = v
|
||||||
|
})
|
||||||
|
|
||||||
const set = (ctx: InstanceContext, k: string, v: string) => {
|
const set = (ctx: InstanceContext, k: string, v: string) => {
|
||||||
rememberEnv(k)
|
rememberEnv(k)
|
||||||
process.env[k] = v
|
process.env[k] = v
|
||||||
@@ -81,10 +87,6 @@ async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceC
|
|||||||
return run(ctx, (provider) => provider.getModel(providerID, modelID))
|
return run(ctx, (provider) => provider.getModel(providerID, modelID))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLanguage(model: Provider.Model, ctx: InstanceContext) {
|
|
||||||
return run(ctx, (provider) => provider.getLanguage(model))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function closest(providerID: ProviderID, query: string[], ctx: InstanceContext) {
|
async function closest(providerID: ProviderID, query: string[], ctx: InstanceContext) {
|
||||||
return run(ctx, (provider) => provider.closest(providerID, query))
|
return run(ctx, (provider) => provider.closest(providerID, query))
|
||||||
}
|
}
|
||||||
@@ -93,10 +95,6 @@ async function getSmallModel(providerID: ProviderID, ctx: InstanceContext) {
|
|||||||
return run(ctx, (provider) => provider.getSmallModel(providerID))
|
return run(ctx, (provider) => provider.getSmallModel(providerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function defaultModel(ctx: InstanceContext) {
|
|
||||||
return run(ctx, (provider) => provider.defaultModel())
|
|
||||||
}
|
|
||||||
|
|
||||||
function paid(providers: Awaited<ReturnType<typeof list>>) {
|
function paid(providers: Awaited<ReturnType<typeof list>>) {
|
||||||
const item = providers[ProviderID.make("opencode")]
|
const item = providers[ProviderID.make("opencode")]
|
||||||
expect(item).toBeDefined()
|
expect(item).toBeDefined()
|
||||||
@@ -128,38 +126,26 @@ const alphaProviderConfig = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
test("provider loaded from env variable", async () => {
|
it.instance("provider loaded from env variable", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
await Bun.write(
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
|
||||||
const providers = await list(ctx)
|
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
// Provider should retain its connection source even if custom loaders
|
// Provider should retain its connection source even if custom loaders
|
||||||
// merge additional options.
|
// merge additional options.
|
||||||
expect(providers[ProviderID.anthropic].source).toBe("env")
|
expect(providers[ProviderID.anthropic].source).toBe("env")
|
||||||
expect(providers[ProviderID.anthropic].options.headers["anthropic-beta"]).toBeDefined()
|
expect(providers[ProviderID.anthropic].options.headers["anthropic-beta"]).toBeDefined()
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("provider loaded from config with apiKey option", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"provider loaded from config with apiKey option",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await Bun.write(
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
path.join(dir, "opencode.json"),
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
JSON.stringify({
|
}),
|
||||||
$schema: "https://opencode.ai/config.json",
|
{
|
||||||
|
config: {
|
||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
options: {
|
options: {
|
||||||
@@ -167,129 +153,84 @@ test("provider loaded from config with apiKey option", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const providers = await list(ctx)
|
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("disabled_providers excludes provider", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"disabled_providers excludes provider",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await Bun.write(
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
path.join(dir, "opencode.json"),
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
disabled_providers: ["anthropic"],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
|
||||||
const providers = await list(ctx)
|
|
||||||
expect(providers[ProviderID.anthropic]).toBeUndefined()
|
expect(providers[ProviderID.anthropic]).toBeUndefined()
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("enabled_providers restricts to only listed providers", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
enabled_providers: ["anthropic"],
|
|
||||||
}),
|
}),
|
||||||
)
|
{ config: { disabled_providers: ["anthropic"] } },
|
||||||
},
|
)
|
||||||
})
|
|
||||||
await withTestInstance({
|
it.instance(
|
||||||
directory: tmp.path,
|
"enabled_providers restricts to only listed providers",
|
||||||
fn: async (ctx) => {
|
Effect.gen(function* () {
|
||||||
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
await set(ctx, "OPENAI_API_KEY", "test-openai-key")
|
yield* setProcessEnv("OPENAI_API_KEY", "test-openai-key")
|
||||||
const providers = await list(ctx)
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
expect(providers[ProviderID.openai]).toBeUndefined()
|
expect(providers[ProviderID.openai]).toBeUndefined()
|
||||||
},
|
}),
|
||||||
})
|
{ config: { enabled_providers: ["anthropic"] } },
|
||||||
})
|
)
|
||||||
|
|
||||||
test("model whitelist filters models for provider", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"model whitelist filters models for provider",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await Bun.write(
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
path.join(dir, "opencode.json"),
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
JSON.stringify({
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
$schema: "https://opencode.ai/config.json",
|
const models = Object.keys(providers[ProviderID.anthropic].models)
|
||||||
|
expect(models).toContain("claude-sonnet-4-20250514")
|
||||||
|
expect(models.length).toBe(1)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
whitelist: ["claude-sonnet-4-20250514"],
|
whitelist: ["claude-sonnet-4-20250514"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
},
|
||||||
await withTestInstance({
|
)
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
it.instance(
|
||||||
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
"model blacklist excludes specific models",
|
||||||
const providers = await list(ctx)
|
Effect.gen(function* () {
|
||||||
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
const models = Object.keys(providers[ProviderID.anthropic].models)
|
const models = Object.keys(providers[ProviderID.anthropic].models)
|
||||||
expect(models).toContain("claude-sonnet-4-20250514")
|
expect(models).not.toContain("claude-sonnet-4-20250514")
|
||||||
expect(models.length).toBe(1)
|
}),
|
||||||
},
|
{
|
||||||
})
|
config: {
|
||||||
})
|
|
||||||
|
|
||||||
test("model blacklist excludes specific models", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
blacklist: ["claude-sonnet-4-20250514"],
|
blacklist: ["claude-sonnet-4-20250514"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
|
||||||
const providers = await list(ctx)
|
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
|
||||||
const models = Object.keys(providers[ProviderID.anthropic].models)
|
|
||||||
expect(models).not.toContain("claude-sonnet-4-20250514")
|
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("custom model alias via config", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"custom model alias via config",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await Bun.write(
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
path.join(dir, "opencode.json"),
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
JSON.stringify({
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
$schema: "https://opencode.ai/config.json",
|
expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
|
||||||
|
expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
models: {
|
models: {
|
||||||
@@ -300,29 +241,20 @@ test("custom model alias via config", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
|
||||||
const providers = await list(ctx)
|
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
|
||||||
expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
|
|
||||||
expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
|
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("custom provider with npm package", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"custom provider with npm package",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await Bun.write(
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
path.join(dir, "opencode.json"),
|
expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
|
||||||
JSON.stringify({
|
expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
|
||||||
$schema: "https://opencode.ai/config.json",
|
expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined()
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
provider: {
|
provider: {
|
||||||
"custom-provider": {
|
"custom-provider": {
|
||||||
name: "Custom Provider",
|
name: "Custom Provider",
|
||||||
@@ -344,20 +276,9 @@ test("custom provider with npm package", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const providers = await list(ctx)
|
|
||||||
expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
|
|
||||||
expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
|
|
||||||
expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined()
|
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
"filters alpha provider models by default",
|
"filters alpha provider models by default",
|
||||||
@@ -379,13 +300,20 @@ experimentalModels.instance(
|
|||||||
{ config: alphaProviderConfig },
|
{ config: alphaProviderConfig },
|
||||||
)
|
)
|
||||||
|
|
||||||
test("custom DeepSeek openai-compatible model defaults interleaved reasoning field", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"custom DeepSeek openai-compatible model defaults interleaved reasoning field",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await Bun.write(
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
path.join(dir, "opencode.json"),
|
const provider = providers[ProviderID.make("custom-provider")]
|
||||||
JSON.stringify({
|
expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
|
||||||
$schema: "https://opencode.ai/config.json",
|
expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" })
|
||||||
|
expect(provider.models["custom-model"].capabilities.interleaved).toBe(false)
|
||||||
|
expect(providers[ProviderID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
provider: {
|
provider: {
|
||||||
"custom-provider": {
|
"custom-provider": {
|
||||||
name: "Custom Provider",
|
name: "Custom Provider",
|
||||||
@@ -421,32 +349,22 @@ test("custom DeepSeek openai-compatible model defaults interleaved reasoning fie
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
const providers = await list(ctx)
|
|
||||||
const provider = providers[ProviderID.make("custom-provider")]
|
|
||||||
expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" })
|
|
||||||
expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" })
|
|
||||||
expect(provider.models["custom-model"].capabilities.interleaved).toBe(false)
|
|
||||||
expect(
|
|
||||||
providers[ProviderID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved,
|
|
||||||
).toBe(false)
|
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("env variable takes precedence, config merges options", async () => {
|
it.instance(
|
||||||
await using tmp = await tmpdir({
|
"env variable takes precedence, config merges options",
|
||||||
init: async (dir) => {
|
Effect.gen(function* () {
|
||||||
await Bun.write(
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "env-api-key")
|
||||||
path.join(dir, "opencode.json"),
|
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||||
JSON.stringify({
|
expect(providers[ProviderID.anthropic]).toBeDefined()
|
||||||
$schema: "https://opencode.ai/config.json",
|
// Config options should be merged
|
||||||
|
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
|
||||||
|
expect(providers[ProviderID.anthropic].options.chunkTimeout).toBe(15000)
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
config: {
|
||||||
provider: {
|
provider: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
options: {
|
options: {
|
||||||
@@ -455,47 +373,22 @@ test("env variable takes precedence, config merges options", async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
await set(ctx, "ANTHROPIC_API_KEY", "env-api-key")
|
|
||||||
const providers = await list(ctx)
|
|
||||||
expect(providers[ProviderID.anthropic]).toBeDefined()
|
|
||||||
// Config options should be merged
|
|
||||||
expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
|
|
||||||
expect(providers[ProviderID.anthropic].options.chunkTimeout).toBe(15000)
|
|
||||||
},
|
},
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("getModel returns model for valid provider/model", async () => {
|
it.instance("getModel returns model for valid provider/model", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
await Bun.write(
|
const provider = yield* Provider.Service
|
||||||
path.join(dir, "opencode.json"),
|
const model = yield* provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"))
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
|
||||||
const model = await getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514"), ctx)
|
|
||||||
expect(model).toBeDefined()
|
expect(model).toBeDefined()
|
||||||
expect(String(model.providerID)).toBe("anthropic")
|
expect(String(model.providerID)).toBe("anthropic")
|
||||||
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
|
expect(String(model.id)).toBe("claude-sonnet-4-20250514")
|
||||||
const language = await getLanguage(model, ctx)
|
const language = yield* provider.getLanguage(model)
|
||||||
expect(language).toBeDefined()
|
expect(language).toBeDefined()
|
||||||
},
|
}),
|
||||||
})
|
)
|
||||||
})
|
|
||||||
|
|
||||||
test("getModel throws ModelNotFoundError for invalid model", async () => {
|
test("getModel throws ModelNotFoundError for invalid model", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
@@ -548,50 +441,25 @@ test("parseModel handles model IDs with slashes", () => {
|
|||||||
expect(String(result.modelID)).toBe("anthropic/claude-3-opus")
|
expect(String(result.modelID)).toBe("anthropic/claude-3-opus")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("defaultModel returns first available model when no config set", async () => {
|
it.instance("defaultModel returns first available model when no config set", () =>
|
||||||
await using tmp = await tmpdir({
|
Effect.gen(function* () {
|
||||||
init: async (dir) => {
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
await Bun.write(
|
const model = yield* Provider.Service.use((provider) => provider.defaultModel())
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await withTestInstance({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async (ctx) => {
|
|
||||||
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
|
||||||
const model = await defaultModel(ctx)
|
|
||||||
expect(model.providerID).toBeDefined()
|
expect(model.providerID).toBeDefined()
|
||||||
expect(model.modelID).toBeDefined()
|
expect(model.modelID).toBeDefined()
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("defaultModel respects config model setting", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
model: "anthropic/claude-sonnet-4-20250514",
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
|
||||||
})
|
it.instance(
|
||||||
await withTestInstance({
|
"defaultModel respects config model setting",
|
||||||
directory: tmp.path,
|
Effect.gen(function* () {
|
||||||
fn: async (ctx) => {
|
yield* setProcessEnv("ANTHROPIC_API_KEY", "test-api-key")
|
||||||
await set(ctx, "ANTHROPIC_API_KEY", "test-api-key")
|
const model = yield* Provider.Service.use((provider) => provider.defaultModel())
|
||||||
const model = await defaultModel(ctx)
|
|
||||||
expect(String(model.providerID)).toBe("anthropic")
|
expect(String(model.providerID)).toBe("anthropic")
|
||||||
expect(String(model.modelID)).toBe("claude-sonnet-4-20250514")
|
expect(String(model.modelID)).toBe("claude-sonnet-4-20250514")
|
||||||
},
|
}),
|
||||||
})
|
{ config: { model: "anthropic/claude-sonnet-4-20250514" } },
|
||||||
})
|
)
|
||||||
|
|
||||||
it.instance(
|
it.instance(
|
||||||
"provider with baseURL from config",
|
"provider with baseURL from config",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { TestConfig } from "../fixture/config"
|
|||||||
import { SyncEvent } from "@/sync"
|
import { SyncEvent } from "@/sync"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||||
|
import { LLMEvent, Usage } from "@opencode-ai/llm"
|
||||||
|
|
||||||
void Log.init({ print: false })
|
void Log.init({ print: false })
|
||||||
|
|
||||||
@@ -47,6 +48,10 @@ const ref = {
|
|||||||
modelID: ModelID.make("test-model"),
|
modelID: ModelID.make("test-model"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const usage = (input: ConstructorParameters<typeof Usage>[0]) => new Usage(input)
|
||||||
|
|
||||||
|
const basicUsage = () => usage({ inputTokens: 1, outputTokens: 1, totalTokens: 2 })
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
})
|
})
|
||||||
@@ -296,11 +301,11 @@ function readCompactionPart(sessionID: SessionID) {
|
|||||||
|
|
||||||
function llm() {
|
function llm() {
|
||||||
const queue: Array<
|
const queue: Array<
|
||||||
Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
|
Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)
|
||||||
> = []
|
> = []
|
||||||
|
|
||||||
return {
|
return {
|
||||||
push(stream: Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)) {
|
push(stream: Stream.Stream<LLMEvent, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown>)) {
|
||||||
queue.push(stream)
|
queue.push(stream)
|
||||||
},
|
},
|
||||||
layer: Layer.succeed(
|
layer: Layer.succeed(
|
||||||
@@ -319,54 +324,22 @@ function llm() {
|
|||||||
function reply(
|
function reply(
|
||||||
text: string,
|
text: string,
|
||||||
capture?: (input: LLM.StreamInput) => void,
|
capture?: (input: LLM.StreamInput) => void,
|
||||||
): (input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown> {
|
): (input: LLM.StreamInput) => Stream.Stream<LLMEvent, unknown> {
|
||||||
return (input) => {
|
return (input) => {
|
||||||
capture?.(input)
|
capture?.(input)
|
||||||
return Stream.make(
|
return Stream.make(
|
||||||
{ type: "start" } satisfies LLM.Event,
|
LLMEvent.textStart({ id: "txt-0" }),
|
||||||
{ type: "text-start", id: "txt-0" } satisfies LLM.Event,
|
LLMEvent.textDelta({ id: "txt-0", text }),
|
||||||
{ type: "text-delta", id: "txt-0", delta: text, text } as LLM.Event,
|
LLMEvent.textEnd({ id: "txt-0" }),
|
||||||
{ type: "text-end", id: "txt-0" } satisfies LLM.Event,
|
LLMEvent.stepFinish({
|
||||||
{
|
index: 0,
|
||||||
type: "finish-step",
|
reason: "stop",
|
||||||
finishReason: "stop",
|
usage: basicUsage(),
|
||||||
rawFinishReason: "stop",
|
}),
|
||||||
response: { id: "res", modelId: "test-model", timestamp: new Date() },
|
LLMEvent.finish({
|
||||||
providerMetadata: undefined,
|
reason: "stop",
|
||||||
usage: {
|
usage: basicUsage(),
|
||||||
inputTokens: 1,
|
}),
|
||||||
outputTokens: 1,
|
|
||||||
totalTokens: 2,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies LLM.Event,
|
|
||||||
{
|
|
||||||
type: "finish",
|
|
||||||
finishReason: "stop",
|
|
||||||
rawFinishReason: "stop",
|
|
||||||
totalUsage: {
|
|
||||||
inputTokens: 1,
|
|
||||||
outputTokens: 1,
|
|
||||||
totalTokens: 2,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies LLM.Event,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1204,7 +1177,7 @@ describe("session.compaction.process", () => {
|
|||||||
Stream.fromAsyncIterable(
|
Stream.fromAsyncIterable(
|
||||||
{
|
{
|
||||||
async *[Symbol.asyncIterator]() {
|
async *[Symbol.asyncIterator]() {
|
||||||
yield { type: "start" } as LLM.Event
|
yield LLMEvent.stepStart({ index: 0 })
|
||||||
throw new APICallError({
|
throw new APICallError({
|
||||||
message: "boom",
|
message: "boom",
|
||||||
url: "https://example.com/v1/chat/completions",
|
url: "https://example.com/v1/chat/completions",
|
||||||
@@ -1290,55 +1263,62 @@ describe("session.compaction.process", () => {
|
|||||||
{ git: true },
|
{ git: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
itCompaction.instance(
|
||||||
|
"silently drops reasoning-delta arriving without prior reasoning-start",
|
||||||
|
() => {
|
||||||
|
// Regression: PR initially auto-created a reasoning Part for orphan deltas (no preceding
|
||||||
|
// reasoning-start). Reverted to match dev — drop silently. Pinned here so any future
|
||||||
|
// change to processor.ts reasoning-delta handling triggers this test.
|
||||||
|
const stub = llm()
|
||||||
|
stub.push(
|
||||||
|
Stream.make(
|
||||||
|
LLMEvent.reasoningDelta({ id: "orphan-1", text: "stray reasoning" }),
|
||||||
|
LLMEvent.textStart({ id: "txt-0" }),
|
||||||
|
LLMEvent.textDelta({ id: "txt-0", text: "summary" }),
|
||||||
|
LLMEvent.textEnd({ id: "txt-0" }),
|
||||||
|
LLMEvent.stepFinish({ index: 0, reason: "stop", usage: basicUsage() }),
|
||||||
|
LLMEvent.finish({ reason: "stop", usage: basicUsage() }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const ssn = yield* SessionNs.Service
|
||||||
|
const session = yield* ssn.create({})
|
||||||
|
const msg = yield* createUserMessage(session.id, "hello")
|
||||||
|
const msgs = yield* ssn.messages({ sessionID: session.id })
|
||||||
|
yield* SessionCompaction.use.process({
|
||||||
|
parentID: msg.id,
|
||||||
|
messages: msgs,
|
||||||
|
sessionID: session.id,
|
||||||
|
auto: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const summary = (yield* ssn.messages({ sessionID: session.id })).find(
|
||||||
|
(item) => item.info.role === "assistant" && item.info.summary,
|
||||||
|
)
|
||||||
|
expect(summary?.parts.some((part) => part.type === "reasoning")).toBe(false)
|
||||||
|
// Sanity: the text part still got through.
|
||||||
|
expect(summary?.parts.some((part) => part.type === "text" && part.text === "summary")).toBe(true)
|
||||||
|
}).pipe(withCompaction({ llm: stub.layer }))
|
||||||
|
},
|
||||||
|
{ git: true },
|
||||||
|
)
|
||||||
|
|
||||||
itCompaction.instance(
|
itCompaction.instance(
|
||||||
"does not allow tool calls while generating the summary",
|
"does not allow tool calls while generating the summary",
|
||||||
() => {
|
() => {
|
||||||
const stub = llm()
|
const stub = llm()
|
||||||
stub.push(
|
stub.push(
|
||||||
Stream.make(
|
Stream.make(
|
||||||
{ type: "start" } satisfies LLM.Event,
|
LLMEvent.toolCall({ id: "call-1", name: "_noop", input: {} }),
|
||||||
{ type: "tool-input-start", id: "call-1", toolName: "_noop" } satisfies LLM.Event,
|
LLMEvent.stepFinish({
|
||||||
{ type: "tool-call", toolCallId: "call-1", toolName: "_noop", input: {} } satisfies LLM.Event,
|
index: 0,
|
||||||
{
|
reason: "tool-calls",
|
||||||
type: "finish-step",
|
usage: basicUsage(),
|
||||||
finishReason: "tool-calls",
|
}),
|
||||||
rawFinishReason: "tool_calls",
|
LLMEvent.finish({
|
||||||
response: { id: "res", modelId: "test-model", timestamp: new Date() },
|
reason: "tool-calls",
|
||||||
providerMetadata: undefined,
|
usage: basicUsage(),
|
||||||
usage: {
|
}),
|
||||||
inputTokens: 1,
|
|
||||||
outputTokens: 1,
|
|
||||||
totalTokens: 2,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies LLM.Event,
|
|
||||||
{
|
|
||||||
type: "finish",
|
|
||||||
finishReason: "tool-calls",
|
|
||||||
rawFinishReason: "tool_calls",
|
|
||||||
totalUsage: {
|
|
||||||
inputTokens: 1,
|
|
||||||
outputTokens: 1,
|
|
||||||
totalTokens: 2,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies LLM.Event,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
@@ -1544,20 +1524,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
const model = createModel({ context: 100_000, output: 32_000 })
|
const model = createModel({ context: 100_000, output: 32_000 })
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
|
||||||
inputTokens: 1000,
|
|
||||||
outputTokens: 500,
|
|
||||||
totalTokens: 1500,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.tokens.input).toBe(1000)
|
expect(result.tokens.input).toBe(1000)
|
||||||
@@ -1571,20 +1538,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
const model = createModel({ context: 100_000, output: 32_000 })
|
const model = createModel({ context: 100_000, output: 32_000 })
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
|
||||||
inputTokens: 1000,
|
|
||||||
outputTokens: 500,
|
|
||||||
totalTokens: 1500,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: 800,
|
|
||||||
cacheReadTokens: 200,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.tokens.input).toBe(800)
|
expect(result.tokens.input).toBe(800)
|
||||||
@@ -1595,20 +1549,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
const model = createModel({ context: 100_000, output: 32_000 })
|
const model = createModel({ context: 100_000, output: 32_000 })
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }),
|
||||||
inputTokens: 1000,
|
|
||||||
outputTokens: 500,
|
|
||||||
totalTokens: 1500,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: {
|
metadata: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
cacheCreationInputTokens: 300,
|
cacheCreationInputTokens: 300,
|
||||||
@@ -1624,20 +1565,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
// AI SDK v6 normalizes inputTokens to include cached tokens for all providers
|
// AI SDK v6 normalizes inputTokens to include cached tokens for all providers
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
|
||||||
inputTokens: 1000,
|
|
||||||
outputTokens: 500,
|
|
||||||
totalTokens: 1500,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: 800,
|
|
||||||
cacheReadTokens: 200,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: {
|
metadata: {
|
||||||
anthropic: {},
|
anthropic: {},
|
||||||
},
|
},
|
||||||
@@ -1651,20 +1579,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
const model = createModel({ context: 100_000, output: 32_000 })
|
const model = createModel({ context: 100_000, output: 32_000 })
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 1000, outputTokens: 500, reasoningTokens: 100, totalTokens: 1500 }),
|
||||||
inputTokens: 1000,
|
|
||||||
outputTokens: 500,
|
|
||||||
totalTokens: 1500,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: 400,
|
|
||||||
reasoningTokens: 100,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.tokens.input).toBe(1000)
|
expect(result.tokens.input).toBe(1000)
|
||||||
@@ -1685,20 +1600,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
})
|
})
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 0, outputTokens: 1_000_000, reasoningTokens: 250_000, totalTokens: 1_000_000 }),
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 1_000_000,
|
|
||||||
totalTokens: 1_000_000,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: 750_000,
|
|
||||||
reasoningTokens: 250_000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.tokens.output).toBe(750_000)
|
expect(result.tokens.output).toBe(750_000)
|
||||||
@@ -1710,20 +1612,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
const model = createModel({ context: 100_000, output: 32_000 })
|
const model = createModel({ context: 100_000, output: 32_000 })
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 0, outputTokens: 0, totalTokens: 0 }),
|
||||||
inputTokens: 0,
|
|
||||||
outputTokens: 0,
|
|
||||||
totalTokens: 0,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.tokens.input).toBe(0)
|
expect(result.tokens.input).toBe(0)
|
||||||
@@ -1746,20 +1635,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
})
|
})
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }),
|
||||||
inputTokens: 1_000_000,
|
|
||||||
outputTokens: 100_000,
|
|
||||||
totalTokens: 1_100_000,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.cost).toBe(3 + 1.5)
|
expect(result.cost).toBe(3 + 1.5)
|
||||||
@@ -1796,20 +1672,12 @@ describe("SessionNs.getUsage", () => {
|
|||||||
})
|
})
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({
|
||||||
inputTokens: 650_000,
|
inputTokens: 650_000,
|
||||||
outputTokens: 100_000,
|
outputTokens: 100_000,
|
||||||
totalTokens: 750_000,
|
totalTokens: 750_000,
|
||||||
inputTokenDetails: {
|
cacheReadInputTokens: 100_000,
|
||||||
noCacheTokens: undefined,
|
}),
|
||||||
cacheReadTokens: 100_000,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.tokens.input).toBe(550_000)
|
expect(result.tokens.input).toBe(550_000)
|
||||||
@@ -1841,20 +1709,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
})
|
})
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 300_000, outputTokens: 100_000, totalTokens: 400_000 }),
|
||||||
inputTokens: 300_000,
|
|
||||||
outputTokens: 100_000,
|
|
||||||
totalTokens: 400_000,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: undefined,
|
|
||||||
cacheReadTokens: undefined,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(result.cost).toBe(0.9 + 0.4)
|
expect(result.cost).toBe(0.9 + 0.4)
|
||||||
@@ -1865,24 +1720,16 @@ describe("SessionNs.getUsage", () => {
|
|||||||
(npm) => {
|
(npm) => {
|
||||||
const model = createModel({ context: 100_000, output: 32_000, npm })
|
const model = createModel({ context: 100_000, output: 32_000, npm })
|
||||||
// AI SDK v6: inputTokens includes cached tokens for all providers
|
// AI SDK v6: inputTokens includes cached tokens for all providers
|
||||||
const usage = {
|
const item = usage({
|
||||||
inputTokens: 1000,
|
inputTokens: 1000,
|
||||||
outputTokens: 500,
|
outputTokens: 500,
|
||||||
totalTokens: 1500,
|
totalTokens: 1500,
|
||||||
inputTokenDetails: {
|
cacheReadInputTokens: 200,
|
||||||
noCacheTokens: 800,
|
})
|
||||||
cacheReadTokens: 200,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if (npm === "@ai-sdk/amazon-bedrock") {
|
if (npm === "@ai-sdk/amazon-bedrock") {
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage,
|
usage: item,
|
||||||
metadata: {
|
metadata: {
|
||||||
bedrock: {
|
bedrock: {
|
||||||
usage: {
|
usage: {
|
||||||
@@ -1903,7 +1750,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
|
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage,
|
usage: item,
|
||||||
metadata: {
|
metadata: {
|
||||||
anthropic: {
|
anthropic: {
|
||||||
cacheCreationInputTokens: 300,
|
cacheCreationInputTokens: 300,
|
||||||
@@ -1924,20 +1771,7 @@ describe("SessionNs.getUsage", () => {
|
|||||||
const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
|
const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
|
||||||
const result = SessionNs.getUsage({
|
const result = SessionNs.getUsage({
|
||||||
model,
|
model,
|
||||||
usage: {
|
usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }),
|
||||||
inputTokens: 1000,
|
|
||||||
outputTokens: 500,
|
|
||||||
totalTokens: 1500,
|
|
||||||
inputTokenDetails: {
|
|
||||||
noCacheTokens: 800,
|
|
||||||
cacheReadTokens: 200,
|
|
||||||
cacheWriteTokens: undefined,
|
|
||||||
},
|
|
||||||
outputTokenDetails: {
|
|
||||||
textTokens: undefined,
|
|
||||||
reasoningTokens: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metadata: {
|
metadata: {
|
||||||
vertex: {
|
vertex: {
|
||||||
cacheCreationInputTokens: 300,
|
cacheCreationInputTokens: 300,
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
|
import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder"
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { tool } from "ai"
|
||||||
|
import { Effect, Layer, Stream } from "effect"
|
||||||
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
|
import path from "node:path"
|
||||||
|
import z from "zod"
|
||||||
|
import { Auth } from "@/auth"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
|
import { Plugin } from "@/plugin"
|
||||||
|
import { Provider } from "@/provider/provider"
|
||||||
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
|
import { Filesystem } from "@/util/filesystem"
|
||||||
|
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||||
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
import type { Agent } from "../../src/agent/agent"
|
||||||
|
import { LLM } from "../../src/session/llm"
|
||||||
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
|
import { MessageID, SessionID } from "../../src/session/schema"
|
||||||
|
import type { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||||
|
import { TestInstance } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
const OPENAI_CASSETTE = "session/native-openai-tool-call"
|
||||||
|
const ZEN_CASSETTE = "session/native-zen-tool-call"
|
||||||
|
const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings")
|
||||||
|
const OPENAI_API_KEY = process.env.OPENCODE_RECORD_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY
|
||||||
|
const CONSOLE_TOKEN = process.env.OPENCODE_RECORD_CONSOLE_TOKEN
|
||||||
|
const ZEN_ORG_ID = process.env.OPENCODE_RECORD_ZEN_ORG_ID
|
||||||
|
const ZEN_API_URL =
|
||||||
|
process.env.OPENCODE_RECORD_ZEN_API_URL ?? "https://console.opencode.ai/proxy/connections/fixture/v1"
|
||||||
|
|
||||||
|
const shouldRecord = process.env.RECORD === "true"
|
||||||
|
const canRunOpenAI = shouldRecord
|
||||||
|
? Boolean(OPENAI_API_KEY)
|
||||||
|
: HttpRecorder.hasCassetteSync(OPENAI_CASSETTE, { directory: FIXTURES_DIR })
|
||||||
|
const canRunZen = shouldRecord
|
||||||
|
? Boolean(CONSOLE_TOKEN && ZEN_ORG_ID)
|
||||||
|
: HttpRecorder.hasCassetteSync(ZEN_CASSETTE, { directory: FIXTURES_DIR })
|
||||||
|
|
||||||
|
async function loadFixture(providerID: string, modelID: string) {
|
||||||
|
const data = await Filesystem.readJson<Record<string, ModelsDev.Provider>>(
|
||||||
|
path.join(import.meta.dir, "../tool/fixtures/models-api.json"),
|
||||||
|
)
|
||||||
|
const provider = data[providerID]
|
||||||
|
if (!provider) throw new Error(`Missing provider in fixture: ${providerID}`)
|
||||||
|
const model = provider.models[modelID]
|
||||||
|
if (!model) throw new Error(`Missing model in fixture: ${modelID}`)
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAIConfig = (model: ModelsDev.Provider["models"][string]): Partial<Config.Info> => ({
|
||||||
|
enabled_providers: ["openai"],
|
||||||
|
provider: {
|
||||||
|
openai: {
|
||||||
|
name: "OpenAI",
|
||||||
|
env: ["OPENAI_API_KEY"],
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
api: "https://api.openai.com/v1",
|
||||||
|
models: {
|
||||||
|
[model.id]: JSON.parse(JSON.stringify(model)) as NonNullable<
|
||||||
|
NonNullable<Config.Info["provider"]>[string]["models"]
|
||||||
|
>[string],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
apiKey: OPENAI_API_KEY ?? "fixture-openai-key",
|
||||||
|
baseURL: "https://api.openai.com/v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const zenConfig = (model: ModelsDev.Provider["models"][string]): Partial<Config.Info> => ({
|
||||||
|
enabled_providers: ["opencode"],
|
||||||
|
provider: {
|
||||||
|
opencode: {
|
||||||
|
name: "OpenCode Zen",
|
||||||
|
env: ["OPENCODE_CONSOLE_TOKEN"],
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
api: ZEN_API_URL,
|
||||||
|
models: {
|
||||||
|
[model.id]: JSON.parse(JSON.stringify(model)) as NonNullable<
|
||||||
|
NonNullable<Config.Info["provider"]>[string]["models"]
|
||||||
|
>[string],
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
apiKey: CONSOLE_TOKEN ?? "fixture-console-token",
|
||||||
|
headers: {
|
||||||
|
"x-org-id": ZEN_ORG_ID ?? "fixture-org",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function recordedNativeLLMLayer(cassette: string, metadata: Record<string, unknown>) {
|
||||||
|
const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe(
|
||||||
|
Layer.provide(NodeFileSystem.layer),
|
||||||
|
)
|
||||||
|
// Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real.
|
||||||
|
const recorder = HttpRecorder.recordingLayer(cassette, {
|
||||||
|
mode: shouldRecord ? "record" : "replay",
|
||||||
|
metadata,
|
||||||
|
redactor: Redactor.compose(
|
||||||
|
Redactor.defaults({
|
||||||
|
url: {
|
||||||
|
transform: (url) => url.replace(/\/proxy\/connections\/[^/]+\/v1/, "/proxy/connections/{connection}/v1"),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
response: (snapshot) => ({ ...snapshot, body: snapshot.body.replace(/wrk_[A-Z0-9]+/g, "wrk_redacted") }),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}).pipe(Layer.provide(FetchHttpClient.layer))
|
||||||
|
const executor = RequestExecutor.layer.pipe(Layer.provide(recorder))
|
||||||
|
const client = LLMClient.layer.pipe(Layer.provide(executor))
|
||||||
|
|
||||||
|
const providerLayer = Provider.defaultLayer.pipe(
|
||||||
|
Layer.provide(Auth.defaultLayer),
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(Plugin.defaultLayer),
|
||||||
|
)
|
||||||
|
const llmLayer = LLM.layer.pipe(
|
||||||
|
Layer.provide(Auth.defaultLayer),
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(Provider.defaultLayer),
|
||||||
|
Layer.provide(Plugin.defaultLayer),
|
||||||
|
Layer.provide(client),
|
||||||
|
Layer.provide(cassetteService),
|
||||||
|
Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: true })),
|
||||||
|
)
|
||||||
|
|
||||||
|
return Layer.mergeAll(providerLayer, llmLayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAIIt = testEffect(
|
||||||
|
recordedNativeLLMLayer(OPENAI_CASSETTE, {
|
||||||
|
provider: "openai",
|
||||||
|
protocol: "openai-responses",
|
||||||
|
route: "openai-responses",
|
||||||
|
tags: ["opencode", "native", "tool-call"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const zenIt = testEffect(
|
||||||
|
recordedNativeLLMLayer(ZEN_CASSETTE, {
|
||||||
|
provider: "opencode",
|
||||||
|
protocol: "openai-responses",
|
||||||
|
route: "openai-responses",
|
||||||
|
tags: ["opencode", "zen", "native", "tool-call"],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const recordedOpenAIInstance = canRunOpenAI ? openAIIt.instance : openAIIt.instance.skip
|
||||||
|
const recordedZenInstance = canRunZen ? zenIt.instance : zenIt.instance.skip
|
||||||
|
|
||||||
|
const writeConfig = (
|
||||||
|
directory: string,
|
||||||
|
model: ModelsDev.Provider["models"][string],
|
||||||
|
config: (model: ModelsDev.Provider["models"][string]) => Partial<Config.Info> = openAIConfig,
|
||||||
|
) =>
|
||||||
|
Effect.promise(() =>
|
||||||
|
Bun.write(
|
||||||
|
path.join(directory, "opencode.json"),
|
||||||
|
JSON.stringify({ $schema: "https://opencode.ai/config.json", ...config(model) }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const getModel = (providerID: ProviderID, modelID: ModelID) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const provider = yield* Provider.Service
|
||||||
|
return yield* provider.getModel(providerID, modelID)
|
||||||
|
})
|
||||||
|
|
||||||
|
const collect = (input: LLM.StreamInput) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const llm = yield* LLM.Service
|
||||||
|
return Array.from(yield* llm.stream(input).pipe(Stream.runCollect))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("session.llm native recorded", () => {
|
||||||
|
recordedOpenAIInstance("uses real RequestExecutor with HTTP recorder for native OpenAI tools", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const test = yield* TestInstance
|
||||||
|
const model = yield* Effect.promise(() => loadFixture("openai", "gpt-4.1-mini"))
|
||||||
|
yield* writeConfig(test.directory, model)
|
||||||
|
|
||||||
|
const sessionID = SessionID.make("session-recorded-native-tool")
|
||||||
|
const agent = {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
prompt: "Call tools exactly as instructed.",
|
||||||
|
options: {},
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||||
|
temperature: 0,
|
||||||
|
} satisfies Agent.Info
|
||||||
|
const resolved = yield* getModel(ProviderID.openai, ModelID.make(model.id))
|
||||||
|
let executed: unknown
|
||||||
|
|
||||||
|
const events = yield* collect({
|
||||||
|
user: {
|
||||||
|
id: MessageID.make("msg_user-recorded-native-tool"),
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 0 },
|
||||||
|
agent: agent.name,
|
||||||
|
model: { providerID: ProviderID.make("openai"), modelID: ModelID.make(model.id) },
|
||||||
|
} satisfies MessageV2.User,
|
||||||
|
sessionID,
|
||||||
|
model: resolved,
|
||||||
|
agent,
|
||||||
|
system: ["You must call the lookup tool exactly once with query weather. Do not answer in text."],
|
||||||
|
messages: [{ role: "user", content: "Use lookup." }],
|
||||||
|
toolChoice: "required",
|
||||||
|
tools: {
|
||||||
|
lookup: tool({
|
||||||
|
description: "Lookup data.",
|
||||||
|
inputSchema: z.object({ query: z.string() }),
|
||||||
|
execute: async (args, options) => {
|
||||||
|
executed = { args, toolCallId: options.toolCallId }
|
||||||
|
return { output: "looked up" }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(events.filter((event) => event.type === "step-finish")).toHaveLength(1)
|
||||||
|
expect(events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||||
|
expect(events.some((event) => event.type === "tool-result")).toBe(true)
|
||||||
|
expect(executed).toMatchObject({ args: { query: "weather" }, toolCallId: expect.any(String) })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
recordedZenInstance("uses console-managed Zen config with native OpenAI-compatible tools", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const test = yield* TestInstance
|
||||||
|
const model = yield* Effect.promise(() => loadFixture("opencode", "gpt-5.2-codex"))
|
||||||
|
yield* writeConfig(test.directory, model, zenConfig)
|
||||||
|
|
||||||
|
const sessionID = SessionID.make("session-recorded-native-zen-tool")
|
||||||
|
const agent = {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
prompt: "Call tools exactly as instructed.",
|
||||||
|
options: {},
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||||
|
} satisfies Agent.Info
|
||||||
|
const resolved = yield* getModel(ProviderID.opencode, ModelID.make(model.id))
|
||||||
|
let executed: unknown
|
||||||
|
|
||||||
|
const events = yield* collect({
|
||||||
|
user: {
|
||||||
|
id: MessageID.make("msg_user-recorded-native-zen-tool"),
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: 0 },
|
||||||
|
agent: agent.name,
|
||||||
|
model: { providerID: ProviderID.opencode, modelID: ModelID.make(model.id) },
|
||||||
|
} satisfies MessageV2.User,
|
||||||
|
sessionID,
|
||||||
|
model: resolved,
|
||||||
|
agent,
|
||||||
|
system: ["You must call the lookup tool exactly once with query weather. Do not answer in text."],
|
||||||
|
messages: [{ role: "user", content: "Use lookup." }],
|
||||||
|
toolChoice: "required",
|
||||||
|
tools: {
|
||||||
|
lookup: tool({
|
||||||
|
description: "Lookup data.",
|
||||||
|
inputSchema: z.object({ query: z.string() }),
|
||||||
|
execute: async (args, options) => {
|
||||||
|
executed = { args, toolCallId: options.toolCallId }
|
||||||
|
return { output: "looked up" }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(events.filter((event) => event.type === "step-finish")).toHaveLength(1)
|
||||||
|
expect(events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||||
|
expect(events.some((event) => event.type === "tool-result")).toBe(true)
|
||||||
|
expect(executed).toMatchObject({ args: { query: "weather" }, toolCallId: expect.any(String) })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { ToolFailure } from "@opencode-ai/llm"
|
||||||
|
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||||
|
import { jsonSchema, tool, type ModelMessage } from "ai"
|
||||||
|
import { Effect } from "effect"
|
||||||
|
import { LLMNative } from "@/session/llm/native-request"
|
||||||
|
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
|
||||||
|
import type { Provider } from "@/provider/provider"
|
||||||
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
|
|
||||||
|
const baseModel: Provider.Model = {
|
||||||
|
id: ModelID.make("gpt-5-mini"),
|
||||||
|
providerID: ProviderID.make("openai"),
|
||||||
|
api: {
|
||||||
|
id: "gpt-5-mini",
|
||||||
|
url: "https://api.openai.com/v1",
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
},
|
||||||
|
name: "GPT-5 Mini",
|
||||||
|
capabilities: {
|
||||||
|
temperature: true,
|
||||||
|
reasoning: true,
|
||||||
|
attachment: true,
|
||||||
|
toolcall: true,
|
||||||
|
input: {
|
||||||
|
text: true,
|
||||||
|
audio: false,
|
||||||
|
image: true,
|
||||||
|
video: false,
|
||||||
|
pdf: false,
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
text: true,
|
||||||
|
audio: false,
|
||||||
|
image: false,
|
||||||
|
video: false,
|
||||||
|
pdf: false,
|
||||||
|
},
|
||||||
|
interleaved: false,
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cache: {
|
||||||
|
read: 0,
|
||||||
|
write: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
context: 128_000,
|
||||||
|
input: 128_000,
|
||||||
|
output: 32_000,
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
options: {},
|
||||||
|
headers: {
|
||||||
|
"x-model": "model-header",
|
||||||
|
},
|
||||||
|
release_date: "2026-01-01",
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerInfo: Provider.Info = {
|
||||||
|
id: ProviderID.make("openai"),
|
||||||
|
name: "OpenAI",
|
||||||
|
source: "config",
|
||||||
|
env: ["OPENAI_API_KEY"],
|
||||||
|
options: { apiKey: "test-openai-key" },
|
||||||
|
models: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("session.llm-native.request", () => {
|
||||||
|
test("maps normalized stream inputs to a native LLM request", () => {
|
||||||
|
const messages: ModelMessage[] = [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: "system from messages",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "hello", providerOptions: { openai: { cacheControl: { type: "ephemeral" } } } },
|
||||||
|
{ type: "file", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "reasoning", text: "thinking", providerOptions: { openai: { encryptedContent: "secret" } } },
|
||||||
|
{ type: "text", text: "I'll run it" },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
toolName: "bash",
|
||||||
|
input: { command: "ls" },
|
||||||
|
providerOptions: { openai: { itemId: "item-1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "tool",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
toolName: "bash",
|
||||||
|
output: { type: "text", value: "ok" },
|
||||||
|
providerOptions: { openai: { outputId: "output-1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const request = LLMNative.request({
|
||||||
|
model: baseModel,
|
||||||
|
system: ["agent system"],
|
||||||
|
messages,
|
||||||
|
tools: {
|
||||||
|
bash: tool({
|
||||||
|
description: "Run a shell command",
|
||||||
|
inputSchema: jsonSchema({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
command: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["command"],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
toolChoice: "required",
|
||||||
|
temperature: 0.2,
|
||||||
|
topP: 0.9,
|
||||||
|
topK: 40,
|
||||||
|
maxOutputTokens: 1024,
|
||||||
|
providerOptions: { openai: { store: false } },
|
||||||
|
headers: { "x-request": "request-header" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(request.model).toMatchObject({
|
||||||
|
id: "gpt-5-mini",
|
||||||
|
provider: "openai",
|
||||||
|
route: "openai-responses",
|
||||||
|
baseURL: "https://api.openai.com/v1",
|
||||||
|
headers: {
|
||||||
|
"x-model": "model-header",
|
||||||
|
"x-request": "request-header",
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
context: 128_000,
|
||||||
|
output: 32_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(request.system).toEqual([
|
||||||
|
{ type: "text", text: "agent system" },
|
||||||
|
{ type: "text", text: "system from messages" },
|
||||||
|
])
|
||||||
|
expect(request.generation).toMatchObject({
|
||||||
|
temperature: 0.2,
|
||||||
|
topP: 0.9,
|
||||||
|
topK: 40,
|
||||||
|
maxTokens: 1024,
|
||||||
|
})
|
||||||
|
expect(request.providerOptions).toEqual({ openai: { store: false } })
|
||||||
|
expect(request.toolChoice).toMatchObject({ type: "required" })
|
||||||
|
expect(request.tools).toMatchObject([
|
||||||
|
{
|
||||||
|
name: "bash",
|
||||||
|
description: "Run a shell command",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
command: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["command"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(request.messages).toMatchObject([
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "hello", providerMetadata: { openai: { cacheControl: { type: "ephemeral" } } } },
|
||||||
|
{ type: "media", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "reasoning", text: "thinking", providerMetadata: { openai: { encryptedContent: "secret" } } },
|
||||||
|
{ type: "text", text: "I'll run it" },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "call-1",
|
||||||
|
name: "bash",
|
||||||
|
input: { command: "ls" },
|
||||||
|
providerMetadata: { openai: { itemId: "item-1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "tool",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
id: "call-1",
|
||||||
|
name: "bash",
|
||||||
|
result: { type: "text", value: "ok" },
|
||||||
|
providerMetadata: { openai: { outputId: "output-1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("selects native routes from existing provider packages", () => {
|
||||||
|
expect(
|
||||||
|
LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/anthropic" } }),
|
||||||
|
).toMatchObject({
|
||||||
|
route: "anthropic-messages",
|
||||||
|
baseURL: "https://api.anthropic.com/v1",
|
||||||
|
})
|
||||||
|
expect(LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/google" } })).toMatchObject({
|
||||||
|
route: "gemini",
|
||||||
|
baseURL: "https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
LLMNative.model({ ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/openai-compatible" } }),
|
||||||
|
).toMatchObject({
|
||||||
|
route: "openai-compatible-chat",
|
||||||
|
baseURL: "https://api.openai.com/v1",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@openrouter/ai-sdk-provider" } }),
|
||||||
|
).toMatchObject({
|
||||||
|
route: "openrouter",
|
||||||
|
baseURL: "https://openrouter.ai/api/v1",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fails fast for unsupported provider packages", () => {
|
||||||
|
expect(() =>
|
||||||
|
LLMNative.request({
|
||||||
|
model: { ...baseModel, api: { ...baseModel.api, npm: "unknown-provider" } },
|
||||||
|
messages: [],
|
||||||
|
}),
|
||||||
|
).toThrow("Native LLM request adapter does not support provider package unknown-provider")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("only enables native runtime for supported OpenAI API-key models", () => {
|
||||||
|
expect(LLMNativeRuntime.status({ model: baseModel, provider: providerInfo, auth: undefined })).toMatchObject({
|
||||||
|
type: "supported",
|
||||||
|
apiKey: "test-openai-key",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
LLMNativeRuntime.status({
|
||||||
|
model: { ...baseModel, providerID: ProviderID.make("opencode") },
|
||||||
|
provider: { ...providerInfo, id: ProviderID.make("opencode") },
|
||||||
|
auth: undefined,
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
type: "supported",
|
||||||
|
apiKey: "test-openai-key",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
LLMNativeRuntime.status({
|
||||||
|
model: { ...baseModel, providerID: ProviderID.make("anthropic") },
|
||||||
|
provider: { ...providerInfo, id: ProviderID.make("anthropic") },
|
||||||
|
auth: undefined,
|
||||||
|
}),
|
||||||
|
).toEqual({ type: "unsupported", reason: "provider is not openai or opencode" })
|
||||||
|
expect(
|
||||||
|
LLMNativeRuntime.status({
|
||||||
|
model: baseModel,
|
||||||
|
provider: providerInfo,
|
||||||
|
auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 },
|
||||||
|
}),
|
||||||
|
).toEqual({ type: "unsupported", reason: "OAuth auth is not supported" })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
LLMNativeRuntime.status({
|
||||||
|
model: { ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/anthropic" } },
|
||||||
|
provider: providerInfo,
|
||||||
|
auth: undefined,
|
||||||
|
}),
|
||||||
|
).toEqual({ type: "unsupported", reason: "provider package is not OpenAI" })
|
||||||
|
|
||||||
|
expect(
|
||||||
|
LLMNativeRuntime.status({
|
||||||
|
model: baseModel,
|
||||||
|
provider: { ...providerInfo, options: {} },
|
||||||
|
auth: undefined,
|
||||||
|
}),
|
||||||
|
).toEqual({ type: "unsupported", reason: "OpenAI API key is not configured" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("native tool wrapper converts thrown errors into typed ToolFailure", async () => {
|
||||||
|
const wrapped = LLMNativeRuntime.nativeTools(
|
||||||
|
{
|
||||||
|
explode: {
|
||||||
|
description: "always throws",
|
||||||
|
inputSchema: jsonSchema({ type: "object" }),
|
||||||
|
execute: async () => {
|
||||||
|
throw new Error("boom")
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
||||||
|
)
|
||||||
|
|
||||||
|
const failure = await Effect.runPromise(
|
||||||
|
Effect.flip(wrapped.explode!.execute!({}, { id: "call-1", name: "explode" })),
|
||||||
|
)
|
||||||
|
expect(failure).toBeInstanceOf(ToolFailure)
|
||||||
|
expect((failure as ToolFailure).message).toBe("boom")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("native tool wrapper raises ToolFailure when the source tool has no execute handler", async () => {
|
||||||
|
// The AI SDK Tool shape allows execute to be omitted (e.g., client-side / MCP tools).
|
||||||
|
// The native runtime owns execution, so encountering such a tool here means upstream
|
||||||
|
// wiring is wrong; we want a typed failure, not a silent skip or unhandled exception.
|
||||||
|
const wrapped = LLMNativeRuntime.nativeTools(
|
||||||
|
{ incomplete: { description: "no execute", inputSchema: jsonSchema({ type: "object" }) } as any },
|
||||||
|
{ messages: [] as ModelMessage[], abort: new AbortController().signal },
|
||||||
|
)
|
||||||
|
|
||||||
|
const failure = await Effect.runPromise(
|
||||||
|
Effect.flip(wrapped.incomplete!.execute!({}, { id: "call-1", name: "incomplete" })),
|
||||||
|
)
|
||||||
|
expect(failure).toBeInstanceOf(ToolFailure)
|
||||||
|
expect((failure as ToolFailure).message).toContain("incomplete")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("compiles through the native OpenAI Responses route", async () => {
|
||||||
|
const prepared = await Effect.runPromise(
|
||||||
|
LLMClient.prepare(
|
||||||
|
LLMNative.request({
|
||||||
|
model: baseModel,
|
||||||
|
messages: [{ role: "user", content: "hello" }],
|
||||||
|
providerOptions: { openai: { store: false } },
|
||||||
|
maxOutputTokens: 512,
|
||||||
|
headers: { "x-request": "request-header" },
|
||||||
|
}),
|
||||||
|
).pipe(Effect.provide(LLMClient.layer), Effect.provide(RequestExecutor.defaultLayer)),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared).toMatchObject({
|
||||||
|
route: "openai-responses",
|
||||||
|
protocol: "openai-responses",
|
||||||
|
body: {
|
||||||
|
model: "gpt-5-mini",
|
||||||
|
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||||
|
max_output_tokens: 512,
|
||||||
|
store: false,
|
||||||
|
stream: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { tool, type ModelMessage } from "ai"
|
import { tool, type ModelMessage } from "ai"
|
||||||
import { Cause, Effect, Exit, Stream } from "effect"
|
import { Cause, Effect, Exit, Layer, Stream } from "effect"
|
||||||
|
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { makeRuntime } from "../../src/effect/run-service"
|
import { makeRuntime } from "../../src/effect/run-service"
|
||||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||||
import { LLM } from "../../src/session/llm"
|
import { LLM } from "../../src/session/llm"
|
||||||
import type { InstanceContext } from "../../src/project/instance-context"
|
import type { InstanceContext } from "../../src/project/instance-context"
|
||||||
|
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||||
|
import { Auth } from "@/auth"
|
||||||
|
import { Config } from "@/config/config"
|
||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import { ProviderTransform } from "@/provider/transform"
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
import { ModelsDev } from "@opencode-ai/core/models-dev"
|
||||||
|
import { Plugin } from "@/plugin"
|
||||||
import { ProviderID, ModelID } from "../../src/provider/schema"
|
import { ProviderID, ModelID } from "../../src/provider/schema"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { tmpdir, withTestInstance } from "../fixture/fixture"
|
import { tmpdir, withTestInstance } from "../fixture/fixture"
|
||||||
@@ -17,6 +22,33 @@ import type { Agent } from "../../src/agent/agent"
|
|||||||
import { MessageV2 } from "../../src/session/message-v2"
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||||
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
import { Permission } from "@/permission"
|
||||||
|
import { LLMAISDK } from "@/session/llm/ai-sdk"
|
||||||
|
import { Session as SessionNs } from "@/session/session"
|
||||||
|
|
||||||
|
const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: string): Partial<Config.Info> => {
|
||||||
|
const { experimental: _experimental, ...configModel } = model
|
||||||
|
type ConfigModel = NonNullable<NonNullable<Config.Info["provider"]>[string]["models"]>[string]
|
||||||
|
return {
|
||||||
|
enabled_providers: ["openai"],
|
||||||
|
provider: {
|
||||||
|
openai: {
|
||||||
|
name: "OpenAI",
|
||||||
|
env: ["OPENAI_API_KEY"],
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
api: "https://api.openai.com/v1",
|
||||||
|
models: {
|
||||||
|
[model.id]: JSON.parse(JSON.stringify(configModel)) as ConfigModel,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
apiKey: "test-openai-key",
|
||||||
|
baseURL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) {
|
async function getModel(providerID: ProviderID, modelID: ModelID, ctx: InstanceContext) {
|
||||||
const effect = Effect.gen(function* () {
|
const effect = Effect.gen(function* () {
|
||||||
@@ -35,6 +67,26 @@ async function drain(input: LLM.StreamInput, ctx: InstanceContext) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function drainWith(layer: Layer.Layer<LLM.Service>, input: LLM.StreamInput, ctx: InstanceContext) {
|
||||||
|
return Effect.runPromise(
|
||||||
|
LLM.Service.use((svc) => svc.stream(input).pipe(Stream.runDrain)).pipe(
|
||||||
|
Effect.provide(layer),
|
||||||
|
Effect.provideService(InstanceRef, ctx),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function llmLayerWithExecutor(executor: Layer.Layer<RequestExecutor.Service>, flags: Partial<RuntimeFlags.Info> = {}) {
|
||||||
|
return LLM.layer.pipe(
|
||||||
|
Layer.provide(Auth.defaultLayer),
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(Provider.defaultLayer),
|
||||||
|
Layer.provide(Plugin.defaultLayer),
|
||||||
|
Layer.provide(LLMClient.layer.pipe(Layer.provide(executor))),
|
||||||
|
Layer.provide(RuntimeFlags.layer(flags)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
describe("session.llm.hasToolCalls", () => {
|
describe("session.llm.hasToolCalls", () => {
|
||||||
test("returns false for empty messages array", () => {
|
test("returns false for empty messages array", () => {
|
||||||
expect(LLM.hasToolCalls([])).toBe(false)
|
expect(LLM.hasToolCalls([])).toBe(false)
|
||||||
@@ -122,6 +174,338 @@ describe("session.llm.hasToolCalls", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("session.llm.ai-sdk adapter", () => {
|
||||||
|
type AISDKAdapterEvent = Parameters<typeof LLMAISDK.toLLMEvents>[1]
|
||||||
|
|
||||||
|
const adapt = (events: ReadonlyArray<AISDKAdapterEvent>) => {
|
||||||
|
const state = LLMAISDK.adapterState()
|
||||||
|
return Effect.runPromise(
|
||||||
|
Effect.forEach(events, (event) => LLMAISDK.toLLMEvents(state, event)).pipe(Effect.map((items) => items.flat())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- tests defensive adapter branches outside AI SDK's current typed surface
|
||||||
|
const uncheckedAdapterEvent = (input: unknown) => input as AISDKAdapterEvent
|
||||||
|
|
||||||
|
test("maps AI SDK stream chunks without losing session-visible fields", async () => {
|
||||||
|
const metadata = { openai: { itemID: "item-1" } }
|
||||||
|
const events = await adapt([
|
||||||
|
{ type: "start" },
|
||||||
|
{ type: "start-step", request: {}, warnings: [] },
|
||||||
|
{ type: "text-start", id: "text-1", providerMetadata: metadata },
|
||||||
|
{ type: "text-delta", id: "text-1", text: "Hel", providerMetadata: { openai: { delta: 1 } } },
|
||||||
|
{ type: "text-delta", id: "text-1", text: "lo", providerMetadata: { openai: { delta: 2 } } },
|
||||||
|
{ type: "text-end", id: "text-1", providerMetadata: { openai: { done: true } } },
|
||||||
|
{ type: "reasoning-start", id: "reasoning-1", providerMetadata: metadata },
|
||||||
|
{ type: "reasoning-delta", id: "reasoning-1", text: "Think", providerMetadata: { openai: { delta: 3 } } },
|
||||||
|
{ type: "reasoning-end", id: "reasoning-1", providerMetadata: { openai: { done: true } } },
|
||||||
|
{ type: "tool-input-start", id: "call-1", toolName: "lookup", providerMetadata: metadata },
|
||||||
|
{ type: "tool-input-delta", id: "call-1", delta: '{"query":' },
|
||||||
|
{ type: "tool-input-delta", id: "call-1", delta: '"weather"}' },
|
||||||
|
{ type: "tool-input-end", id: "call-1", providerMetadata: { openai: { inputDone: true } } },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
toolName: "lookup",
|
||||||
|
input: { query: "weather" },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { openai: { called: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
toolName: "lookup",
|
||||||
|
input: { query: "weather" },
|
||||||
|
output: { title: "Lookup", output: "sunny", metadata: { ok: true } },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { openai: { result: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "finish-step",
|
||||||
|
response: { id: "response-1", timestamp: new Date(0), modelId: "gpt-test" },
|
||||||
|
finishReason: "other",
|
||||||
|
rawFinishReason: "other",
|
||||||
|
usage: {
|
||||||
|
inputTokens: 10,
|
||||||
|
outputTokens: 5,
|
||||||
|
totalTokens: 15,
|
||||||
|
inputTokenDetails: { noCacheTokens: 5, cacheReadTokens: 3, cacheWriteTokens: 2 },
|
||||||
|
outputTokenDetails: { textTokens: 4, reasoningTokens: 1 },
|
||||||
|
},
|
||||||
|
providerMetadata: { openai: { step: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "finish",
|
||||||
|
finishReason: "other",
|
||||||
|
rawFinishReason: "other",
|
||||||
|
totalUsage: {
|
||||||
|
inputTokens: 11,
|
||||||
|
outputTokens: 6,
|
||||||
|
totalTokens: 17,
|
||||||
|
cachedInputTokens: 4,
|
||||||
|
reasoningTokens: 2,
|
||||||
|
inputTokenDetails: { noCacheTokens: 7, cacheReadTokens: 4, cacheWriteTokens: undefined },
|
||||||
|
outputTokenDetails: { textTokens: 4, reasoningTokens: 2 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(events).toMatchObject([
|
||||||
|
{ type: "step-start", index: 0 },
|
||||||
|
{ type: "text-start", id: "text-1", providerMetadata: metadata },
|
||||||
|
{ type: "text-delta", id: "text-1", text: "Hel", providerMetadata: { openai: { delta: 1 } } },
|
||||||
|
{ type: "text-delta", id: "text-1", text: "lo", providerMetadata: { openai: { delta: 2 } } },
|
||||||
|
{ type: "text-end", id: "text-1", providerMetadata: { openai: { done: true } } },
|
||||||
|
{ type: "reasoning-start", id: "reasoning-1", providerMetadata: metadata },
|
||||||
|
{ type: "reasoning-delta", id: "reasoning-1", text: "Think", providerMetadata: { openai: { delta: 3 } } },
|
||||||
|
{ type: "reasoning-end", id: "reasoning-1", providerMetadata: { openai: { done: true } } },
|
||||||
|
{ type: "tool-input-start", id: "call-1", name: "lookup", providerMetadata: metadata },
|
||||||
|
{ type: "tool-input-delta", id: "call-1", name: "lookup", text: '{"query":' },
|
||||||
|
{ type: "tool-input-delta", id: "call-1", name: "lookup", text: '"weather"}' },
|
||||||
|
{ type: "tool-input-end", id: "call-1", name: "lookup", providerMetadata: { openai: { inputDone: true } } },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "call-1",
|
||||||
|
name: "lookup",
|
||||||
|
input: { query: "weather" },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { openai: { called: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
id: "call-1",
|
||||||
|
name: "lookup",
|
||||||
|
result: { type: "json", value: { title: "Lookup", output: "sunny", metadata: { ok: true } } },
|
||||||
|
providerExecuted: true,
|
||||||
|
providerMetadata: { openai: { result: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "step-finish",
|
||||||
|
index: 0,
|
||||||
|
reason: "unknown",
|
||||||
|
usage: {
|
||||||
|
inputTokens: 10,
|
||||||
|
outputTokens: 5,
|
||||||
|
totalTokens: 15,
|
||||||
|
reasoningTokens: 1,
|
||||||
|
cacheReadInputTokens: 3,
|
||||||
|
cacheWriteInputTokens: 2,
|
||||||
|
},
|
||||||
|
providerMetadata: { openai: { step: true } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "finish",
|
||||||
|
reason: "unknown",
|
||||||
|
usage: {
|
||||||
|
inputTokens: 11,
|
||||||
|
outputTokens: 6,
|
||||||
|
totalTokens: 17,
|
||||||
|
reasoningTokens: 2,
|
||||||
|
cacheReadInputTokens: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("creates stable block ids when AI SDK omits them", async () => {
|
||||||
|
const events = await adapt([
|
||||||
|
uncheckedAdapterEvent({ type: "text-delta", text: "implicit text" }),
|
||||||
|
uncheckedAdapterEvent({ type: "text-end" }),
|
||||||
|
uncheckedAdapterEvent({ type: "reasoning-delta", text: "implicit reasoning" }),
|
||||||
|
uncheckedAdapterEvent({ type: "reasoning-end" }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(events).toMatchObject([
|
||||||
|
{ type: "text-delta", id: "text-0", text: "implicit text" },
|
||||||
|
{ type: "text-end", id: "text-0" },
|
||||||
|
{ type: "reasoning-delta", id: "reasoning-0", text: "implicit reasoning" },
|
||||||
|
{ type: "reasoning-end", id: "reasoning-0" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("explicitly ignores non-session-visible AI SDK chunks", async () => {
|
||||||
|
expect(
|
||||||
|
await adapt([
|
||||||
|
uncheckedAdapterEvent({ type: "abort" }),
|
||||||
|
uncheckedAdapterEvent({ type: "source" }),
|
||||||
|
uncheckedAdapterEvent({ type: "file" }),
|
||||||
|
uncheckedAdapterEvent({ type: "raw" }),
|
||||||
|
uncheckedAdapterEvent({ type: "tool-output-denied" }),
|
||||||
|
uncheckedAdapterEvent({ type: "tool-approval-request" }),
|
||||||
|
]),
|
||||||
|
).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves tool-error cause", async () => {
|
||||||
|
const error = new Permission.RejectedError()
|
||||||
|
const events = await Effect.runPromise(
|
||||||
|
LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), {
|
||||||
|
type: "tool-error",
|
||||||
|
toolCallId: "call_123",
|
||||||
|
toolName: "bash",
|
||||||
|
input: {},
|
||||||
|
error,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
type: "tool-error",
|
||||||
|
id: "call_123",
|
||||||
|
name: "bash",
|
||||||
|
message: error.message,
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("emits undefined usage when every AI SDK usage field is missing", async () => {
|
||||||
|
// If every numeric field is undefined the translator should signal "no usage info"
|
||||||
|
// by emitting undefined, not by polluting the event with usage: {}. Downstream cost
|
||||||
|
// telemetry distinguishes "missing" from "zero," so emitting an empty object causes
|
||||||
|
// false positives ("usage was tracked, just empty") instead of correct nulls.
|
||||||
|
const events = await adapt([
|
||||||
|
{
|
||||||
|
type: "finish-step",
|
||||||
|
response: { id: "response-1", timestamp: new Date(0), modelId: "gpt-test" },
|
||||||
|
finishReason: "stop",
|
||||||
|
rawFinishReason: "stop",
|
||||||
|
providerMetadata: undefined,
|
||||||
|
usage: {
|
||||||
|
inputTokens: undefined,
|
||||||
|
outputTokens: undefined,
|
||||||
|
totalTokens: undefined,
|
||||||
|
reasoningTokens: undefined,
|
||||||
|
cachedInputTokens: undefined,
|
||||||
|
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
|
||||||
|
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
const stepFinish = events[0]
|
||||||
|
if (stepFinish.type !== "step-finish") throw new Error("expected step-finish")
|
||||||
|
expect(stepFinish.usage).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("reuses adapter state cleanly across streams once finish has fired", async () => {
|
||||||
|
// adapterState() is meant to be per-stream, but the only thing finish currently clears
|
||||||
|
// is toolNames — step, text counters, and the current text/reasoning IDs all leak
|
||||||
|
// forward. A caller that reuses a state across two streams sees text-1/reasoning-1/
|
||||||
|
// step index 1 on the second stream's first events. The test pins the intended
|
||||||
|
// contract: after finish, the same state can be reused and starts fresh.
|
||||||
|
const state = LLMAISDK.adapterState()
|
||||||
|
const run = (events: ReadonlyArray<AISDKAdapterEvent>) =>
|
||||||
|
Effect.runPromise(
|
||||||
|
Effect.forEach(events, (event) => LLMAISDK.toLLMEvents(state, event)).pipe(Effect.map((items) => items.flat())),
|
||||||
|
)
|
||||||
|
|
||||||
|
await run([
|
||||||
|
{ type: "start-step", request: {}, warnings: [] },
|
||||||
|
uncheckedAdapterEvent({ type: "text-delta", text: "first" }),
|
||||||
|
uncheckedAdapterEvent({ type: "text-end" }),
|
||||||
|
uncheckedAdapterEvent({ type: "reasoning-delta", text: "first reasoning" }),
|
||||||
|
uncheckedAdapterEvent({ type: "reasoning-end" }),
|
||||||
|
{
|
||||||
|
type: "finish-step",
|
||||||
|
response: { id: "r1", timestamp: new Date(0), modelId: "gpt-test" },
|
||||||
|
finishReason: "stop",
|
||||||
|
rawFinishReason: "stop",
|
||||||
|
providerMetadata: undefined,
|
||||||
|
usage: {
|
||||||
|
inputTokens: 1,
|
||||||
|
outputTokens: 1,
|
||||||
|
totalTokens: 2,
|
||||||
|
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
|
||||||
|
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "finish",
|
||||||
|
finishReason: "stop",
|
||||||
|
rawFinishReason: "stop",
|
||||||
|
totalUsage: {
|
||||||
|
inputTokens: 1,
|
||||||
|
outputTokens: 1,
|
||||||
|
totalTokens: 2,
|
||||||
|
inputTokenDetails: { noCacheTokens: undefined, cacheReadTokens: undefined, cacheWriteTokens: undefined },
|
||||||
|
outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const secondStream = await run([
|
||||||
|
{ type: "start-step", request: {}, warnings: [] },
|
||||||
|
uncheckedAdapterEvent({ type: "text-delta", text: "second" }),
|
||||||
|
uncheckedAdapterEvent({ type: "text-end" }),
|
||||||
|
uncheckedAdapterEvent({ type: "reasoning-delta", text: "second reasoning" }),
|
||||||
|
uncheckedAdapterEvent({ type: "reasoning-end" }),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(secondStream).toMatchObject([
|
||||||
|
{ type: "step-start", index: 0 },
|
||||||
|
{ type: "text-delta", id: "text-0", text: "second" },
|
||||||
|
{ type: "text-end", id: "text-0" },
|
||||||
|
{ type: "reasoning-delta", id: "reasoning-0", text: "second reasoning" },
|
||||||
|
{ type: "reasoning-end", id: "reasoning-0" },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
// Anthropic emits cache write counts in providerMetadata.anthropic.cacheCreationInputTokens
|
||||||
|
// rather than usage.inputTokenDetails.cacheWriteTokens. Session.getUsage falls back to the
|
||||||
|
// metadata path — but only if the adapter preserves providerMetadata on step-finish.
|
||||||
|
test("preserves providerMetadata on step-finish so Anthropic cache writes survive getUsage", async () => {
|
||||||
|
const events = await adapt([
|
||||||
|
{
|
||||||
|
type: "finish-step",
|
||||||
|
response: { id: "msg_test", timestamp: new Date(0), modelId: "claude-3-5-sonnet" },
|
||||||
|
finishReason: "stop",
|
||||||
|
rawFinishReason: "stop",
|
||||||
|
// Anthropic's AI SDK shape: cacheWriteTokens is NOT in usage, it arrives via providerMetadata.
|
||||||
|
usage: {
|
||||||
|
inputTokens: 1000,
|
||||||
|
outputTokens: 500,
|
||||||
|
totalTokens: 1500,
|
||||||
|
inputTokenDetails: { noCacheTokens: 800, cacheReadTokens: 200, cacheWriteTokens: undefined },
|
||||||
|
outputTokenDetails: { textTokens: 500, reasoningTokens: undefined },
|
||||||
|
},
|
||||||
|
providerMetadata: { anthropic: { cacheCreationInputTokens: 300 } },
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(events).toHaveLength(1)
|
||||||
|
const stepFinish = events[0]
|
||||||
|
if (stepFinish.type !== "step-finish") throw new Error("expected step-finish")
|
||||||
|
expect(stepFinish.providerMetadata).toEqual({ anthropic: { cacheCreationInputTokens: 300 } })
|
||||||
|
expect(stepFinish.usage?.cacheWriteInputTokens).toBeUndefined()
|
||||||
|
expect(stepFinish.usage?.cacheReadInputTokens).toBe(200)
|
||||||
|
|
||||||
|
// End-to-end: with the metadata preserved, getUsage extracts cache.write from the fallback path.
|
||||||
|
const result = SessionNs.getUsage({
|
||||||
|
model: {
|
||||||
|
id: "claude-3-5-sonnet",
|
||||||
|
providerID: "anthropic",
|
||||||
|
name: "Claude",
|
||||||
|
limit: { context: 200_000, output: 8_000 },
|
||||||
|
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||||
|
capabilities: {
|
||||||
|
toolcall: true,
|
||||||
|
attachment: false,
|
||||||
|
reasoning: false,
|
||||||
|
temperature: true,
|
||||||
|
input: { text: true, image: false, audio: false, video: false },
|
||||||
|
output: { text: true, image: false, audio: false, video: false },
|
||||||
|
},
|
||||||
|
api: { npm: "@ai-sdk/anthropic" },
|
||||||
|
options: {},
|
||||||
|
} as never,
|
||||||
|
usage: stepFinish.usage!,
|
||||||
|
metadata: stepFinish.providerMetadata,
|
||||||
|
})
|
||||||
|
expect(result.tokens.cache.write).toBe(300)
|
||||||
|
expect(result.tokens.cache.read).toBe(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
type Capture = {
|
type Capture = {
|
||||||
url: URL
|
url: URL
|
||||||
headers: Headers
|
headers: Headers
|
||||||
@@ -608,6 +992,18 @@ describe("session.llm.stream", () => {
|
|||||||
service_tier: null,
|
service_tier: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
output_index: 0,
|
||||||
|
item: { type: "message", id: "item-1", status: "in_progress", role: "assistant", content: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.content_part.added",
|
||||||
|
item_id: "item-1",
|
||||||
|
output_index: 0,
|
||||||
|
content_index: 0,
|
||||||
|
part: { type: "output_text", text: "", annotations: [] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "response.output_text.delta",
|
type: "response.output_text.delta",
|
||||||
item_id: "item-1",
|
item_id: "item-1",
|
||||||
@@ -630,32 +1026,7 @@ describe("session.llm.stream", () => {
|
|||||||
]
|
]
|
||||||
const request = waitRequest("/responses", createEventResponse(responseChunks, true))
|
const request = waitRequest("/responses", createEventResponse(responseChunks, true))
|
||||||
|
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({ config: openAIConfig(model, `${server.url.origin}/v1`) })
|
||||||
init: async (dir) => {
|
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
enabled_providers: ["openai"],
|
|
||||||
provider: {
|
|
||||||
openai: {
|
|
||||||
name: "OpenAI",
|
|
||||||
env: ["OPENAI_API_KEY"],
|
|
||||||
npm: "@ai-sdk/openai",
|
|
||||||
api: "https://api.openai.com/v1",
|
|
||||||
models: {
|
|
||||||
[model.id]: configModel(model),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
apiKey: "test-openai-key",
|
|
||||||
baseURL: `${server.url.origin}/v1`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await withTestInstance({
|
await withTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
@@ -706,6 +1077,438 @@ describe("session.llm.stream", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps supported OpenAI models on AI SDK path when native flag is off", async () => {
|
||||||
|
const server = state.server
|
||||||
|
if (!server) {
|
||||||
|
throw new Error("Server not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = await loadFixture("openai", "gpt-5.2")
|
||||||
|
const model = source.model
|
||||||
|
const request = waitRequest(
|
||||||
|
"/responses",
|
||||||
|
createEventResponse(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
type: "response.created",
|
||||||
|
response: {
|
||||||
|
id: "resp-flag-off",
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
model: model.id,
|
||||||
|
service_tier: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
output_index: 0,
|
||||||
|
item: { type: "message", id: "item-flag-off", status: "in_progress", role: "assistant", content: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.content_part.added",
|
||||||
|
item_id: "item-flag-off",
|
||||||
|
output_index: 0,
|
||||||
|
content_index: 0,
|
||||||
|
part: { type: "output_text", text: "", annotations: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_text.delta",
|
||||||
|
item_id: "item-flag-off",
|
||||||
|
delta: "Flag off",
|
||||||
|
logprobs: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.completed",
|
||||||
|
response: {
|
||||||
|
incomplete_details: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 1,
|
||||||
|
input_tokens_details: null,
|
||||||
|
output_tokens: 1,
|
||||||
|
output_tokens_details: null,
|
||||||
|
},
|
||||||
|
service_tier: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const failingNativeClient = Layer.succeed(
|
||||||
|
LLMClient.Service,
|
||||||
|
LLMClient.Service.of({
|
||||||
|
prepare: () => Effect.die(new Error("native LLM client should not be used when the flag is off")),
|
||||||
|
stream: () => Stream.die(new Error("native LLM client should not be used when the flag is off")),
|
||||||
|
generate: () => Effect.die(new Error("native LLM client should not be used when the flag is off")),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await using tmp = await tmpdir({ config: openAIConfig(model, `${server.url.origin}/v1`) })
|
||||||
|
|
||||||
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
|
||||||
|
const sessionID = SessionID.make("session-test-native-flag-off")
|
||||||
|
const agent = {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
options: {},
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||||
|
} satisfies Agent.Info
|
||||||
|
|
||||||
|
await drainWith(
|
||||||
|
LLM.layer.pipe(
|
||||||
|
Layer.provide(Auth.defaultLayer),
|
||||||
|
Layer.provide(Config.defaultLayer),
|
||||||
|
Layer.provide(Provider.defaultLayer),
|
||||||
|
Layer.provide(Plugin.defaultLayer),
|
||||||
|
Layer.provide(failingNativeClient),
|
||||||
|
Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: false })),
|
||||||
|
),
|
||||||
|
{
|
||||||
|
user: {
|
||||||
|
id: MessageID.make("msg_user-native-flag-off"),
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: agent.name,
|
||||||
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
|
||||||
|
} satisfies MessageV2.User,
|
||||||
|
sessionID,
|
||||||
|
model: resolved,
|
||||||
|
agent,
|
||||||
|
system: ["You are a helpful assistant."],
|
||||||
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
const capture = await request
|
||||||
|
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
|
||||||
|
expect(capture.body.model).toBe(resolved.api.id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("streams OpenAI through native runtime when opted in", async () => {
|
||||||
|
const server = state.server
|
||||||
|
if (!server) {
|
||||||
|
throw new Error("Server not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = await loadFixture("openai", "gpt-5.2")
|
||||||
|
const model = source.model
|
||||||
|
const chunks = [
|
||||||
|
{
|
||||||
|
type: "response.created",
|
||||||
|
response: {
|
||||||
|
id: "resp-native",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "message", id: "item-native", status: "in_progress" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_text.delta",
|
||||||
|
item_id: "item-native",
|
||||||
|
delta: "Hello native",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.completed",
|
||||||
|
response: {
|
||||||
|
incomplete_details: null,
|
||||||
|
usage: {
|
||||||
|
input_tokens: 1,
|
||||||
|
input_tokens_details: null,
|
||||||
|
output_tokens: 1,
|
||||||
|
output_tokens_details: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const request = waitRequest("/responses", createEventResponse(chunks, true))
|
||||||
|
|
||||||
|
await using tmp = await tmpdir({ config: openAIConfig(model, `${server.url.origin}/v1`) })
|
||||||
|
|
||||||
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
|
||||||
|
const sessionID = SessionID.make("session-test-native")
|
||||||
|
const agent = {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
options: {},
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||||
|
temperature: 0.2,
|
||||||
|
} satisfies Agent.Info
|
||||||
|
|
||||||
|
await drainWith(
|
||||||
|
llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }),
|
||||||
|
{
|
||||||
|
user: {
|
||||||
|
id: MessageID.make("msg_user-native"),
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: agent.name,
|
||||||
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" },
|
||||||
|
} satisfies MessageV2.User,
|
||||||
|
sessionID,
|
||||||
|
model: resolved,
|
||||||
|
agent,
|
||||||
|
system: ["You are a helpful assistant."],
|
||||||
|
messages: [{ role: "user", content: "Hello" }],
|
||||||
|
tools: {},
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
const capture = await request
|
||||||
|
expect(capture.url.pathname.endsWith("/responses")).toBe(true)
|
||||||
|
expect(capture.headers.get("Authorization")).toBe("Bearer test-openai-key")
|
||||||
|
expect(capture.body.model).toBe(model.id)
|
||||||
|
expect(capture.body.stream).toBe(true)
|
||||||
|
expect((capture.body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
|
||||||
|
expect(JSON.stringify(capture.body.input)).toContain("You are a helpful assistant.")
|
||||||
|
expect(capture.body.input).toContainEqual({ role: "user", content: [{ type: "input_text", text: "Hello" }] })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("uses injected native request executor for tool calls", async () => {
|
||||||
|
const source = await loadFixture("openai", "gpt-5.2")
|
||||||
|
const model = source.model
|
||||||
|
const chunks = [
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "function_call", id: "item-injected-tool", call_id: "call-injected-tool", name: "lookup" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.function_call_arguments.delta",
|
||||||
|
item_id: "item-injected-tool",
|
||||||
|
delta: '{"query":"weather"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "item-injected-tool",
|
||||||
|
call_id: "call-injected-tool",
|
||||||
|
name: "lookup",
|
||||||
|
arguments: '{"query":"weather"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.completed",
|
||||||
|
response: { incomplete_details: null, usage: { input_tokens: 1, output_tokens: 1 } },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
let captured: Record<string, unknown> | undefined
|
||||||
|
let executed: unknown
|
||||||
|
const executor = Layer.succeed(
|
||||||
|
RequestExecutor.Service,
|
||||||
|
RequestExecutor.Service.of({
|
||||||
|
execute: (request) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
|
||||||
|
captured = (yield* Effect.promise(() => web.json())) as Record<string, unknown>
|
||||||
|
return HttpClientResponse.fromWeb(request, createEventResponse(chunks, true))
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await using tmp = await tmpdir({ config: openAIConfig(model, "https://injected-openai.test/v1") })
|
||||||
|
|
||||||
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
|
||||||
|
const sessionID = SessionID.make("session-test-native-injected-tool")
|
||||||
|
const agent = {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
options: {},
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||||
|
} satisfies Agent.Info
|
||||||
|
|
||||||
|
await drainWith(
|
||||||
|
llmLayerWithExecutor(executor, { experimentalNativeLlm: true }),
|
||||||
|
{
|
||||||
|
user: {
|
||||||
|
id: MessageID.make("msg_user-native-injected-tool"),
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: agent.name,
|
||||||
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
|
||||||
|
} satisfies MessageV2.User,
|
||||||
|
sessionID,
|
||||||
|
model: resolved,
|
||||||
|
agent,
|
||||||
|
system: [],
|
||||||
|
messages: [{ role: "user", content: "Use lookup" }],
|
||||||
|
tools: {
|
||||||
|
lookup: tool({
|
||||||
|
description: "Lookup data",
|
||||||
|
inputSchema: z.object({ query: z.string() }),
|
||||||
|
execute: async (args, options) => {
|
||||||
|
executed = { args, toolCallId: options.toolCallId }
|
||||||
|
return { output: "looked up" }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(captured?.model).toBe(model.id)
|
||||||
|
expect(captured?.tools).toEqual([
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
name: "lookup",
|
||||||
|
description: "Lookup data",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: { query: { type: "string" } },
|
||||||
|
required: ["query"],
|
||||||
|
additionalProperties: false,
|
||||||
|
$schema: "http://json-schema.org/draft-07/schema#",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-injected-tool" })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("executes OpenAI tool calls through native runtime", async () => {
|
||||||
|
const server = state.server
|
||||||
|
if (!server) {
|
||||||
|
throw new Error("Server not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = await loadFixture("openai", "gpt-5.2")
|
||||||
|
const model = source.model
|
||||||
|
const chunks = [
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
item: { type: "function_call", id: "item-native-tool", call_id: "call-native-tool", name: "lookup" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.function_call_arguments.delta",
|
||||||
|
item_id: "item-native-tool",
|
||||||
|
delta: '{"query":"weather"}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: {
|
||||||
|
type: "function_call",
|
||||||
|
id: "item-native-tool",
|
||||||
|
call_id: "call-native-tool",
|
||||||
|
name: "lookup",
|
||||||
|
arguments: '{"query":"weather"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.completed",
|
||||||
|
response: { incomplete_details: null, usage: { input_tokens: 1, output_tokens: 1 } },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const request = waitRequest("/responses", createEventResponse(chunks, true))
|
||||||
|
let executed: unknown
|
||||||
|
|
||||||
|
await using tmp = await tmpdir({
|
||||||
|
init: async (dir) => {
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
enabled_providers: ["openai"],
|
||||||
|
provider: {
|
||||||
|
openai: {
|
||||||
|
name: "OpenAI",
|
||||||
|
env: ["OPENAI_API_KEY"],
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
api: "https://api.openai.com/v1",
|
||||||
|
models: {
|
||||||
|
[model.id]: model,
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
apiKey: "test-openai-key",
|
||||||
|
baseURL: `${server.url.origin}/v1`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await withTestInstance({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async (ctx) => {
|
||||||
|
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id), ctx)
|
||||||
|
const sessionID = SessionID.make("session-test-native-tool")
|
||||||
|
const agent = {
|
||||||
|
name: "test",
|
||||||
|
mode: "primary",
|
||||||
|
options: {},
|
||||||
|
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||||
|
} satisfies Agent.Info
|
||||||
|
|
||||||
|
await drainWith(
|
||||||
|
llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }),
|
||||||
|
{
|
||||||
|
user: {
|
||||||
|
id: MessageID.make("msg_user-native-tool"),
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: agent.name,
|
||||||
|
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
|
||||||
|
} satisfies MessageV2.User,
|
||||||
|
sessionID,
|
||||||
|
model: resolved,
|
||||||
|
agent,
|
||||||
|
system: [],
|
||||||
|
messages: [{ role: "user", content: "Use lookup" }],
|
||||||
|
tools: {
|
||||||
|
lookup: tool({
|
||||||
|
description: "Lookup data",
|
||||||
|
inputSchema: z.object({ query: z.string() }),
|
||||||
|
execute: async (args, options) => {
|
||||||
|
executed = { args, toolCallId: options.toolCallId }
|
||||||
|
return { output: "looked up" }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
const capture = await request
|
||||||
|
expect(capture.body.tools).toEqual([
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
name: "lookup",
|
||||||
|
description: "Lookup data",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
properties: { query: { type: "string" } },
|
||||||
|
required: ["query"],
|
||||||
|
additionalProperties: false,
|
||||||
|
$schema: "http://json-schema.org/draft-07/schema#",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-native-tool" })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test("accepts user image attachments as data URLs for OpenAI models", async () => {
|
test("accepts user image attachments as data URLs for OpenAI models", async () => {
|
||||||
const server = state.server
|
const server = state.server
|
||||||
if (!server) {
|
if (!server) {
|
||||||
@@ -724,6 +1527,18 @@ describe("session.llm.stream", () => {
|
|||||||
service_tier: null,
|
service_tier: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "response.output_item.added",
|
||||||
|
output_index: 0,
|
||||||
|
item: { type: "message", id: "item-data-url", status: "in_progress", role: "assistant", content: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "response.content_part.added",
|
||||||
|
item_id: "item-data-url",
|
||||||
|
output_index: 0,
|
||||||
|
content_index: 0,
|
||||||
|
part: { type: "output_text", text: "", annotations: [] },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "response.output_text.delta",
|
type: "response.output_text.delta",
|
||||||
item_id: "item-data-url",
|
item_id: "item-data-url",
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
|
import { tool } from "ai"
|
||||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import z from "zod"
|
||||||
import type { Agent } from "../../src/agent/agent"
|
import type { Agent } from "../../src/agent/agent"
|
||||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
@@ -661,6 +663,71 @@ it.live("session.processor effect tests compact on structured context overflow",
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.live("session.processor effect tests complete AI SDK tool calls when native flag is off", () =>
|
||||||
|
provideTmpdirServer(
|
||||||
|
({ dir, llm }) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { processors, session, provider } = yield* boot()
|
||||||
|
|
||||||
|
yield* llm.tool("lookup", { query: "weather" })
|
||||||
|
|
||||||
|
const chat = yield* session.create({})
|
||||||
|
const parent = yield* user(chat.id, "tool")
|
||||||
|
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||||
|
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||||
|
const handle = yield* processors.create({
|
||||||
|
assistantMessage: msg,
|
||||||
|
sessionID: chat.id,
|
||||||
|
model: mdl,
|
||||||
|
})
|
||||||
|
|
||||||
|
const value = yield* handle.process({
|
||||||
|
user: {
|
||||||
|
id: parent.id,
|
||||||
|
sessionID: chat.id,
|
||||||
|
role: "user",
|
||||||
|
time: parent.time,
|
||||||
|
agent: parent.agent,
|
||||||
|
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||||
|
} satisfies MessageV2.User,
|
||||||
|
sessionID: chat.id,
|
||||||
|
model: mdl,
|
||||||
|
agent: agent(),
|
||||||
|
system: [],
|
||||||
|
messages: [{ role: "user", content: "tool" }],
|
||||||
|
tools: {
|
||||||
|
lookup: tool({
|
||||||
|
description: "Look up information",
|
||||||
|
inputSchema: z.object({ query: z.string() }),
|
||||||
|
execute: async (input) => ({
|
||||||
|
title: "Weather lookup",
|
||||||
|
output: `result:${input.query}`,
|
||||||
|
metadata: { source: "test" },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const parts = MessageV2.parts(msg.id)
|
||||||
|
const call = parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
|
||||||
|
|
||||||
|
expect(value).toBe("continue")
|
||||||
|
expect(yield* llm.calls).toBe(1)
|
||||||
|
expect(call?.callID).toBe("call_1")
|
||||||
|
expect(call?.tool).toBe("lookup")
|
||||||
|
expect(call?.state.status).toBe("completed")
|
||||||
|
if (call?.state.status !== "completed") return
|
||||||
|
expect(call.state.input).toEqual({ query: "weather" })
|
||||||
|
expect(call.state.output).toBe("result:weather")
|
||||||
|
expect(call.state.title).toBe("Weather lookup")
|
||||||
|
expect(call.state.metadata).toEqual({ source: "test" })
|
||||||
|
expect(call.state.time.start).toBeDefined()
|
||||||
|
expect(call.state.time.end).toBeDefined()
|
||||||
|
}),
|
||||||
|
{ git: true, config: (url) => providerCfg(url) },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
it.live("session.processor effect tests mark pending tools as aborted on cleanup", () =>
|
it.live("session.processor effect tests mark pending tools as aborted on cleanup", () =>
|
||||||
provideTmpdirServer(
|
provideTmpdirServer(
|
||||||
({ dir, llm }) =>
|
({ dir, llm }) =>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { describe, expect, beforeEach, afterAll } from "bun:test"
|
import { describe, expect, beforeEach, afterAll } from "bun:test"
|
||||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
import { Effect, Layer, Schema } from "effect"
|
import { Deferred, Effect, Layer, Schema } from "effect"
|
||||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
|
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
|
||||||
import { SyncEvent } from "../../src/sync"
|
import { SyncEvent } from "../../src/sync"
|
||||||
import { Database, eq } from "@/storage/db"
|
import { Database, eq } from "@/storage/db"
|
||||||
import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
|
import { EventSequenceTable, EventTable } from "../../src/sync/event.sql"
|
||||||
import { MessageID } from "../../src/session/schema"
|
import { MessageID } from "../../src/session/schema"
|
||||||
import { initProjectors } from "../../src/server/projectors"
|
import { initProjectors } from "../../src/server/projectors"
|
||||||
import { testEffect } from "../lib/effect"
|
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
|
||||||
const it = testEffect(
|
const it = testEffect(
|
||||||
@@ -139,6 +140,43 @@ describe("SyncEvent", () => {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Regression for the EffectBridge migration. GlobalBus.emit used to fire
|
||||||
|
// synchronously inside the Database.effect post-commit callback. After the
|
||||||
|
// migration it fires inside the forked publish Effect, AFTER bus.publish
|
||||||
|
// completes. Consumers don't care about microsecond-level ordering, but
|
||||||
|
// we still need to prove the emit actually fires.
|
||||||
|
it.live(
|
||||||
|
"emits sync events to GlobalBus after publishing to ProjectBus",
|
||||||
|
provideTmpdirInstance(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const { Created } = setup()
|
||||||
|
// Filter for OUR specific event in the handler so we ignore any
|
||||||
|
// stray sync events from other tests' lingering forks.
|
||||||
|
const received = yield* Deferred.make<GlobalEvent>()
|
||||||
|
const handler = (evt: GlobalEvent) => {
|
||||||
|
if (evt.payload?.type === "sync" && evt.payload?.syncEvent?.type === "item.created.1") {
|
||||||
|
Deferred.doneUnsafe(received, Effect.succeed(evt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GlobalBus.on("event", handler)
|
||||||
|
try {
|
||||||
|
yield* SyncEvent.use.run(Created, { id: "evt_global_1", name: "global" })
|
||||||
|
const event = yield* awaitWithTimeout(
|
||||||
|
Deferred.await(received),
|
||||||
|
"timed out waiting for sync event on GlobalBus",
|
||||||
|
"2 seconds",
|
||||||
|
)
|
||||||
|
expect(event.payload).toMatchObject({
|
||||||
|
type: "sync",
|
||||||
|
syncEvent: { type: "item.created.1", data: { id: "evt_global_1", name: "global" } },
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
GlobalBus.off("event", handler)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("replay", () => {
|
describe("replay", () => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { Effect } from "effect"
|
|
||||||
|
|
||||||
export type ToolContext = {
|
export type ToolContext = {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -17,7 +16,7 @@ export type ToolContext = {
|
|||||||
worktree: string
|
worktree: string
|
||||||
abort: AbortSignal
|
abort: AbortSignal
|
||||||
metadata(input: { title?: string; metadata?: { [key: string]: any } }): void
|
metadata(input: { title?: string; metadata?: { [key: string]: any } }): void
|
||||||
ask(input: AskInput): Effect.Effect<void>
|
ask(input: AskInput): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type AskInput = {
|
type AskInput = {
|
||||||
|
|||||||
@@ -1563,7 +1563,7 @@ PART_MAPPING["reasoning"] = function ReasoningPartDisplay(props) {
|
|||||||
const streaming = createMemo(
|
const streaming = createMemo(
|
||||||
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
|
() => props.message.role === "assistant" && typeof (props.message as AssistantMessage).time.completed !== "number",
|
||||||
)
|
)
|
||||||
const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text).trim()
|
const text = () => (data.store.part_text_accum_delta?.[part().id] ?? part().text ?? "").trim()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={text()}>
|
<Show when={text()}>
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ Repeated setup work, long sleeps/timeouts, serial integration tests, filesystem/
|
|||||||
| Session processor effect tests do not require repository state | Removed git setup from all processor-effect temp server fixtures | 12.500s | 9.230s | keep | Two targeted reruns passed after the change: 9.61s, 9.23s. |
|
| Session processor effect tests do not require repository state | Removed git setup from all processor-effect temp server fixtures | 12.500s | 9.230s | keep | Two targeted reruns passed after the change: 9.61s, 9.23s. |
|
||||||
| HTTP listen PTY ticket tests restart the same listener topology twice | Folded directory-scoped ticket regression into the broader unsafe-ticket test | 7.051s | 6.170s | keep | Two targeted reruns passed after the change: 6.76s, 6.17s; still covers mint failure and successful same-directory upgrade. |
|
| HTTP listen PTY ticket tests restart the same listener topology twice | Folded directory-scoped ticket regression into the broader unsafe-ticket test | 7.051s | 6.170s | keep | Two targeted reruns passed after the change: 6.76s, 6.17s; still covers mint failure and successful same-directory upgrade. |
|
||||||
| File watcher readiness can write before async native subscriptions are active | Retried short readiness writes and accepted symlink-realpath HEAD events | failed | 4.62s | keep | Three sequential focused watcher runs passed: 4.62s, 4.57s, 4.64s; full suite no longer failed in `watcher.test.ts`. |
|
| File watcher readiness can write before async native subscriptions are active | Retried short readiness writes and accepted symlink-realpath HEAD events | failed | 4.62s | keep | Three sequential focused watcher runs passed: 4.62s, 4.57s, 4.64s; full suite no longer failed in `watcher.test.ts`. |
|
||||||
|
| First provider config/env/filtering block can use Effect-aware instance fixtures | Migrated six `tmpdir` + `withTestInstance` cases to `it.instance` | 6.06s | 6.07s | keep | Neutral timing, but removes manual config file writes and instance plumbing; use as the pattern for later provider slices. |
|
||||||
|
| Custom provider/model config cases can use Effect-aware instance fixtures | Migrated three more config-heavy provider cases to `it.instance` | 6.07s | 6.12s | keep | Neutral timing within noise, but continues removing manual config file writes on top of the first provider fixture PR. |
|
||||||
|
| Provider env precedence and model lookup cases can use Effect-aware instance fixtures | Migrated four more provider lookup/default-model cases to `it.instance` | 6.12s | 6.36s | keep | Noisy 5-run median; kept as a small stacked cleanup slice but do not claim speedup from this migration. |
|
||||||
|
|
||||||
## Profiling Results
|
## Profiling Results
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user