Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton e083b27ee6 fix(core): refresh rotated environment integration credentials 2026-08-25 12:49:02 -04:00
Kit Langton 8f4b45698a fix(core): discover environment-backed integration connections 2026-08-25 12:36:52 -04:00
222 changed files with 1641 additions and 9047 deletions
+7 -59
View File
@@ -22,36 +22,6 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
affected:
name: affected packages
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
app: ${{ steps.packages.outputs.app }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: package.json
- name: Find affected packages
id: packages
env:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "app=true" >> "$GITHUB_OUTPUT"
exit 0
fi
bun x turbo@2.10.2 ls --affected --filter=@opencode-ai/app --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
unit:
name: unit (${{ matrix.settings.name }})
strategy:
@@ -71,7 +41,6 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
@@ -111,16 +80,9 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
GITHUB_ACTIONS=false bun turbo test
exit 0
fi
GITHUB_ACTIONS=false bun turbo test --affected
run: GITHUB_ACTIONS=false bun turbo test
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Verify published codemode package
if: runner.os == 'Linux'
@@ -130,15 +92,8 @@ jobs:
- name: Verify packed workerd SDK
if: runner.os == 'Linux'
timeout-minutes: 15
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
bun turbo verify:package --filter=@opencode-ai/sdk
exit 0
fi
bun turbo verify:package --affected --filter=@opencode-ai/sdk
env:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
working-directory: packages/sdk
run: bun run verify:package
- name: Verify compiled service lifecycle
if: always()
@@ -178,7 +133,7 @@ jobs:
e2e:
name: e2e (${{ matrix.settings.name }})
needs: affected
if: github.ref_name != 'v2' && github.head_ref != 'v2'
strategy:
fail-fast: false
matrix:
@@ -189,38 +144,32 @@ jobs:
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
env:
E2E_ENABLED: ${{ needs.affected.outputs.app == 'true' && github.ref_name != 'v2' && github.head_ref != 'v2' }}
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults:
run:
shell: bash
steps:
- name: Checkout repository
if: env.E2E_ENABLED == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
if: env.E2E_ENABLED == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
node-version: "24.15"
- name: Setup Bun
if: env.E2E_ENABLED == 'true'
uses: ./.github/actions/setup-bun
- name: Read Playwright version
if: env.E2E_ENABLED == 'true'
id: playwright-version
run: |
version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
if: env.E2E_ENABLED == 'true'
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
@@ -228,24 +177,23 @@ jobs:
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: env.E2E_ENABLED == 'true' && runner.os == 'Linux'
if: runner.os == 'Linux'
working-directory: packages/app
run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: env.E2E_ENABLED == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests
if: env.E2E_ENABLED == 'true'
run: bun --cwd packages/app test:e2e:local
env:
CI: true
timeout-minutes: 30
- name: Upload Playwright artifacts
if: always() && env.E2E_ENABLED == 'true'
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
+8 -2
View File
@@ -346,6 +346,7 @@
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
@@ -356,6 +357,7 @@
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0",
"@ff-labs/fff-bun": "0.10.5",
@@ -665,7 +667,6 @@
"dependencies": {
"@opencode-ai/client": "workspace:*",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/server": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -972,7 +973,6 @@
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
"@npmcli/arborist": "catalog:",
"@npmcli/config": "10.8.1",
"@opentelemetry/api": "1.9.0",
@@ -1206,6 +1206,8 @@
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="],
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@2.0.41", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-k3p9e3k0/gpDDyTtvafsK4HYR4D/aUQW/kzCwWo1+CzdBU84i4L14gWISC/mv6tgSicMXHcEUd521fPufQwNlg=="],
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.123", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.69", "@ai-sdk/provider": "3.0.15", "@ai-sdk/provider-utils": "4.0.46" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WNASvd1C516oh2qYIj9EvAVPdU+Abads8DQWU6p9lQtvFFeGh8QW+3LDOARZd1GCINUFfw5yadEK845SMQKLsA=="],
@@ -5952,6 +5954,10 @@
"@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+POSFVcgiu47BK64dhsI6OpcDC0/VAE2ZSaXdXGNNhpC/ava++uSRJYks0k2bpfY0wwCTgpAWZsXn/dG2Yppiw=="],
"@ai-sdk/vercel/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-oQnV96kE3lIqsQaaUrH4tiEX8/5xvBWizXMGxayFSHo=",
"aarch64-linux": "sha256-Hkl1xdCQ7voAllwtyrm3TjQT9cfej29fvPIP3lH7zVo=",
"aarch64-darwin": "sha256-s0WHRB13qcD0KlgWNXO7gLpsDOnfB8xny81GnN5YeQc=",
"x86_64-darwin": "sha256-fnYi1AxCrnO3byW9keDfBH2ueCfGZdaweOrV6AerrGs="
"x86_64-linux": "sha256-rIg+QSwF0kmPRnlPrpV7IlPTrHAxvoBOjdtoFOcUnKc=",
"aarch64-linux": "sha256-74rMnqiGy7gAUDWr9HWBc6Sn3kxh/hDfm5kp+I4fjok=",
"aarch64-darwin": "sha256-ImASKYxUQDHzH/UuwXUBsMNkitAH5wDYESlevEy9qeA=",
"x86_64-darwin": "sha256-txPhXfZOSoQdNXPfddKSdKqdjEWrb9GkBb38h+pZIuo="
}
}
+5 -5
View File
@@ -157,9 +157,9 @@ const PROVIDERS: ReadonlyArray<Provider> = [
id: "togetherai",
label: "TogetherAI",
tier: "compatible",
note: "Native Together AI text/tool recorded tests",
vars: [{ name: "TOGETHER_API_KEY" }],
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_API_KEY)),
note: "Existing OpenAI-compatible text/tool recorded tests",
vars: [{ name: "TOGETHER_AI_API_KEY" }],
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)),
},
{
id: "minimax",
@@ -200,8 +200,8 @@ const PROVIDERS: ReadonlyArray<Provider> = [
{
id: "cerebras",
label: "Cerebras",
tier: "compatible",
note: "Native Cerebras text/tool/tool-loop recorded tests",
tier: "optional",
note: "OpenAI-compatible bridge",
vars: [{ name: "CEREBRAS_API_KEY" }],
validate: (env) => validateBearer("https://api.cerebras.ai/v1/models", Redacted.make(env.CEREBRAS_API_KEY)),
},
+1 -1
View File
@@ -36,7 +36,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
// whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "google-vertex-messages", "bedrock-converse", "openrouter"])
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse", "openrouter"])
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
@@ -744,12 +744,9 @@ const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
return message.role === "assistant" && last?.type === "tool-call" && last.providerExecuted === true
}
const canUseNativeSystemUpdate = (request: LLMRequest, index: number) => {
const previous = request.messages[index - 1]
const next = request.messages[index + 1]
// Vertex currently rejects/404s for a system message after local tool results,
// so fold it into the user tool-result turn across continuations and history.
if (request.model.route.id === "google-vertex-messages" && previous?.role === "tool") return false
const canUseNativeSystemUpdate = (messages: LLMRequest["messages"], index: number) => {
const previous = messages[index - 1]
const next = messages[index + 1]
return (
previous !== undefined &&
previous.role !== "system" &&
@@ -796,7 +793,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
if (message.role === "system") {
if (splitsLocalToolResults(request.messages, index))
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request, index)) {
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request.messages, index)) {
messages.push(yield* lowerNativeSystemUpdate(message, breakpoints))
continue
}
+75 -128
View File
@@ -79,60 +79,10 @@ const OpenResponsesReasoningItem = Schema.Struct({
encrypted_content: optionalNull(Schema.String),
})
const OpenResponsesWebSearchCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("web_search_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
}),
[JsonObject],
)
const OpenResponsesFileSearchCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("file_search_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
queries: Schema.optional(Schema.Array(Schema.String)),
results: optionalNull(Schema.Array(JsonObject)),
}),
[JsonObject],
)
const OpenResponsesCodeInterpreterCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("code_interpreter_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
code: optionalNull(Schema.String),
container_id: optionalNull(Schema.String),
outputs: optionalNull(Schema.Array(JsonObject)),
}),
[JsonObject],
)
const OpenResponsesMCPCall = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("mcp_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
server_label: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
arguments: Schema.optional(Schema.String),
output: optionalNull(Schema.String),
error: Schema.optional(Schema.Unknown),
}),
[JsonObject],
)
export const HostedToolItem = Schema.Union([
OpenResponsesWebSearchCall,
OpenResponsesFileSearchCall,
OpenResponsesCodeInterpreterCall,
OpenResponsesMCPCall,
])
export type HostedToolItem = Schema.Schema.Type<typeof HostedToolItem>
const OpenResponsesItemReference = Schema.Struct({
type: Schema.tag("item_reference"),
id: Schema.String,
})
// `function_call_output.output` accepts either a plain string or an ordered
// array of content items so tools can return images and files in addition to text.
@@ -161,6 +111,7 @@ export const InputItem = Schema.Union([
phase: Schema.optionalKey(MessagePhase),
}),
OpenResponsesReasoningItem,
OpenResponsesItemReference,
Schema.Struct({
type: Schema.tag("function_call"),
id: Schema.optionalKey(Schema.String),
@@ -173,17 +124,10 @@ export const InputItem = Schema.Union([
call_id: Schema.String,
output: OpenResponsesFunctionCallOutput,
}),
HostedToolItem,
])
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
export type ExtendedHostedToolItem = {
readonly type: string
readonly id: string
readonly [key: string]: unknown
}
type LoweredInputItem =
| OpenResponsesInputItem
| ExtendedHostedToolItem
| {
readonly type: "message"
readonly id?: string
@@ -344,7 +288,6 @@ export const Event = Schema.StructWithRest(
arguments: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
response: Schema.optional(
@@ -373,7 +316,7 @@ export const Event = Schema.StructWithRest(
export type Event = Schema.Schema.Type<typeof Event>
// Which lowered input item a persisted item id is about to be attached to.
export type ItemKind = "message" | "reasoning" | "function-call" | "hosted-tool"
export type ItemKind = "message" | "reasoning" | "function-call" | "reference"
export interface Extension {
readonly id: string
@@ -383,7 +326,6 @@ export interface Extension {
readonly media: ProviderShared.NormalizedMedia
readonly request: LLMRequest
}) => MediaInput | undefined
readonly lowerHostedToolItem?: (item: unknown) => ExtendedHostedToolItem | undefined
// Optional grammar check applied before a persisted item id is resent as
// part of replayed history. Returning false drops the id; every lowered
// item treats a dropped id the same as an absent one.
@@ -399,10 +341,10 @@ export interface ParserState {
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly messageItems: ReadonlySet<string>
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
}
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
@@ -584,7 +526,10 @@ const lowerToolResultOutput = Effect.fnUntraced(function* (
})
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
const input: LoweredInputItem[] = []
const system: LoweredInputItem[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const input: LoweredInputItem[] = [...system]
const store = OpenResponsesOptions.resolve(request).store
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
for (const message of request.messages) {
@@ -607,7 +552,8 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (message.role === "assistant") {
const content: TextPart[] = []
const reasoningItems: Record<string, OpenResponsesReasoningInput> = {}
const hostedToolItems = new Set<string>()
const reasoningReferences = new Set<string>()
const hostedToolReferences = new Set<string>()
const flushText = () => {
if (content.length === 0) return
const groups = content.reduce<
@@ -642,6 +588,11 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
flushText()
const reasoning = lowerReasoning(part, providerMetadataKey, extension)
if (!reasoning) continue
if (store !== false) {
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
reasoningReferences.add(reasoning.id)
continue
}
const existing = reasoningItems[reasoning.id]
if (existing) {
existing.summary.push(...reasoning.summary)
@@ -662,29 +613,24 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const id = itemID(part.providerMetadata, providerMetadataKey)
const hosted =
part.result.type !== "json"
? undefined
: Schema.is(HostedToolItem)(part.result.value)
const reference = acceptsItemID(extension, "reference", id) ? id : undefined
if (store !== false && reference && !hostedToolReferences.has(reference))
input.push({ type: "item_reference", id: reference })
if (store === false) {
// The server is not storing this exchange, so the tool outcome has to
// travel in the input. Non-content results degrade to their text form.
const content: ReadonlyArray<Content> =
part.result.type === "content"
? part.result.value
: extension.lowerHostedToolItem?.(part.result.value)
if (acceptsItemID(extension, "hosted-tool", id) && hosted?.id === id) {
if (!hostedToolItems.has(id)) {
input.push(hosted)
hostedToolItems.add(id)
}
continue
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
})
}
const content: ReadonlyArray<Content> =
part.result.type === "content"
? part.result.value
: [{ type: "text", text: ProviderShared.toolResultText(part) }]
input.push({
role: "user",
content: yield* Effect.forEach(content, (item) =>
lowerHostedToolResultContentItem(item, request, extension),
),
})
if (reference) hostedToolReferences.add(reference)
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -709,16 +655,22 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
}
return input
// With store:false, Responses APIs only accept previous reasoning items when the
// complete item has encrypted state. Summary blocks for one item may carry
// that state only on the last block, so filter after they have been joined.
return store === false
? input.filter(
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
)
: input
})
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const instructions = ProviderShared.joinText(request.system)
const cacheKey = ProviderShared.promptCacheKey(request)
const parallelToolCalls = resolveParallelToolCalls(request)
return {
...(instructions ? { instructions } : {}),
...(options.instructions ? { instructions: options.instructions } : {}),
...(options.store !== undefined ? { store: options.store } : {}),
...(options.metadata ? { metadata: options.metadata } : {}),
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
@@ -866,9 +818,6 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item) return [state, NO_EVENTS]
@@ -944,23 +893,25 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
events,
]
}
if (item?.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
const id = item.id ?? item.call_id
const metadata = item.id ? providerMetadata(state, { itemId: item.id }) : undefined
if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
{
...state,
lifecycle,
tools: ToolStream.start(state.tools, id, {
id: item.call_id,
tools: ToolStream.start(state.tools, item.id, {
id: item.call_id ?? item.id,
name: item.name ?? "",
input: item.arguments ?? "",
providerMetadata: metadata,
}),
},
[...events, LLMEvent.toolInputStart({ id: item.call_id, name: item.name ?? "", providerMetadata: metadata })],
[
...events,
LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata: metadata }),
],
]
}
@@ -1015,21 +966,31 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
const item = state.reasoningItems[event.item_id]
if (!item) return [state, NO_EVENTS]
const events: LLMEvent[] = []
return [
{
...state,
lifecycle:
state.store !== false
? Lifecycle.reasoningEnd(
state.lifecycle,
events,
`${event.item_id}:${event.summary_index}`,
providerMetadata(state, { itemId: event.item_id }),
)
: state.lifecycle,
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...item.summaryParts,
[event.summary_index]: "can-conclude",
[event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
},
},
},
},
NO_EVENTS,
events,
]
}
@@ -1092,19 +1053,18 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
}
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const id = item.id ?? item.call_id
const tools = state.tools[id]
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const tools = state.tools[item.id]
? state.tools
: ToolStream.start(state.tools, id, {
: ToolStream.start(state.tools, item.id, {
id: item.call_id,
name: item.name,
providerMetadata: item.id ? providerMetadata(state, { itemId: item.id }) : undefined,
providerMetadata: providerMetadata(state, { itemId: item.id }),
})
const result =
item.arguments === undefined
? yield* ToolStream.finish(state.id, tools, id)
: yield* ToolStream.finishWithInput(state.id, tools, id, item.arguments)
? yield* ToolStream.finish(state.id, tools, item.id)
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
const events: LLMEvent[] = []
const resultEvents = result.events ?? []
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
@@ -1158,11 +1118,10 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
event.response?.output ?? [],
() => [state, NO_EVENTS] satisfies StepResult,
([current, events], item) => {
const id = item.id ?? (item.type === "function_call" ? item.call_id : undefined)
if (
!id ||
((item.type !== "function_call" || !current.tools[id]) &&
(item.type !== "reasoning" || !current.reasoningItems[id]))
!item.id ||
((item.type !== "function_call" || !current.tools[item.id]) &&
(item.type !== "reasoning" || !current.reasoningItems[item.id]))
)
return Effect.succeed([current, events] satisfies StepResult)
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
@@ -1242,11 +1201,7 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
export const step = (state: ParserState, input: Event) => {
const event =
input.item_id && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
export const step = (state: ParserState, event: Event) => {
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
@@ -1288,15 +1243,7 @@ export const step = (state: ParserState, input: Event) => {
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined)
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && id
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: id } }
: state,
event,
),
)
return Effect.succeed(onOutputItemAdded(state, event))
}
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
return event.item_id
@@ -1331,10 +1278,10 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
messageItems: new Set<string>(),
messagePhases: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
export const protocol = Protocol.make({
+17 -50
View File
@@ -278,7 +278,6 @@ interface LoweringOptions {
readonly cacheControl?: (
cache: CacheHint | undefined,
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
readonly toolCallID?: (id: string) => string
}
const lowerTool = (
@@ -305,8 +304,8 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ type: "function" as const, function: { name } }),
})
const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChatAssistantToolCall => ({
id: options.toolCallID?.(part.id) ?? part.id,
const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
id: part.id,
type: "function",
function: {
name: part.name,
@@ -364,9 +363,8 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
message: OpenAIChatRequestMessage,
configuredField: string | undefined,
requireReasoning: boolean,
options: LoweringOptions,
configuredField?: string,
options: LoweringOptions = {},
) {
const content: TextPart[] = []
const reasoning: ReasoningPart[] = []
@@ -383,7 +381,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
continue
}
if (part.type === "tool-call") {
toolCalls.push(lowerToolCall(part, options))
toolCalls.push(lowerToolCall(part))
continue
}
}
@@ -393,17 +391,15 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
const field = (() => {
if (configuredField !== undefined && (requireReasoning || reasoning.length > 0 || nativeReasoning !== undefined))
return configuredField
if (reasoning.length === 0) return requireReasoning ? "reasoning_content" : undefined
if (configuredField !== undefined) return configuredField
if (reasoning.length === 0) return undefined
if (observedField !== undefined) return observedField
if (nativeReasoning !== undefined) return "reasoning_content"
if (!fullyStructured || requireReasoning) return "reasoning_content"
if (!fullyStructured) return "reasoning_content"
})()
const reasoningText = (() => {
if (configuredField !== undefined)
return reasoning.length === 0 ? (nativeReasoning ?? (requireReasoning ? "" : undefined)) : text
if (reasoning.length === 0) return nativeReasoning ?? (requireReasoning ? "" : undefined)
if (configuredField !== undefined) return reasoning.length === 0 ? (nativeReasoning ?? "") : text
if (reasoning.length === 0) return nativeReasoning
return text
})()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
@@ -431,7 +427,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
if (part.result.type !== "content") {
messages.push({
role: "tool",
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
tool_call_id: part.id,
content: ProviderShared.toolResultText(part),
cache_control: options.cacheControl?.(part.cache),
})
@@ -441,7 +437,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({
role: "tool",
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
tool_call_id: part.id,
content: text.join("\n"),
cache_control: options.cacheControl?.(part.cache),
})
@@ -457,13 +453,11 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField: string | undefined,
requireReasoning: boolean,
options: LoweringOptions,
reasoningField?: string,
options: LoweringOptions = {},
) {
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
if (message.role === "assistant")
return [yield* lowerAssistantMessage(message, reasoningField, requireReasoning, options)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)]
return (yield* lowerToolMessages(message, options)).messages
})
@@ -484,37 +478,12 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
]
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages = [...system]
const modelID = request.model.id.toLowerCase()
const requireReasoning =
request.model.compatibility?.requireReasoning ??
(request.model.compatibility?.reasoningField !== undefined ||
request.model.provider === "deepseek" ||
request.model.route.endpoint.baseURL?.toLowerCase().includes("deepseek.com") ||
modelID.includes("deepseek"))
const reasoningField = request.model.compatibility?.reasoningField
const mistral = ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family))
const lowering = {
...options,
toolCallID: (id: string) => {
if (mistral) return id.replace(/[^a-zA-Z0-9]/g, "").slice(0, 9).padEnd(9, "0")
if (modelID.includes("claude")) return id.replace(/[^a-zA-Z0-9_-]/g, "_")
if (request.model.provider === "openai" || request.model.provider === "azure" || modelID.startsWith("openai/"))
return id.slice(0, 40)
return id
},
}
const requireAssistantAfterTool = request.model.compatibility?.requireAssistantAfterTool ?? mistral
const bridgeTools = () => {
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool") messages.push({ role: "assistant", content: "Done." })
}
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => {
if (pendingImages.length === 0) return
bridgeTools()
messages.push({ role: "user", content: pendingImages.splice(0) })
}
for (const message of request.messages) {
if (message.role === "user") bridgeTools()
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
if (pendingImages.length > 0) {
@@ -557,16 +526,14 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
)
continue
}
if (message.role === "assistant" && message.content.every((part) => part.type === "text" && part.text.trim() === ""))
continue
if (message.role === "tool") {
const lowered = yield* lowerToolMessages(message, lowering)
const lowered = yield* lowerToolMessages(message, options)
messages.push(...lowered.messages)
pendingImages.push(...lowered.images)
continue
}
flushImages()
messages.push(...(yield* lowerMessage(message, reasoningField, requireReasoning, lowering)))
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
}
flushImages()
return messages
+8 -47
View File
@@ -7,7 +7,7 @@ import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { optionalArray, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -32,40 +32,6 @@ const OpenAIResponsesImageGenerationTool = Schema.Struct({
size: Schema.optional(OpenAIImage.Size),
})
const OpenAIResponsesHostedToolItem = Schema.Union([
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("computer_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
call_id: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
pending_safety_checks: Schema.optional(Schema.Array(JsonObject)),
}),
[JsonObject],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("web_search_preview_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
}),
[JsonObject],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("image_generation_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
result: optionalNull(Schema.String),
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
revised_prompt: optionalNull(Schema.String),
}),
[JsonObject],
),
])
const OpenAIResponsesTools = Schema.Union([OpenResponses.Tool, OpenAIResponsesImageGenerationTool])
const OpenAIResponsesToolChoice = Schema.Union([
@@ -75,7 +41,6 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
}
@@ -89,21 +54,21 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
// Replayed items are paired with stored server state by id, so a foreign or
// synthetic token can fail request validation even when `call_id` pairing is
// intact. Only resend ids in each item kind's own grammar; hosted tool
// items keep generic validation because every hosted tool mints its own
// references keep generic validation because every hosted tool mints its own
// prefix. The same allowlist approach codex uses before resending history
// (codex-rs core/src/client.rs, `prepare_response_items_for_request`).
const ITEM_ID_PREFIXES: Record<OpenResponses.ItemKind, ReadonlyArray<string>> = {
message: ["msg_"],
reasoning: ["rs_"],
"function-call": ["fc_"],
// Every hosted tool mints its own id prefix, so items keep generic validation.
"hosted-tool": [],
// Every hosted tool mints its own id prefix, so references keep generic
// validation only.
reference: [],
}
const extension = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
acceptsItemID: (kind: OpenResponses.ItemKind, id: string) => {
const prefixes = ITEM_ID_PREFIXES[kind]
return prefixes.length === 0 || prefixes.some((prefix) => id.startsWith(prefix))
@@ -140,8 +105,6 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
: { type: "function" as const, name },
})
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
const body = yield* OpenResponses.fromRequestWithExtension(
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
@@ -149,7 +112,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
return yield* decodeBody({
return {
...body,
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
tools:
@@ -160,7 +123,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
),
tool_choice:
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
})
} satisfies OpenAIResponsesBody
})
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: ResponsesHostedTools.Item) {
@@ -203,9 +166,7 @@ const HOSTED_TOOLS = {
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
+7 -20
View File
@@ -210,9 +210,10 @@ export const errorText = (error: unknown) => {
* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
* decoder, optionally filters named events, and drops empty / `[DONE]`
* keep-alive events so the protocol event schema sees one JSON string per
* element. Retry control events are ignored without interrupting the stream.
* Decoder failures become provider output errors so the public error channel
* stays `AIError`.
* element. The SSE channel emits a
* `Retry` control event on its error channel; we drop it here (we don't
* implement client-driven retries). Decoder failures become provider output
* errors so the public error channel stays `AIError`.
*/
export const sseFraming = (
bytes: Stream.Stream<Uint8Array, AIError>,
@@ -220,23 +221,9 @@ export const sseFraming = (
): Stream.Stream<string, AIError> =>
bytes.pipe(
Stream.decodeText(),
Stream.mapAccumEffect(
() => {
const output: Sse.Event[] = []
return {
output,
parser: Sse.makeParser((event) => {
if (event._tag === "Event") output.push(event)
}),
}
},
(state, chunk) =>
Effect.gen(function* () {
const error = state.parser.feed(chunk)
if (error) return yield* eventError("sse", error.message)
return [state, state.output.splice(0)] as const
}),
),
Stream.pipeThroughChannel(Sse.decode()),
Stream.catchTag("Retry", () => Stream.empty),
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
Stream.filter(
(event) =>
(events === undefined || events.has(event.event)) &&
@@ -56,6 +56,7 @@ export const StreamOptions = Schema.Struct({
})
export const Options = Schema.Struct({
instructions: Schema.optional(Schema.String),
store: Schema.optional(Schema.Boolean),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
safetyIdentifier: Schema.optional(Schema.String),
+1 -41
View File
@@ -1,52 +1,15 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import type { LLMRequest } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalNull, ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
const ADAPTER = "xai-responses"
const NAME = "xAI Responses"
const XAIResponsesHostedToolItem = Schema.Union([
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("x_search_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
action: optionalNull(JsonObject),
}),
[JsonObject],
),
Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("image_generation_call"),
id: Schema.String,
status: Schema.optional(Schema.String),
result: Schema.optional(Schema.Unknown),
error: Schema.optional(Schema.Unknown),
}),
[JsonObject],
),
])
const XAIResponsesBody = Schema.Struct({
...OpenResponses.coreFields,
input: Schema.Array(Schema.Union([OpenResponses.InputItem, XAIResponsesHostedToolItem])),
stream: Schema.Literal(true),
})
const extension = {
id: ADAPTER,
name: NAME,
lowerHostedToolItem: (item: unknown) => (Schema.is(XAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.Extension
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
return yield* decodeBody(yield* OpenResponses.fromRequestWithExtension(request, extension))
})
const HOSTED_TOOLS = {
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
x_search_call: { name: "x_search", input: (item) => item.action ?? {} },
@@ -72,10 +35,7 @@ const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
export const protocol = Protocol.make({
id: ADAPTER,
body: {
schema: XAIResponsesBody,
from: fromRequest,
},
body: OpenResponses.protocol.body,
stream: {
event: OpenResponses.protocol.stream.event,
initial: (request) => OpenResponses.initial(request, extension),
-58
View File
@@ -1,58 +0,0 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { profiles } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cerebras")
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = OpenAICompatibleChat.route.with({
id: "cerebras-chat",
provider: id,
endpoint: { baseURL: profiles.cerebras.baseURL },
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: baseURL ?? profiles.cerebras.baseURL },
auth: AuthOptions.bearer(input, "CEREBRAS_API_KEY"),
})
return {
id,
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
}),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
-2
View File
@@ -3,7 +3,6 @@ export * as AnthropicCompatible from "./anthropic-compatible.js"
export * as AmazonBedrock from "./amazon-bedrock.js"
export * as AmazonBedrockMantle from "./amazon-bedrock-mantle.js"
export * as Azure from "./azure.js"
export * as Cerebras from "./cerebras.js"
export * as Cloudflare from "./cloudflare.js"
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare.js"
export * as Google from "./google.js"
@@ -15,6 +14,5 @@ export * as OpenAI from "./openai.js"
export * as OpenAICompatible from "./openai-compatible.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
export * as OpenRouter from "./openrouter.js"
export * as TogetherAI from "./togetherai.js"
export * as XAI from "./xai.js"
export * as ZAI from "./zai.js"
-58
View File
@@ -1,58 +0,0 @@
import type { ProviderPackage } from "../provider-package.js"
import { OpenAICompatibleChat } from "../protocols/openai-compatible-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { profiles } from "./openai-compatible-profile.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("togetherai")
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const route = OpenAICompatibleChat.route.with({
id: "togetherai-chat",
provider: id,
endpoint: { baseURL: profiles.togetherai.baseURL },
})
export const routes = [route]
export const configure = (input: LanguageModelOptions = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
const configured = route.with({
...defaults,
endpoint: { baseURL: baseURL ?? profiles.togetherai.baseURL },
auth: AuthOptions.bearer(input, ["TOGETHER_API_KEY", "TOGETHER_AI_API_KEY"]),
})
return {
id,
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
}),
configure,
}
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
-3
View File
@@ -153,11 +153,8 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
)({
toolSchema: Schema.optional(LanguageModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
/** Require every assistant message to include its reasoning field, even when empty. */
requireReasoning: Schema.optional(Schema.Boolean),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
requireAssistantAfterTool: Schema.optional(Schema.Boolean),
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
+1 -22
View File
@@ -3,7 +3,7 @@ import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../src/index.js"
import { Auth } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { AmazonBedrock, GoogleVertexMessages } from "../src/providers.js"
import { AmazonBedrock } from "../src/providers.js"
import * as AnthropicMessages from "../src/protocols/anthropic-messages.js"
import * as Gemini from "../src/protocols/gemini.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
@@ -86,27 +86,6 @@ describe("applyCachePolicy", () => {
}),
)
it.effect("'auto' emits Anthropic cache markers on Vertex", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
"claude-opus-4-8",
),
system: "You are concise.",
tools: [{ name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } }],
prompt: "hi",
}),
)
expect(prepared.body).toMatchObject({
tools: [{ name: "lookup", cache_control: { type: "ephemeral" } }],
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
})
}),
)
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1,32 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "cerebras",
"route": "cerebras-chat",
"transport": "http",
"model": "gpt-oss-120b",
"tags": ["prefix:cerebras-chat", "provider:cerebras", "text", "golden"],
"name": "cerebras-chat/cerebras-gpt-oss-120b-text",
"recordedAt": "2026-08-25T23:55:27.619Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.cerebras.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-oss-120b\",\"messages\":[{\"role\":\"system\",\"content\":\"You are concise.\"},{\"role\":\"user\",\"content\":\"Reply exactly with: Hello!\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":256}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\"The\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\" user says: \\\"Reply exactly with: Hello!\\\" So we must output exactly\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\" \\\"Hello!\\\" with no extra characters, no formatting\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\". Ensure no extra spaces or new\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\"lines? Probably just \\\"Hello!\\\".\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"reasoning\":\" Usually we output exactly that.\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{\"content\":\"Hello!\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-cd45cd6e-886a-433e-8ba9-caca78c1f13a\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_e2cabf4999eb0aead3d1\",\"object\":\"chat.completion.chunk\",\"usage\":{\"total_tokens\":142,\"completion_tokens\":58,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0,\"reasoning_tokens\":46},\"prompt_tokens\":84,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"time_info\":{\"created\":1787702127.645281,\"queue_time\":0.003817115,\"prompt_time\":0.001587193,\"completion_time\":0.029805929,\"total_time\":0.036823272705078125}}\n\ndata: [DONE]\n\n"
}
}
]
}
@@ -1,32 +0,0 @@
{
"version": 1,
"metadata": {
"provider": "cerebras",
"route": "cerebras-chat",
"transport": "http",
"model": "gpt-oss-120b",
"tags": ["prefix:cerebras-chat", "provider:cerebras", "tool", "tool-call", "golden"],
"name": "cerebras-chat/cerebras-gpt-oss-120b-tool-call",
"recordedAt": "2026-08-25T23:55:28.454Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.cerebras.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-oss-120b\",\"messages\":[{\"role\":\"system\",\"content\":\"Call tools exactly as requested.\"},{\"role\":\"user\",\"content\":\"Call get_weather with city exactly Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":512}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream; charset=utf-8"
},
"body": "data: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"reasoning\":\"We\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"reasoning\":\" need to call the function get_weather with city \\\"\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"reasoning\":\"Paris\\\".\"},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"},\"type\":\"function\",\"id\":\"3d860cefe\",\"index\":0}]},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"type\":\"function\",\"index\":0}]},\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"id\":\"chatcmpl-402a09cc-8668-446d-8f9c-3e4121f5fa51\",\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\",\"index\":0}],\"created\":1787702127,\"model\":\"gpt-oss-120b\",\"system_fingerprint\":\"fp_4cfabdd6620dc0120785\",\"object\":\"chat.completion.chunk\",\"usage\":{\"total_tokens\":174,\"completion_tokens\":37,\"completion_tokens_details\":{\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0,\"reasoning_tokens\":13},\"prompt_tokens\":137,\"prompt_tokens_details\":{\"cached_tokens\":0}},\"time_info\":{\"created\":1787702127.8019717,\"queue_time\":0.31196235,\"prompt_time\":0.005234764,\"completion_time\":0.020198402,\"total_time\":0.702225923538208}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -32,7 +32,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Call get_weather once, then reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
@@ -62,7 +62,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_ws_weather\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"max_output_tokens\":50,\"previous_response_id\":\"resp_ws_tool_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
@@ -32,7 +32,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
@@ -81,7 +81,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_reconnect_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
@@ -32,7 +32,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
@@ -81,7 +81,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"previous_response_id\":\"resp_ws_rejection_1\",\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
@@ -91,7 +91,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"instructions\":\"Follow the user's exact reply instruction.\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Follow the user's exact reply instruction.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"message\",\"id\":\"msg_ws_rejection_1\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"store\":false,\"max_output_tokens\":30,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"}}"
},
{
"direction": "server",
File diff suppressed because one or more lines are too long
@@ -26,7 +26,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true,\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
},
"response": {
"status": 200,
@@ -18,7 +18,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true,\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
},
"response": {
"status": 200,
@@ -25,7 +25,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true,\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}"
},
"response": {
"status": 200,
@@ -43,7 +43,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0ad67c31d9ddad95016a869efbd02487d1a51eed850e6f87f5\",\"summary\":[],\"encrypted_content\":\"gAAAAABqhp79U9UPKmTdmo9tmdil0C2KXpkFqUc4MNkYHT53Lzos9omncFPg76QzUmmSOdBcajisWBEo-xiTCvhp135uACUq8TJcdw4DluieYq6dWszijy28PFFfeO-6MmHwi7zeln1Z202zErJUEyuf1bML68VAeam5PqlMLG-a4-pmnWiH2ExWKibTUX37QoMQoArrkccJOCmxwDflV_kWDPMFxQVDfeMg9fd1gVv2u-x1Mjk0b9mJDOq0Fe5Gh-IkpWzfXgZTdptFmCM75cksvs61Rqsx6P33czal-LSixEF0WMizCvbMQmqKGs7MKGMeoa6j6vWOnB3ICIbv6FShnSaTpZWJFwejvOurkfuxa-2q6xVDZsBoQCgMWPHsqLxwAo1JKdfBk0pMvSuvpw2BRxykUZ1ULCYJ-BypST65292-EuSZFIuXPMPir-_raSCTsgsZNMscDG6ll3qksDTDS6_o5NutD7Ra-WZzaUe_HQlSLKLACTc4qv2EK1QoC4aYv4goxkTSx17WhS2D86lILgkUd-TIHjJ6iR3uxSNx7YeBNxiJgddIAEjAaSrdF-WDouSNT9k3efd5HhT3zahIOMKgb3XIQzFOYWfWgea5-SbaIdKwne9hU0QyhcBQs6yoifSg-fJZtahbPb-GCDYnOLlH-bV94vldoccb-2P1JdB3jaLj5tJUecfr2H4qiu8MgkPj0TkwYNbJynYmJo9H5Lm-XJ9gfzIXzJh0arKwsS4gwDLf4J3LOEF3WEW3mknOjjb9PrLmHRYXQQh9tTiX9ILPZpbufkyCurTUMQgWiSCitXBC6FoLXRHilSmb-6_avBnlUMziMfey-FkKvRfiPox6BaJrnOq6SGlOv11y7EKvzrn29la7HKPygYenDAkyq2mq0Zk2nLWNmJcv9sQTBrkBdFMmJYPi2J2im8XD5MmAjEL8R4FCBHoPIIZ6pENQykvH8PhpWKuzF5gJlY3Vwz4iJ0Qb9TrNI0hzBoI1U0LeB5FJ2HgjZQwCFF5x3ubh72xrUsFpuyYyYPa8GDT0Bo-LW_IlJ_mN4EwI5Nk9n-8Bt015yxsfpa5YaDeCeQFcdj8SD0UAd7QWtGACpzKcIj1-vJJU7OiwscV_v1dLvoiEe1ehI9jcvPn28TgHlo_dippe0iMN4FAm1Bf8vtWVMFDvfV1rPv1pAFFnSa9XqFszD5Exo_xzcQEoKXvQv3OnUtoiM4Db4uadClazLjoep2TQgHcJBVbTbLySTVPmok4ROFQZsU_mq4vu2M__d8HOjADfIIYz5VLVQKNpo0Hv_QkT2bn56Q==\"},{\"type\":\"function_call\",\"id\":\"fc_0ad67c31d9ddad95016a869efd126887d1b7e2f17f155cb5dc\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true,\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0ad67c31d9ddad95016a869efbd02487d1a51eed850e6f87f5\",\"summary\":[],\"encrypted_content\":\"gAAAAABqhp79U9UPKmTdmo9tmdil0C2KXpkFqUc4MNkYHT53Lzos9omncFPg76QzUmmSOdBcajisWBEo-xiTCvhp135uACUq8TJcdw4DluieYq6dWszijy28PFFfeO-6MmHwi7zeln1Z202zErJUEyuf1bML68VAeam5PqlMLG-a4-pmnWiH2ExWKibTUX37QoMQoArrkccJOCmxwDflV_kWDPMFxQVDfeMg9fd1gVv2u-x1Mjk0b9mJDOq0Fe5Gh-IkpWzfXgZTdptFmCM75cksvs61Rqsx6P33czal-LSixEF0WMizCvbMQmqKGs7MKGMeoa6j6vWOnB3ICIbv6FShnSaTpZWJFwejvOurkfuxa-2q6xVDZsBoQCgMWPHsqLxwAo1JKdfBk0pMvSuvpw2BRxykUZ1ULCYJ-BypST65292-EuSZFIuXPMPir-_raSCTsgsZNMscDG6ll3qksDTDS6_o5NutD7Ra-WZzaUe_HQlSLKLACTc4qv2EK1QoC4aYv4goxkTSx17WhS2D86lILgkUd-TIHjJ6iR3uxSNx7YeBNxiJgddIAEjAaSrdF-WDouSNT9k3efd5HhT3zahIOMKgb3XIQzFOYWfWgea5-SbaIdKwne9hU0QyhcBQs6yoifSg-fJZtahbPb-GCDYnOLlH-bV94vldoccb-2P1JdB3jaLj5tJUecfr2H4qiu8MgkPj0TkwYNbJynYmJo9H5Lm-XJ9gfzIXzJh0arKwsS4gwDLf4J3LOEF3WEW3mknOjjb9PrLmHRYXQQh9tTiX9ILPZpbufkyCurTUMQgWiSCitXBC6FoLXRHilSmb-6_avBnlUMziMfey-FkKvRfiPox6BaJrnOq6SGlOv11y7EKvzrn29la7HKPygYenDAkyq2mq0Zk2nLWNmJcv9sQTBrkBdFMmJYPi2J2im8XD5MmAjEL8R4FCBHoPIIZ6pENQykvH8PhpWKuzF5gJlY3Vwz4iJ0Qb9TrNI0hzBoI1U0LeB5FJ2HgjZQwCFF5x3ubh72xrUsFpuyYyYPa8GDT0Bo-LW_IlJ_mN4EwI5Nk9n-8Bt015yxsfpa5YaDeCeQFcdj8SD0UAd7QWtGACpzKcIj1-vJJU7OiwscV_v1dLvoiEe1ehI9jcvPn28TgHlo_dippe0iMN4FAm1Bf8vtWVMFDvfV1rPv1pAFFnSa9XqFszD5Exo_xzcQEoKXvQv3OnUtoiM4Db4uadClazLjoep2TQgHcJBVbTbLySTVPmok4ROFQZsU_mq4vu2M__d8HOjADfIIYz5VLVQKNpo0Hv_QkT2bn56Q==\"},{\"type\":\"function_call\",\"id\":\"fc_0ad67c31d9ddad95016a869efd126887d1b7e2f17f155cb5dc\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_qrzOfKDfzaq8fqbSNNVHlNsV\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}"
},
"response": {
"status": 200,
@@ -21,7 +21,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"max_output_tokens\":40,\"temperature\":0,\"stream\":true,\"instructions\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}"
"body": "{\"model\":\"gpt-4o-mini\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
},
"response": {
"status": 200,
@@ -21,7 +21,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"max_output_tokens\":40,\"temperature\":0,\"stream\":true,\"instructions\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}"
"body": "{\"model\":\"grok-4.5\",\"input\":[{\"role\":\"system\",\"content\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"type\":\"function_call\",\"call_id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"arguments\":\"{}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_pdf_1\",\"output\":[{\"type\":\"input_text\",\"text\":\"PDF read successfully\"},{\"type\":\"input_file\",\"filename\":\"verification.pdf\",\"file_data\":\"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"parameters\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"max_output_tokens\":40,\"temperature\":0,\"stream\":true}"
},
"response": {
"status": 200,
@@ -26,8 +26,6 @@ describe("provider package entrypoints", () => {
import("@opencode-ai/ai/providers/amazon-bedrock/mantle"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/chat"),
import("@opencode-ai/ai/providers/amazon-bedrock/mantle/responses"),
import("@opencode-ai/ai/providers/togetherai"),
import("@opencode-ai/ai/providers/cerebras"),
])
for (const module of modules) expect(module.model).toBeFunction()
@@ -5,7 +5,6 @@ import { CacheHint, LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinit
import { Auth, LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import * as AnthropicMessages from "../../src/protocols/anthropic-messages.js"
import { GoogleVertexMessages } from "../../src/providers.js"
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios.js"
import { it } from "../lib/effect.js"
import { dynamicResponse, fixedResponse } from "../lib/http.js"
@@ -28,12 +27,6 @@ const compileUnsignedReasoning = (model: LLMRequest["model"]) =>
}),
)
const vertexOpus48 = GoogleVertexMessages.configure({
accessToken: "test",
location: "global",
project: "test",
}).model("claude-opus-4-8")
const request = LLM.request({
id: "req_1",
model,
@@ -293,149 +286,6 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("keeps a terminal Vertex system update in the tool-result turn", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: vertexOpus48,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.system("Operator update."),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: '"Done."',
is_error: undefined,
cache_control: undefined,
},
{
type: "text",
text: "<system-update>\nOperator update.\n</system-update>",
cache_control: undefined,
},
],
},
])
}),
)
it.effect("preserves folded tool-result system updates across multi-turn Vertex history", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: vertexOpus48,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.system("Operator update."),
Message.assistant("Acknowledged."),
Message.user("Next step."),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: '"Done."',
is_error: undefined,
cache_control: undefined,
},
{
type: "text",
text: "<system-update>\nOperator update.\n</system-update>",
cache_control: undefined,
},
],
},
{ role: "assistant", content: [{ type: "text", text: "Acknowledged." }] },
{ role: "user", content: [{ type: "text", text: "Next step." }] },
])
}),
)
it.effect("keeps a terminal direct Anthropic system update native", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: opus48,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
Message.system("Operator update."),
],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{
role: "assistant",
content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }],
},
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "call_1",
content: '"Done."',
is_error: undefined,
cache_control: undefined,
},
],
},
{
role: "system",
content: [{ type: "text", text: "Operator update.", cache_control: undefined }],
},
])
}),
)
it.effect("keeps an ordinary terminal Vertex system update native", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: vertexOpus48,
messages: [Message.user("Before."), Message.system("Operator update.")],
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Before." }] },
{
role: "system",
content: [{ type: "text", text: "Operator update.", cache_control: undefined }],
},
])
}),
)
it.effect("rejects a system update between a local tool call and its result", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
@@ -1,6 +1,5 @@
import * as Anthropic from "../../src/providers/anthropic.js"
import * as AnthropicCompatible from "../../src/providers/anthropic-compatible.js"
import { Cerebras, TogetherAI } from "../../src/providers/index.js"
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare.js"
import * as Google from "../../src/providers/google.js"
import * as OpenAI from "../../src/providers/openai.js"
@@ -48,10 +47,11 @@ const cloudflareWorkersAITools = cloudflareWorkers.model("@cf/openai/gpt-oss-20b
const deepseek = OpenAICompatible.deepseek
.configure({ apiKey: process.env.DEEPSEEK_API_KEY ?? "fixture" })
.model("deepseek-chat")
const together = TogetherAI.configure({
apiKey: process.env.TOGETHER_API_KEY ?? process.env.TOGETHER_AI_API_KEY ?? "fixture",
}).model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
const cerebras = Cerebras.configure({ apiKey: process.env.CEREBRAS_API_KEY ?? "fixture" }).model("gpt-oss-120b")
const together = OpenAICompatible.togetherai
.configure({
apiKey: process.env.TOGETHER_AI_API_KEY ?? "fixture",
})
.model("meta-llama/Llama-3.3-70B-Instruct-Turbo")
const groq = OpenAICompatible.groq
.configure({ apiKey: process.env.GROQ_API_KEY ?? "fixture" })
.model("llama-3.3-70b-versatile")
@@ -193,27 +193,8 @@ describeRecordedGoldenScenarios([
name: "TogetherAI Llama 3.3 70B",
prefix: "openai-compatible-chat",
model: together,
requires: ["TOGETHER_API_KEY"],
scenarios: [
{
id: "text",
cassette: "openai-compatible-chat/togetherai-streams-text",
prompt: "Reply with exactly: Hello!",
maxTokens: 20,
},
{ id: "tool-call", cassette: "openai-compatible-chat/togetherai-streams-tool-call" },
],
},
{
name: "Cerebras GPT OSS 120B",
prefix: "cerebras-chat",
model: cerebras,
requires: ["CEREBRAS_API_KEY"],
scenarios: [
{ id: "text", maxTokens: 256, temperature: false },
{ id: "tool-call", maxTokens: 512, temperature: false },
{ id: "tool-loop", maxTokens: 512, temperature: false, timeout: 30_000 },
],
requires: ["TOGETHER_AI_API_KEY"],
scenarios: ["text", "tool-call"],
},
{
name: "Groq Llama 3.3 70B",
@@ -1,151 +0,0 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { LLM, Message, ToolDefinition } from "../../src/index.js"
import { Cerebras, TogetherAI } from "../../src/providers/index.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
describe("native OpenAI-compatible providers", () => {
it.effect("preserves native Together AI and Cerebras provider and route identities", () =>
Effect.gen(function* () {
const together = TogetherAI.configure({ apiKey: "fixture" }).model("meta-llama/Llama-3.3-70B")
const cerebras = Cerebras.configure({ apiKey: "fixture" }).model("qwen-3-235b-a22b")
expect(together).toMatchObject({
provider: "togetherai",
compatibility: { maxTokensField: "max_tokens", supportsStore: false, supportsStrictMode: false },
route: { id: "togetherai-chat", protocol: "openai-chat" },
})
expect(together.route.endpoint.baseURL).toBe("https://api.together.xyz/v1")
expect(cerebras).toMatchObject({
provider: "cerebras",
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
route: { id: "cerebras-chat", protocol: "openai-chat" },
})
expect(cerebras.route.endpoint.baseURL).toBe("https://api.cerebras.ai/v1")
}),
)
it.effect("applies native provider request defaults even with a custom gateway URL", () =>
Effect.gen(function* () {
const together = yield* compileRequest(
LLM.request({
model: TogetherAI.configure({ apiKey: "fixture", baseURL: "https://gateway.example/v1" }).model("llama"),
prompt: "Use a tool.",
generation: { maxTokens: 32 },
tools: [
ToolDefinition.make({ name: "lookup", description: "Look up data", inputSchema: { type: "object" } }),
],
providerOptions: { store: true },
}),
)
expect(together.body).toMatchObject({
max_tokens: 32,
stream_options: { include_usage: true },
tools: [{ function: { name: "lookup" } }],
})
expect(together.body).not.toHaveProperty("max_completion_tokens")
expect(together.body).not.toHaveProperty("store")
expect(together.body.tools?.[0]?.function).not.toHaveProperty("strict")
const cerebras = yield* compileRequest(
LLM.request({
model: Cerebras.configure({ apiKey: "fixture", baseURL: "https://gateway.example/v1" }).model("qwen"),
generation: { maxTokens: 48 },
messages: [
Message.user("Think first."),
Message.assistant([
{ type: "reasoning", text: "A deliberate thought." },
{ type: "text", text: "An answer." },
]),
Message.user("Continue."),
],
providerOptions: { store: true },
}),
)
expect(cerebras.body).toMatchObject({
max_tokens: 48,
messages: [
{ role: "user", content: "Think first." },
{ role: "assistant", content: "An answer.", reasoning: "A deliberate thought." },
{ role: "user", content: "Continue." },
],
})
expect(cerebras.body).not.toHaveProperty("max_completion_tokens")
expect(cerebras.body).not.toHaveProperty("store")
expect(cerebras.body.messages[1]).not.toHaveProperty("reasoning_content")
}),
)
it.effect("maps package settings onto native executable models", () =>
Effect.gen(function* () {
for (const native of [TogetherAI, Cerebras]) {
const selected = native.model("provider-model", {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" },
})
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
}
}),
)
it.effect("resolves provider environment credentials and preserves deprecated Together credentials", () =>
Effect.gen(function* () {
const scenarios = [
{
model: TogetherAI.configure().model("llama"),
env: { TOGETHER_API_KEY: "together-primary", TOGETHER_AI_API_KEY: "together-legacy" },
token: "together-primary",
url: "https://api.together.xyz/v1/chat/completions",
},
{
model: TogetherAI.configure().model("llama"),
env: { TOGETHER_AI_API_KEY: "together-legacy" },
token: "together-legacy",
url: "https://api.together.xyz/v1/chat/completions",
},
{
model: Cerebras.configure().model("qwen"),
env: { CEREBRAS_API_KEY: "cerebras-secret" },
token: "cerebras-secret",
url: "https://api.cerebras.ai/v1/chat/completions",
},
]
yield* Effect.forEach(scenarios, (scenario) =>
LLM.generate(LLM.request({ model: scenario.model, prompt: "Say hello." })).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe(scenario.url)
expect(request.headers.get("authorization")).toBe(`Bearer ${scenario.token}`)
return input.respond(
sseEvents(
{ id: "chatcmpl_fixture", choices: [{ delta: { content: "Hello" }, finish_reason: null }] },
{ id: "chatcmpl_fixture", choices: [{ delta: {}, finish_reason: "stop" }] },
),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
),
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: scenario.env }))),
Effect.tap((response) => Effect.sync(() => expect(response.text).toBe("Hello"))),
),
)
}),
)
})
@@ -85,28 +85,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("omits empty and whitespace-only assistant messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Before."),
Message.assistant([]),
Message.assistant(""),
Message.assistant(" \n\t "),
Message.assistant("After."),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: "Before." },
{ role: "assistant", content: "After." },
])
}),
)
it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -169,56 +147,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves observed reasoning fields when reasoning is required", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, { compatibility: { requireReasoning: true } }),
messages: [
Message.assistant([
{
type: "reasoning",
text: "thinking",
providerMetadata: { openai: { reasoningField: "reasoning_text" } },
},
{ type: "text", text: "Hello" },
]),
Message.assistant("Done"),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning_text: "thinking" },
{ role: "assistant", content: "Done", reasoning_content: "" },
])
}),
)
it.effect("omits empty configured reasoning fields when reasoning is explicitly optional", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, {
compatibility: { reasoningField: "reasoning_text", requireReasoning: false },
}),
messages: [
Message.assistant([
{ type: "reasoning", text: "thinking" },
{ type: "text", text: "Hello" },
]),
Message.assistant("Done"),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "assistant", content: "Hello", reasoning_text: "thinking" },
{ role: "assistant", content: "Done" },
])
}),
)
it.effect("rejects reasoning fields that conflict with assistant message fields", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
@@ -438,35 +366,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("limits OpenAI and Azure Chat tool call IDs to 40 characters", () =>
Effect.gen(function* () {
const id = `call_${"a".repeat(48)}`
const models = [
model,
Azure.configure({ baseURL: "https://opencode-test.openai.azure.com/openai/", apiKey: "test" }).chat("gpt-4o"),
]
yield* Effect.forEach(models, (selected) =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: selected,
messages: [
Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]),
Message.tool({ id, name: "lookup", result: "Sunny" }),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", tool_calls: [{ id: id.slice(0, 40) }] },
{ role: "tool", tool_call_id: id.slice(0, 40) },
])
}),
)
}),
)
it.effect("preserves structured tool errors for the model", () =>
Effect.gen(function* () {
const error = { error: { type: "unknown", message: "Tool execution interrupted" } }
@@ -532,30 +431,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("bridges image tool results before their synthetic user message when required", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, { compatibility: { requireAssistantAfterTool: true } }),
messages: [
Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: {} })]),
Message.tool({
id: "call_image",
name: "read",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" }],
},
}),
],
}),
)
expect(prepared.body.messages.map((message) => message.role)).toEqual(["assistant", "tool", "assistant", "user"])
expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
}),
)
it.effect("orders parallel tool responses before one aggregated vision message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -238,135 +238,6 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("normalizes tool call IDs for the selected model family", () =>
Effect.gen(function* () {
const longID = `call_${"a".repeat(48)}`
const cases = [
{ provider: "custom", model: "mistral-small", id: "toolu_01CBhTTz95qkd9LJMdC9sf8t", expected: "toolu01CB" },
{ provider: "custom", model: "devstral-small", id: "abc", expected: "abc000000" },
{ provider: "custom", model: "codestral-latest", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
{ provider: "custom", model: "pixtral-large", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
{ provider: "custom", model: "open-mixtral-8x22b", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
{ provider: "gateway", model: "anthropic/claude-sonnet-4", id: "call|item/+", expected: "call_item__" },
{ provider: "gateway", model: "openai/gpt-4o", id: longID, expected: longID.slice(0, 40) },
{ provider: "custom", model: "ordinary-model", id: "call|item/+", expected: "call|item/+" },
{ provider: "mistral", model: "zai-glm-5-2", id: "call_long_identifier", expected: "call_long_identifier" },
]
yield* Effect.forEach(cases, (item) =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAICompatibleChat.route
.with({ provider: item.provider, endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({ id: item.model }),
messages: [
Message.assistant([ToolCallPart.make({ id: item.id, name: "lookup", input: {} })]),
Message.tool({ id: item.id, name: "lookup", result: { type: "content", value: [] } }),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", tool_calls: [{ id: item.expected }] },
{ role: "tool", tool_call_id: item.expected },
])
}),
)
}),
)
it.effect("bridges tool results for Mistral-family models and honors compatibility overrides", () =>
Effect.gen(function* () {
const cases = [
{ id: "mistral-small", bridge: true },
{ id: "devstral-small", bridge: true },
{ id: "codestral-latest", bridge: true },
{ id: "pixtral-large", bridge: true },
{ id: "open-mixtral-8x22b", bridge: true },
{ id: "ordinary-model", bridge: false },
{ id: "ordinary-model", override: true, bridge: true },
{ id: "mistral-small", override: false, bridge: false },
] as const
yield* Effect.forEach(cases, (item) =>
Effect.gen(function* () {
const selected = OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({
id: item.id,
compatibility: "override" in item ? { requireAssistantAfterTool: item.override } : undefined,
})
const prepared = yield* compileRequest(
LLM.request({
model: selected,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
Message.user("What next?"),
],
}),
)
expect(prepared.body.messages.map((message) => message.role)).toEqual(
item.bridge ? ["assistant", "tool", "assistant", "user"] : ["assistant", "tool", "user"],
)
if (item.bridge) expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
}),
)
}),
)
it.effect("requires reasoning for DeepSeek models, providers, and endpoints unless explicitly overridden", () =>
Effect.gen(function* () {
const cases = [
{ id: "DeepSeek-V3", provider: "custom", baseURL: "https://api.custom.test/v1", required: true },
{ id: "custom-model", provider: "deepseek", baseURL: "https://api.custom.test/v1", required: true },
{ id: "custom-model", provider: "custom", baseURL: "https://API.DeepSeek.COM/v1", required: true },
{ id: "ordinary-model", provider: "custom", baseURL: "https://api.custom.test/v1", required: false },
{
id: "ordinary-model",
provider: "custom",
baseURL: "https://api.custom.test/v1",
compatibility: { requireReasoning: true, reasoningField: "reasoning" },
required: true,
field: "reasoning",
},
{
id: "deepseek-chat",
provider: "deepseek",
baseURL: "https://api.deepseek.com/v1",
compatibility: { requireReasoning: false },
required: false,
},
] as const
yield* Effect.forEach(cases, (item) =>
Effect.gen(function* () {
const selected = OpenAICompatibleChat.route
.with({ provider: item.provider, endpoint: { baseURL: item.baseURL } })
.model({ id: item.id, compatibility: "compatibility" in item ? item.compatibility : undefined })
const prepared = yield* compileRequest(
LLM.request({
model: selected,
messages: [
Message.assistant("Hello"),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
],
}),
)
const field = "field" in item ? item.field : "reasoning_content"
for (const message of prepared.body.messages.filter((message) => message.role === "assistant")) {
if (item.required) expect(message).toHaveProperty(field, "")
else expect(message).not.toHaveProperty(field)
}
}),
)
}),
)
it.effect("posts to the configured compatible endpoint and parses text usage", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -47,8 +47,10 @@ describe("Open Responses-compatible route", () => {
})
expect(prepared.body).toEqual({
model: "example-model",
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
instructions: "You are concise.",
input: [
{ role: "system", content: "You are concise." },
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
],
stream: true,
store: false,
include: ["reasoning.encrypted_content"],
@@ -82,12 +84,10 @@ describe("Open Responses-compatible route", () => {
const prepared = yield* compileRequest(
LLM.request({
model,
system: "Initial instructions.",
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
}),
)
expect(prepared.body.instructions).toBe("Initial instructions.")
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "developer", content: "Operator update." },
@@ -225,106 +225,6 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("replays only shared hosted tool items", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const items = [
{ type: "web_search_call", id: "ws_1", status: "completed" },
{ type: "x_search_call", id: "x_search_1", status: "completed" },
{ type: "future_call", id: "future_1", status: "completed" },
{ type: "file_search_call", id: "fs_1", queries: "not-an-array" },
]
const prepared = yield* compileRequest(
LLM.request({
model,
messages: items.map((item) =>
Message.assistant({
type: "tool-result",
id: item.id,
name: item.type,
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openresponses: { itemId: item.id } },
}),
),
}),
)
expect(prepared.body.input).toEqual([
items[0],
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[1]) }] },
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[2]) }] },
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[3]) }] },
])
}),
)
it.effect("routes response deltas by output index", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" },
{ type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "text", text: "Indexed", providerMetadata: { openresponses: { itemId: "msg_1" } } },
])
}),
)
it.effect("streams function calls without optional item ids through the shared baseline", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item },
{
type: "response.function_call_arguments.delta",
output_index: 1,
item_id: "opaque_item",
delta: '{"query":"shared"}',
},
{
type: "response.output_item.done",
output_index: 1,
item: { ...item, arguments: '{"query":"complete"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("finalizes pending function calls from completed response output", () =>
Effect.gen(function* () {
const model = configure({
@@ -136,7 +136,6 @@ describe("OpenAI Responses WebSocket recorded", () => {
expect(channel.opens()).toBe(1)
expect(channel.sent).toHaveLength(2)
expect(channel.sent[1]).toMatchObject({
instructions: "Call get_weather once, then reply exactly: Paris is sunny.",
previous_response_id: expect.any(String),
input: [{ type: "function_call_output", call_id: call.id, output: expect.any(String) }],
})
@@ -168,8 +167,8 @@ describe("OpenAI Responses WebSocket recorded", () => {
expect(channel.opens()).toBe(2)
expect(channel.sent[1]).not.toHaveProperty("previous_response_id")
expect(channel.sent[1]).toMatchObject({
instructions: "Follow the user's exact reply instruction.",
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Alpha." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Alpha." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Beta." }] },
@@ -205,8 +204,8 @@ describe("OpenAI Responses WebSocket recorded", () => {
expect(channel.sent[1]).toHaveProperty("previous_response_id", expect.any(String))
expect(channel.sent[2]).not.toHaveProperty("previous_response_id")
expect(channel.sent[2]).toMatchObject({
instructions: "Follow the user's exact reply instruction.",
input: [
{ role: "system", content: "Follow the user's exact reply instruction." },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Ready." }] },
{ role: "assistant", content: [{ type: "output_text", text: "Ready." }] },
{ role: "user", content: [{ type: "input_text", text: "Reply exactly: Recovered." }] },
@@ -112,8 +112,10 @@ describe("OpenAI Responses route", () => {
expect(prepared.body).toEqual({
model: "gpt-4.1-mini",
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
instructions: "You are concise.",
input: [
{ role: "system", content: "You are concise." },
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
],
store: false,
include: ["reasoning.encrypted_content"],
stream: true,
@@ -467,7 +469,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues an item-id-less tool call with only the new tool output", () =>
it.effect("continues a tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
@@ -483,6 +485,7 @@ describe("OpenAI Responses route", () => {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
@@ -827,9 +830,7 @@ describe("OpenAI Responses route", () => {
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
yield* exchange.driver
.create(undefined)
.pipe(Effect.flatMap((create) => Ref.set(message, create.message)))
yield* exchange.driver.create(undefined).pipe(Effect.flatMap((create) => Ref.set(message, create.message)))
return { frames: exchange.fallback(), complete: Effect.void }
}),
},
@@ -1594,8 +1595,8 @@ describe("OpenAI Responses route", () => {
)
expect(prepared.body).toMatchObject({
instructions: "You are concise. Continue from the provided history.",
input: [
{ role: "system", content: "You are concise. Continue from the provided history." },
{
role: "user",
content: [
@@ -2050,204 +2051,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("routes assistant text by output index when its item id disagrees", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" },
{ type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("Indexed")
expect(response.message.content).toEqual([
{ type: "text", text: "Indexed", providerMetadata: { openai: { itemId: "msg_1" } } },
])
}),
)
it.effect("routes interleaved function calls by output index", () =>
Effect.gen(function* () {
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "first", arguments: "" }
const second = { type: "function_call", id: "fc_2", call_id: "call_2", name: "second", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item: first },
{ type: "response.output_item.added", output_index: 3, item: second },
{ type: "response.function_call_arguments.delta", output_index: 1, item_id: "fc_2", delta: '{"a":' },
{ type: "response.function_call_arguments.delta", output_index: 3, item_id: "fc_1", delta: '{"b":' },
{
type: "response.function_call_arguments.done",
output_index: 3,
item_id: "fc_1",
arguments: '{"b":2}',
},
{
type: "response.function_call_arguments.done",
output_index: 1,
item_id: "fc_2",
arguments: '{"a":1}',
},
{ type: "response.output_item.done", output_index: 1, item: { ...first, arguments: '{"a":1}' } },
{ type: "response.output_item.done", output_index: 3, item: { ...second, arguments: '{"b":2}' } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
{ id: "call_1", text: '{"a":' },
{ id: "call_2", text: '{"b":' },
{ id: "call_2", text: "2}" },
{ id: "call_1", text: "1}" },
])
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "first", input: { a: 1 } }),
expect.objectContaining({ id: "call_2", name: "second", input: { b: 2 } }),
])
}),
)
it.effect("routes item-id-less function arguments by output index and prefers item completion", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item },
{
type: "response.function_call_arguments.delta",
output_index: 2,
item_id: "opaque_delta",
delta: '{"query":"streamed"}',
},
{
type: "response.function_call_arguments.done",
output_index: 2,
item_id: "opaque_done",
arguments: '{"query":"arguments-done"}',
},
{
type: "response.output_item.done",
output_index: 2,
item: { ...item, arguments: '{"query":"output-item-done"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
{ id: "call_1", text: '{"query":"streamed"}' },
])
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "output-item-done" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
}),
)
it.effect("routes reasoning summary events by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 4,
item: { type: "reasoning", id: "rs_1" },
},
{
type: "response.reasoning_summary_part.added",
output_index: 4,
item_id: "wrong_reasoning",
summary_index: 0,
},
{
type: "response.reasoning_summary_text.delta",
output_index: 4,
item_id: "wrong_reasoning",
summary_index: 0,
delta: "Thinking",
},
{
type: "response.reasoning_summary_part.done",
output_index: 4,
item_id: "wrong_reasoning",
summary_index: 0,
},
{
type: "response.output_item.done",
output_index: 4,
item: { type: "reasoning", id: "rs_1", encrypted_content: "state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Thinking")
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Thinking",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "state" } },
},
])
}),
)
it.effect("routes native reasoning text deltas by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_text.delta", output_index: 1, item_id: "wrong_reasoning", delta: "Raw" },
{ type: "response.output_item.done", output_index: 1, item: { type: "reasoning", id: "rs_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Raw")
}),
)
it.effect("falls back to item ids when an output index was not registered", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 9, item_id: "msg_1", delta: "Fallback" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("Fallback")
}),
)
it.effect("rejects output text events without the spec-required item id", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -2267,25 +2070,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("requires item ids even when their output index is known", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 0, delta: "Missing item ID" },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("response.output_text.delta is missing item_id")
}),
)
it.effect("ignores deltas without a matching output item", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -2336,25 +2120,13 @@ describe("OpenAI Responses route", () => {
it.effect("rejects function argument events without the spec-required item id", () =>
Effect.gen(function* () {
const events = [
{ type: "response.function_call_arguments.delta", output_index: 0, delta: "{}" },
{ type: "response.function_call_arguments.done", output_index: 0, arguments: "{}" },
{ type: "response.function_call_arguments.delta", delta: "{}" },
{ type: "response.function_call_arguments.done", arguments: "{}" },
]
for (const event of events) {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 0,
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" },
},
event,
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
Effect.provide(fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } }))),
Effect.flip,
)
@@ -2808,7 +2580,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves final reasoning metadata when storage is enabled", () =>
it.effect("closes reasoning summary parts when storage is not disabled", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLMRequest.update(request, { providerOptions: { store: true } })).pipe(
Effect.provide(
@@ -2826,7 +2598,7 @@ describe("OpenAI Responses route", () => {
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
{
type: "response.output_item.done",
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
@@ -2836,11 +2608,7 @@ describe("OpenAI Responses route", () => {
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
{
type: "reasoning-end",
id: "rs_1:1",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
},
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
])
}),
)
@@ -2945,7 +2713,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("replays complete reasoning items when storage is enabled", () =>
it.effect("references stored reasoning items by id", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -2955,7 +2723,7 @@ describe("OpenAI Responses route", () => {
{
type: "reasoning",
text: "Checked the previous diff.",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
providerMetadata: { openai: { itemId: "rs_1" } },
},
]),
],
@@ -2963,20 +2731,12 @@ describe("OpenAI Responses route", () => {
}),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: "encrypted-state",
},
])
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
}),
)
it.effect("replays complete hosted tool items when storage is enabled", () =>
it.effect("references stored provider-executed hosted tool results by id", () =>
Effect.gen(function* () {
const item = { type: "web_search_call", id: "ws_1", status: "completed" }
const prepared = yield* compileRequest(
LLM.request({
model,
@@ -2993,7 +2753,7 @@ describe("OpenAI Responses route", () => {
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "json", value: item },
result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
@@ -3005,15 +2765,14 @@ describe("OpenAI Responses route", () => {
)
expect(prepared.body.input).toEqual([
item,
{ type: "item_reference", id: "ws_1" },
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
])
}),
)
it.effect("replays stateless hosted tool results as native provider items", () =>
it.effect("continues stateless hosted tool results with their text form", () =>
Effect.gen(function* () {
const item = { type: "web_search_call", id: "ws_1", status: "completed" }
const prepared = yield* compileRequest(
LLM.request({
model,
@@ -3031,7 +2790,7 @@ describe("OpenAI Responses route", () => {
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "json", value: item },
result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
@@ -3044,74 +2803,6 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Search." }] },
{ type: "web_search_call", id: "ws_1", status: "completed" },
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
])
}),
)
it.effect("replays OpenAI hosted tool extensions but rejects foreign and unknown items", () =>
Effect.gen(function* () {
const items = [
{ type: "computer_call", id: "computer_1", status: "completed", action: { type: "click", x: 1, y: 2 } },
{ type: "x_search_call", id: "x_search_1", status: "completed" },
{ type: "future_call", id: "future_1", status: "completed" },
]
const prepared = yield* compileRequest(
LLM.request({
model,
messages: items.map((item) =>
Message.assistant({
type: "tool-result",
id: item.id,
name: item.type,
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: item.id } },
}),
),
}),
)
expect(prepared.body.input).toEqual([
items[0],
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[1]) }] },
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[2]) }] },
])
}),
)
it.effect("preserves foreign hosted tool results as portable message content when storage is enabled", () =>
Effect.gen(function* () {
const item = { type: "web_search_call", id: "ws_1", status: "completed" }
const prepared = yield* compileRequest(
LLM.request({
model: xaiModel,
messages: [
Message.assistant([
ToolCallPart.make({
id: "ws_1",
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
}),
{
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
]),
Message.user("Continue."),
],
providerOptions: { store: true },
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_1","status":"completed"}' }],
@@ -3121,35 +2812,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("does not replay hosted tool items whose result id differs from provider metadata", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "json", value: { type: "web_search_call", id: "ws_other", status: "completed" } },
providerExecuted: true,
providerMetadata: { openai: { itemId: "ws_1" } },
},
]),
],
}),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_text", text: '{"type":"web_search_call","id":"ws_other","status":"completed"}' }],
},
])
}),
)
it.effect("drops replayed item ids outside the server's grammar", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -3206,28 +2868,25 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("falls back to portable hosted results when stored item metadata is malformed", () =>
it.effect("keeps well-formed hosted references and drops malformed ones under storage", () =>
Effect.gen(function* () {
const hostedResult = (itemId: string) => {
const item = { type: "web_search_call", id: itemId, status: "completed" }
return [
ToolCallPart.make({
id: itemId,
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId } },
}),
{
type: "tool-result" as const,
id: itemId,
name: "web_search",
result: { type: "json" as const, value: item },
providerExecuted: true as const,
providerMetadata: { openai: { itemId } },
},
]
}
const hostedResult = (itemId: string) => [
ToolCallPart.make({
id: itemId,
name: "web_search",
input: { query: "effect 4" },
providerExecuted: true,
providerMetadata: { openai: { itemId } },
}),
{
type: "tool-result" as const,
id: itemId,
name: "web_search",
result: { type: "json" as const, value: { status: "completed" } },
providerExecuted: true as const,
providerMetadata: { openai: { itemId } },
},
]
const prepared = yield* compileRequest(
LLM.request({
model,
@@ -3236,13 +2895,7 @@ describe("OpenAI Responses route", () => {
}),
)
expect(prepared.body.input).toEqual([
{ type: "web_search_call", id: "ws_1", status: "completed" },
{
role: "user",
content: [{ type: "input_text", text: '{"type":"web_search_call","id":"bad ref","status":"completed"}' }],
},
])
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "ws_1" }])
}),
)
@@ -3288,43 +2941,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves foreign hosted images as portable image content when storage is enabled", () =>
Effect.gen(function* () {
const item = { type: "image_generation_call", id: "ig_1", status: "completed", result: "AQID" }
const prepared = yield* compileRequest(
LLM.request({
model: xaiModel,
messages: [
Message.assistant([
ToolCallPart.make({
id: "ig_1",
name: "image_generation",
input: {},
providerExecuted: true,
providerMetadata: { openai: { itemId: "ig_1" } },
}),
ToolResultPart.make({
id: "ig_1",
name: "image_generation",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
},
providerExecuted: true,
providerMetadata: { openai: { itemId: "ig_1" } },
}),
]),
],
providerOptions: { store: true },
}),
)
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] },
])
}),
)
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -3363,7 +2979,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("replays stateless reasoning without encrypted state", () =>
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -3393,12 +3009,6 @@ describe("OpenAI Responses route", () => {
expect(prepared.body).toMatchObject({
input: [
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: null,
},
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
@@ -3507,43 +3117,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("finalizes and replays a completed function call without an optional item id", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "weather" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
response.message,
Message.tool({ id: "call_1", name: "lookup", resultType: "json", result: { forecast: "sunny" } }),
],
}),
)
expect(prepared.body.input).toEqual([
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
])
}),
)
it.effect("emits only missing function arguments from the arguments done event", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -3768,37 +3341,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("reconciles an item-id-less pending function call from completed response output", () =>
Effect.gen(function* () {
const item = { type: "function_call", call_id: "call_1", name: "lookup", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item },
{
type: "response.function_call_arguments.delta",
output_index: 0,
item_id: "opaque_delta",
delta: '{"query":"partial',
},
{
type: "response.completed",
response: { id: "resp_1", output: [{ ...item, arguments: '{"query":"complete"}' }] },
},
),
),
),
)
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "complete" } }),
])
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toBeUndefined()
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
}),
)
it.effect("lets completed response output override arguments done", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -4102,15 +3644,13 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("replays hosted image results as portable content regardless of storage", () =>
it.effect("decodes image generation output as image content", () =>
Effect.gen(function* () {
const item = {
type: "image_generation_call",
id: "ig_1",
status: "completed",
result: "AQID",
action: "generate",
output_format: "png",
}
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
@@ -4123,9 +3663,6 @@ describe("OpenAI Responses route", () => {
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
providerMetadata: { openai: { itemId: "ig_1" } },
})
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
id: "ig_1",
name: "image_generation",
@@ -4134,52 +3671,7 @@ describe("OpenAI Responses route", () => {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
},
providerMetadata: { openai: { itemId: "ig_1" } },
})
const prepared = yield* Effect.forEach([false, true], (store) =>
compileRequest(LLM.request({ model, messages: [response.message], providerOptions: { store } })),
)
expect(prepared.map((request) => request.body.input)).toEqual([
[{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] }],
[{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] }],
])
}),
)
it.effect("preserves failed hosted tool results as portable error content", () =>
Effect.gen(function* () {
const item = {
type: "web_search_call",
id: "ws_failed",
status: "failed",
error: { code: "search_failed", message: "Search unavailable" },
}
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
result: { type: "error", value: item.error },
providerMetadata: { openai: { itemId: "ws_failed" } },
})
const prepared = yield* compileRequest(
LLM.request({ model, messages: [response.message], providerOptions: { store: true } }),
)
expect(prepared.body.input).toEqual([
{
role: "user",
content: [{ type: "input_text", text: '{"code":"search_failed","message":"Search unavailable"}' }],
},
])
}),
)
+7 -102
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message } from "../../src/index.js"
import { LLM, LLMEvent } from "../../src/index.js"
import { XAI } from "../../src/providers.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
@@ -14,9 +14,9 @@ import { sseEvents } from "../lib/sse.js"
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
describe("xAI Responses route", () => {
it.effect("composes the Open Responses baseline with xAI extensions", () =>
it.effect("extends the Open Responses baseline directly", () =>
Effect.gen(function* () {
expect(XAIResponses.protocol.body).not.toBe(OpenResponses.protocol.body)
expect(XAIResponses.protocol.body).toBe(OpenResponses.protocol.body)
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
@@ -70,106 +70,16 @@ describe("xAI Responses route", () => {
}),
)
it.effect("routes xAI reasoning summaries by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 3,
item: { type: "reasoning", id: "reasoning_1" },
},
{
type: "response.reasoning_summary_text.delta",
output_index: 3,
item_id: "wrong_reasoning",
summary_index: 0,
delta: "Considering.",
},
{
type: "response.output_item.done",
output_index: 3,
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
},
{ type: "response.completed", response: { id: "response_1" } },
),
),
),
)
expect(response.reasoning).toBe("Considering.")
expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({
providerMetadata: { xai: { itemId: "reasoning_1", reasoningEncryptedContent: "opaque" } },
})
}),
)
it.effect("replays xAI hosted tool items when continuing with the same provider", () =>
Effect.gen(function* () {
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "tool-result",
id: "x_search_1",
name: "x_search",
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { xai: { itemId: "x_search_1" } },
},
]),
],
}),
)
expect(prepared.body.input).toEqual([item])
}),
)
it.effect("replays shared and xAI hosted tool items but rejects OpenAI extensions", () =>
Effect.gen(function* () {
const items = [
{ type: "web_search_call", id: "ws_1", status: "completed" },
{ type: "image_generation_call", id: "ig_1", status: "completed", result: "AQID" },
{ type: "computer_call", id: "computer_1", status: "completed" },
]
const prepared = yield* compileRequest(
LLM.request({
model,
messages: items.map((item) =>
Message.assistant({
type: "tool-result",
id: item.id,
name: item.type,
result: { type: "json", value: item },
providerExecuted: true,
providerMetadata: { xai: { itemId: item.id } },
}),
),
}),
)
expect(prepared.body.input).toEqual([
items[0],
items[1],
{ role: "user", content: [{ type: "input_text", text: JSON.stringify(items[2]) }] },
])
}),
)
it.effect("parses xAI hosted tool items", () =>
Effect.gen(function* () {
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.done", item },
{
type: "response.output_item.done",
item: { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } },
},
{ type: "response.completed", response: { id: "response_1" } },
),
),
@@ -181,11 +91,6 @@ describe("xAI Responses route", () => {
name: "x_search",
input: { query: "news" },
providerExecuted: true,
providerMetadata: { xai: { itemId: "x_search_1" } },
})
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
result: { type: "json", value: item },
providerMetadata: { xai: { itemId: "x_search_1" } },
})
}),
)
-2
View File
@@ -20,7 +20,6 @@ type ScenarioInput =
readonly name?: string
readonly cassette?: string
readonly tags?: ReadonlyArray<string>
readonly prompt?: string
readonly maxTokens?: number
readonly temperature?: number | false
readonly timeout?: number
@@ -88,7 +87,6 @@ const runTarget = (target: TargetInput) => {
yield* runGoldenScenario(input.id, {
id: `recorded_${kebab(target.name).replaceAll("-", "_")}_${input.id.replaceAll("-", "_")}`,
model: target.model,
prompt: input.prompt,
maxTokens: input.maxTokens,
temperature: input.temperature,
})
+1 -2
View File
@@ -164,7 +164,6 @@ export const expectGoldenWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) =>
export interface GoldenScenarioContext {
readonly id: string
readonly model: LanguageModel
readonly prompt?: string
readonly maxTokens?: number
readonly temperature?: number | false
}
@@ -299,7 +298,7 @@ const runGeneratedConversation = (context: GoldenScenarioContext, steps: Readonl
const runTextScenario = (context: GoldenScenarioContext) =>
runGeneratedConversation(context, [
user(context.prompt ?? "Reply exactly with: Hello!"),
user("Reply exactly with: Hello!"),
assistant.expectText(/^Hello!?$/, {
system: "You are concise.",
maxTokens: context.maxTokens ?? 40,
-32
View File
@@ -102,38 +102,6 @@ describe("AI.Usage", () => {
expect(error.reason._tag).toBe("InvalidProviderOutput")
})
test("sseFraming ignores retry directives without ending the stream", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
Stream.make(
encoder.encode("retry: 1000\n\n"),
encoder.encode('data: {"first":true}\n\n'),
encoder.encode("retry: 2000\n\n"),
encoder.encode('data: {"second":true}\n\n'),
).pipe(Stream.rechunk(1)),
).pipe(Stream.runCollect),
)
expect(Array.from(frames)).toEqual(['{"first":true}', '{"second":true}'])
})
test("sseFraming preserves event data around retry directives", async () => {
const encoder = new TextEncoder()
const frames = await Effect.runPromise(
ProviderShared.sseFraming(
Stream.make(
encoder.encode("event: update\ndata: first\n"),
encoder.encode("retry: 1000\n"),
encoder.encode("data: second\n\n"),
).pipe(Stream.rechunk(1)),
new Set(["update"]),
).pipe(Stream.runCollect),
)
expect(Array.from(frames)).toEqual(["first\nsecond"])
})
test("visibleOutputTokens clamps reasoning > output to zero", () => {
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
@@ -16,7 +16,6 @@ import { useSessionKey } from "@/session/session-layout"
import { showToast } from "@/shell/notifications/toast"
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff"
import { beginWorkspaceSetup } from "@/workspaces/setup"
export function createNewSessionComposerAdapter(props: {
draftID: string
@@ -47,7 +46,7 @@ export function createNewSessionComposerAdapter(props: {
async start(selection, submission) {
const projectDirectory = location().directory
const worktree = props.worktree()
const workspace = await resolveSessionDirectory({
const sessionDirectory = await resolveSessionDirectory({
projectDirectory,
worktree,
branch: props.branch(),
@@ -55,8 +54,7 @@ export function createNewSessionComposerAdapter(props: {
serverSDK,
language,
})
if (!workspace) return
const sessionDirectory = workspace.directory
if (!sessionDirectory) return
const created = data.session.create({
agent: selection.agent,
@@ -86,7 +84,6 @@ export function createNewSessionComposerAdapter(props: {
serverSDK.scope,
SessionRouteKey.fromRoute(base64Encode(sessionDirectory), created.id),
)
if (workspace.initializing) beginWorkspaceSetup(created.id, workspace.ready)
const cleanupReady = startTransition(() => {
tabs.updateDraft(props.draftID, { worktree: undefined, branch: undefined })
local.session.promote(sessionDirectory, created.id, {
@@ -122,7 +119,7 @@ export function createNewSessionComposerAdapter(props: {
prompt: (input) =>
data.session.prompt({
...input,
gate: Promise.all([input.gate, afterCreation(async () => undefined), workspace.ready]),
gate: Promise.all([input.gate, afterCreation(async () => undefined)]),
}),
},
},
@@ -171,54 +168,28 @@ async function resolveSessionDirectory(input: {
serverSDK: ReturnType<typeof useServerSDK>
language: ReturnType<typeof useLanguage>
}) {
if (input.worktree !== "create") {
return {
directory: input.worktree === "main" ? input.projectDirectory : input.worktree,
ready: Promise.resolve(),
initializing: false,
}
}
if (input.worktree === "main") return input.projectDirectory
if (input.worktree !== "create") return input.worktree
const projectID = input.data.location.info({ directory: input.projectDirectory })?.project.id ?? ""
const pending = Promise.withResolvers<
{ directory: string; ready: Promise<void>; initializing: boolean } | undefined
>()
const unsubscribe = input.serverSDK.event.on("worktree.updated", (event) => {
if (event.data.projectID !== projectID || !event.data.directory) return
unsubscribe()
pending.resolve({ directory: event.data.directory, ready, initializing: true })
})
const creation = input.serverSDK.api.worktree
return input.serverSDK.api.worktree
.create({
projectID,
projectID: input.data.location.info({ directory: input.projectDirectory })?.project.id ?? "",
strategy: "git",
branch: input.branch,
directory: getDirectory(
input.data.location.info({ directory: input.projectDirectory })?.project.directory ?? input.projectDirectory,
),
})
.then(
(created) => ({ ok: true as const, created }),
(error) => ({ ok: false as const, error }),
)
const ready = creation.then((result) => {
unsubscribe()
if (result.ok) {
pending.resolve({ directory: result.created.directory, ready: Promise.resolve(), initializing: false })
return
}
showToast({
title: input.language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(input.language, result.error),
.then(async (created) => {
await input.serverSDK.api.location.get({ location: { directory: created.directory } })
return created.directory
})
.catch((error) => {
showToast({
title: input.language.t("prompt.toast.worktreeCreateFailed.title"),
description: errorMessage(input.language, error),
})
})
pending.resolve(undefined)
throw result.error
})
void ready.catch(() => undefined)
const workspace = await pending.promise
if (!workspace) return
await input.serverSDK.api.location.get({ location: { directory: workspace.directory } })
return workspace
}
function errorMessage(language: ReturnType<typeof useLanguage>, error: unknown) {
-1
View File
@@ -312,7 +312,6 @@ export const dict = {
"prompt.toast.modelAgentRequired.title": "Select an agent and model",
"prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.",
"prompt.toast.worktreeCreateFailed.title": "Failed to create worktree",
"session.workspace.initializing": "Setting up workspace",
"prompt.toast.sessionCreateFailed.title": "Failed to create session",
"prompt.toast.shellSendFailed.title": "Failed to send shell command",
"prompt.toast.commandSendFailed.title": "Failed to send command",
@@ -30,7 +30,6 @@ import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
import { useCommand } from "@/shell/commands/command"
import { useSettings } from "@/settings/model"
import { isWorkspaceSetupPending } from "@/workspaces/setup"
import { SessionTitleHeader } from "../session-identity-header"
type BackgroundTask = {
@@ -351,7 +350,8 @@ function MessageTimelineView(
return { ...value, worktree: value.canonical, worktrees: [] }
})
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
const showProjectIcon = () => import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
const showProjectIcon = () =>
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
const avatarProject = createMemo(() => {
if (!showProjectIcon()) return
const session = props.session.data.info()
@@ -465,8 +465,6 @@ function MessageTimelineView(
editToolDefaultOpen: props.data.editToolPartsExpanded,
disclosure: virtualized.disclosure,
centered: () => props.centered,
thinkingLabel: () =>
isWorkspaceSetupPending(sessionID() ?? "") ? language.t("session.workspace.initializing") : undefined,
padding: turnPadding,
anchor: props.anchor,
})
@@ -92,15 +92,6 @@ export function createTimelineVirtualizer(input: Input) {
initialOffset: () => (input.pinned() ? Number.MAX_SAFE_INTEGER : 0),
initialMeasurementsCache: initialMeasurements,
estimateSize: () => fallbackItemSize,
// Do not replace this with TanStack's default measurer: without a ResizeObserver entry,
// it returns the cached height instead of reading the element (TanStack/virtual#1183).
// Restored sessions, deferred tools, and rewrapped content can then keep stale heights;
// our fixed-height, overflow-clipped rows will hide their content. Keep observer entries
// on the cheap precomputed path, but make explicit measurements read the real height.
measureElement: (element, entry) => {
const box = entry?.borderBoxSize[0]
return box ? Math.round(box.blockSize) : element.offsetHeight
},
scrollToFn: (offset, options, instance) => {
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
elementScroll(offset, options, instance)
@@ -6,7 +6,6 @@ import { LocalProvider } from "@/providers/models/selection"
import type { ServerConnection } from "@/runtime/server/registry"
import { sessionHref } from "@/shell/routes/session"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
export function SessionUIProvider(
@@ -18,7 +17,6 @@ export function SessionUIProvider(
const navigate = useNavigate()
const params = useParams()
const data = useData()
const serverSDK = useServerSDK()
const tabs = useTabs()
const directory = () => props.directory
const href = (sessionID: string) => sessionHref(props.server, sessionID)
@@ -55,7 +53,6 @@ export function SessionUIProvider(
data={sessionUIData()}
directory={directory()}
sessionID={params.id}
shellOutput={(input) => serverSDK.api.shell.output(input)}
onNavigateToSession={navigateToSession}
onSessionHref={href}
>
-25
View File
@@ -1,25 +0,0 @@
import { describe, expect, test } from "bun:test"
import { beginWorkspaceSetup, isWorkspaceSetupPending } from "./setup"
describe("workspace setup", () => {
test("remains pending until the setup script finishes", async () => {
const ready = Promise.withResolvers<void>()
beginWorkspaceSetup("session-ready", ready.promise)
expect(isWorkspaceSetupPending("session-ready")).toBe(true)
ready.resolve()
await ready.promise
expect(isWorkspaceSetupPending("session-ready")).toBe(false)
})
test("clears pending setup when the script fails", async () => {
const ready = Promise.withResolvers<void>()
beginWorkspaceSetup("session-failed", ready.promise)
ready.reject(new Error("setup failed"))
await ready.promise.catch(() => undefined)
expect(isWorkspaceSetupPending("session-failed")).toBe(false)
})
})
-15
View File
@@ -1,15 +0,0 @@
import { createStore } from "solid-js/store"
const [sessions, setSessions] = createStore<Record<string, boolean | undefined>>({})
export function beginWorkspaceSetup(sessionID: string, ready: Promise<void>) {
setSessions(sessionID, true)
void ready.then(
() => setSessions(sessionID, undefined),
() => setSessions(sessionID, undefined),
)
}
export function isWorkspaceSetupPending(sessionID: string) {
return sessions[sessionID] === true
}
@@ -113,32 +113,6 @@ test("reactive count updates preserve measured row sizes", () => {
})
})
test("explicit measurement refreshes a cached row size with a custom measurer", () => {
const root = document.createElement("div")
const element = document.createElement("div")
element.dataset.index = "0"
Object.defineProperty(element, "offsetHeight", { value: 120 })
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>({
count: 1,
estimateSize: () => 60,
initialRect: { width: 400, height: 200 },
getScrollElement: () => root,
scrollToFn: () => {},
observeElementRect: () => {},
observeElementOffset: () => {},
measureElement: (node) => node.offsetHeight,
})
virtualizer.getTotalSize()
virtualizer.resizeItem(0, 60)
virtualizer._willUpdate()
virtualizer.measureElement(element)
expect(virtualizer.itemSizeCache.get(0)).toBe(120)
expect(virtualizer.getTotalSize()).toBe(120)
})
test("initial rect projects rows before a scroll element connects", () => {
createRoot((dispose) => {
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
+1 -19
View File
@@ -9,7 +9,6 @@ import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { buildAppArchive } from "./app-assets"
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
import { resolveOpencodePty } from "./opencode-pty"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
@@ -79,23 +78,6 @@ const appAssetsPlugin: BunPlugin = {
}
for (const item of targets) {
const opencodePty = await resolveOpencodePty({
platform: item.os,
arch: item.arch,
...(item.os === "linux" ? { libc: item.abi ?? "glibc" } : {}),
})
const opencodePtyPlugin: BunPlugin = {
name: "opencode-pty-binary",
setup(build) {
build.onLoad({ filter: /persistent-pty[/\\]pty-binding\.ts$/ }, () => ({
loader: "js",
contents: opencodePty
? `import file from ${JSON.stringify(opencodePty.source)} with { type: "file" }
export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sha256: ${JSON.stringify(opencodePty.sha256)} }`
: "export default undefined",
}))
},
}
const simulationInputs = new Set<string>()
const simulationGraphPlugin: BunPlugin = {
name: "opencode-simulation-graph",
@@ -123,7 +105,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
-7
View File
@@ -5,7 +5,6 @@ import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
import { collectFiles } from "./files"
import { resolveOpencodePty } from "./opencode-pty"
const dir = path.resolve(import.meta.dirname, "..")
@@ -19,11 +18,6 @@ export type NodeAsset = {
}
export async function collectNodeAssets(target: NodeTarget) {
const opencodePty = await resolveOpencodePty({
platform: target.platform,
arch: target.arch,
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
})
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
const assets: NodeAsset[] = [
@@ -47,7 +41,6 @@ export async function collectNodeAssets(target: NodeTarget) {
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
})),
...(opencodePty && target.opencodePtyAsset ? [{ key: target.opencodePtyAsset, source: opencodePty.source }] : []),
...(await collectFiles(ptyRoot))
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
.map((relative) => ({
-102
View File
@@ -1,102 +0,0 @@
import { spawnSync } from "node:child_process"
import { createHash } from "node:crypto"
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const VERSION = "0.1.5"
const RELEASE = `https://github.com/anomalyco/opencode-pty/releases/download/v${VERSION}`
const SHA256 = {
"aarch64-apple-darwin": "d5156e44a6783381aadbd968dbd27c1d83e7e0f1b6042c7c934e6d33541d334f",
"aarch64-unknown-linux-gnu": "075d99ffb269cbd0846d3d404fdee93965a53cd6eaf046dbd1064785a7ce9351",
"aarch64-unknown-linux-musl": "22fb55c944ff05fbe03e84de67333e9fd037ad4e04ffc93d8a3f0b2193c29421",
"x86_64-apple-darwin": "773e363b5385c1bd56021e69ada95132efd615ed5b9c3734f878ad644ae22b01",
"x86_64-unknown-linux-gnu": "d9cac2a7c09d013188f696c45ded5eb5764d308e52dd31cb2de68bf4fc675624",
"x86_64-unknown-linux-musl": "2a176302de3d24f8ae3fbacf0b4afce7b4af3e00abd619906187a487b5e50bd6",
} as const
export type OpencodePtyAsset = {
readonly source: string
readonly version: string
readonly sha256: string
}
type Target = {
readonly platform: string
readonly arch: string
readonly libc?: "glibc" | "musl"
}
const pending = new Map<string, Promise<OpencodePtyAsset | undefined>>()
export function resolveOpencodePty(target: Target) {
const rustTarget = targetName(target)
if (!rustTarget) return Promise.resolve(undefined)
const existing = pending.get(rustTarget)
if (existing) return existing
const result = acquire(rustTarget).catch((error) => {
pending.delete(rustTarget)
throw error
})
pending.set(rustTarget, result)
return result
}
async function acquire(target: keyof typeof SHA256): Promise<OpencodePtyAsset> {
const root = path.resolve(import.meta.dirname, "../.cache/opencode-pty", VERSION, target)
const executable = path.join(root, "opencode-pty")
const cached = await readFile(executable).catch(() => undefined)
if (cached)
return {
source: executable,
version: VERSION,
sha256: createHash("sha256").update(cached).digest("hex"),
}
await mkdir(root, { recursive: true })
const archiveName = `opencode-pty-${VERSION}-${target}.tar.gz`
const response = await fetch(`${RELEASE}/${archiveName}`)
if (!response.ok) throw new Error(`Failed to download ${archiveName}: ${response.status}`)
const archive = new Uint8Array(await response.arrayBuffer())
const actual = createHash("sha256").update(archive).digest("hex")
if (actual !== SHA256[target]) throw new Error(`Checksum mismatch for ${archiveName}`)
const temporary = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-build-"))
try {
const archivePath = path.join(temporary, archiveName)
await writeFile(archivePath, archive)
run("tar", ["-xzf", archivePath, "-C", temporary])
const source = path.join(temporary, `opencode-pty-${VERSION}-${target}`, "opencode-pty")
const bytes = await readFile(source)
const staged = path.join(root, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
await writeFile(staged, bytes, { flag: "wx", mode: 0o755 })
await rename(staged, executable).catch(async (error) => {
await rm(staged, { force: true })
if (!(await readFile(executable).catch(() => undefined))) throw error
})
const installed = await readFile(executable)
return {
source: executable,
version: VERSION,
sha256: createHash("sha256").update(installed).digest("hex"),
}
} finally {
await rm(temporary, { recursive: true, force: true })
}
}
function targetName(target: Target): keyof typeof SHA256 | undefined {
const arch = target.arch === "arm64" ? "aarch64" : target.arch === "x64" ? "x86_64" : undefined
if (!arch) return undefined
if (target.platform === "darwin") return arch === "aarch64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
if (target.platform === "linux" && target.libc === "musl")
return arch === "aarch64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"
if (target.platform === "linux") return arch === "aarch64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
return undefined
}
function run(command: string, args: readonly string[]) {
const result = spawnSync(command, args, { stdio: "inherit" })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
}
-27
View File
@@ -1,27 +0,0 @@
import { Schema } from "effect"
import { format } from "prettier"
import { Info, SchemaURL } from "../src/config/schema"
const target = process.argv[2]
if (!target) throw new Error("A schema output path is required")
const document = Schema.toJsonSchemaDocument(Info)
const content = await format(
JSON.stringify({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: SchemaURL,
...document.schema,
...(Object.keys(document.definitions).length ? { $defs: document.definitions } : {}),
}),
{ parser: "json", printWidth: 120 },
)
if (process.argv.includes("--check")) {
if ((await Bun.file(target).text()) !== content) {
console.error("Generated CLI config schema is stale. Run `bun run generate` from packages/www.")
process.exit(1)
}
process.exit(0)
}
await Bun.write(target, content)
+1 -2
View File
@@ -79,7 +79,6 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
commands: [
Spec.make("agents", { description: "List all agents" }),
Spec.make("config", { description: "List configuration sources" }),
Spec.make("paths", { description: "Show global paths (data, config, cache, state)" }),
],
}),
Spec.make("console", {
@@ -181,7 +180,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
Spec.make("add", {
description: "Install a plugin and add it to the global configuration",
params: {
package: Argument.string("package").pipe(Argument.withDescription("npm registry or Git package specifier")),
package: Argument.string("package").pipe(Argument.withDescription("npm registry package specifier")),
},
}),
Spec.make("remove", {
@@ -1,17 +0,0 @@
import { EOL } from "os"
import { Effect } from "effect"
import { Global } from "@opencode-ai/util/global"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
export default Runtime.handler(
Commands.commands.debug.commands.paths,
Effect.fn("cli.debug.paths")(function* () {
const global = yield* Global.Service
process.stdout.write(
Object.entries(global)
.map(([key, value]) => `${key.padEnd(10)} ${value}${EOL}`)
.join(""),
)
}),
)
@@ -13,8 +13,10 @@ import { Config } from "../../../config"
export default Runtime.handler(
Commands.commands.plugin.commands.add,
Effect.fn("cli.plugin.add")(function* (input) {
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(input.package))))
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
if (!(yield* Effect.promise(() => Npm.isRegistryPackage(input.package))))
return yield* Effect.fail(
new Error("Plugin target must be an npm registry package name, version, tag, or semver range"),
)
const npm = yield* Npm.Service
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
+2 -4
View File
@@ -7,7 +7,7 @@ import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
import { ConfigMigration } from "./migrate"
import { Info, SchemaURL } from "./schema"
import { Info } from "./schema"
export * from "./schema"
@@ -87,9 +87,7 @@ export const layer = Layer.effect(
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs
.readFileString(file)
.pipe(Effect.orElseSucceed(() => JSON.stringify({ $schema: SchemaURL }, null, 2)))
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => "{}"))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
+3 -4
View File
@@ -7,7 +7,7 @@ import { Effect, FileSystem, Option, Schema } from "effect"
import { randomUUID } from "crypto"
import { applyEdits, createScanner, modify, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
import path from "path"
import { Info, SchemaURL } from "./schema"
import { Info } from "./schema"
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
const decodeInfo = Schema.decodeUnknownOption(Info)
@@ -100,9 +100,8 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
const kv = yield* readJson(path.join(input.state, "kv.json"))
const values = migrateV1(legacy, kv ?? {})
if (!Object.keys(values).length) return
const migrated = { $schema: SchemaURL, ...values }
const migrated = migrateV1(legacy, kv ?? {})
if (!Object.keys(migrated).length) return
const result = yield* persist(JSON.stringify(migrated, null, 2) + "\n", migrated)
if (result.cause === undefined)
+1 -6
View File
@@ -1,10 +1,5 @@
import { Config } from "@opencode-ai/tui/config"
import { Schema } from "effect"
export const SchemaURL = "https://opencode.ai/v2/cli.json"
export const Info = Schema.Struct({
$schema: Schema.optional(Schema.String).annotate({ description: "JSON Schema for CLI configuration" }),
...Config.Info.fields,
})
export const Info = Schema.Struct({ ...Config.Info.fields })
export type Info = Schema.Schema.Type<typeof Info>
-1
View File
@@ -27,7 +27,6 @@ const Handlers = Runtime.handlers(Commands, {
debug: {
agents: () => import("./commands/handlers/debug/agents"),
config: () => import("./commands/handlers/debug/config"),
paths: () => import("./commands/handlers/debug/paths"),
},
console: {
login: () => import("./commands/handlers/console/login"),
-2
View File
@@ -13,7 +13,6 @@ export function nodeTarget(platform: string, arch: string) {
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
const opencodePtyAsset = targetPlatform === "win32" ? undefined : "opencode-pty/opencode-pty"
return {
platform: targetPlatform,
@@ -26,7 +25,6 @@ export function nodeTarget(platform: string, arch: string) {
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
fffFfiPackage,
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
opencodePtyAsset,
}
}
+2 -70
View File
@@ -1,7 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Flock } from "@opencode-ai/util/flock"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem, Option, Schema } from "effect"
import { Effect, FileSystem, Option } from "effect"
import { expect, test } from "bun:test"
import { parse } from "jsonc-parser"
import path from "path"
@@ -17,67 +17,6 @@ function run<A, E>(directory: string, effect: Effect.Effect<A, E, Config.Service
)
}
test("generates reusable keybind schemas and preserves descriptions and numeric constraints", () => {
const document = Schema.toJsonSchemaDocument(Config.Info)
expect(document).toHaveProperty(["definitions", "TuiKeybind.BindingValue"])
expect(document).toHaveProperty(["schema", "properties", "keybinds", "anyOf", 0, "properties", "app.exit"], {
anyOf: [{ $ref: "#/$defs/TuiKeybind.BindingValue" }, { type: "null" }],
description: "Exit the application",
})
expect(document).toHaveProperty(["schema", "properties", "scroll", "anyOf", 0, "properties", "speed", "anyOf", 0], {
type: "number",
minimum: 0.001,
})
expect(document).toHaveProperty(
["schema", "properties", "attention", "anyOf", 0, "properties", "volume", "anyOf", 0],
{ type: "number", minimum: 0, maximum: 1 },
)
})
test("includes the published schema when creating cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.animations = false
})
}),
)
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual(config)
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("preserves the schema in an existing cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, JSON.stringify({ $schema: "https://opencode.ai/v2/cli.json", animations: true }))
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.animations = false
})
}),
)
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
expect(await Bun.file(file).json()).toEqual(config)
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("migrates tui and kv config into cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(
@@ -134,7 +73,6 @@ test("migrates tui and kv config into cli.json", async () => {
)
expect(config).toMatchObject({
$schema: "https://opencode.ai/v2/cli.json",
theme: { name: "legacy", mode: "light" },
keybinds: {
leader: "ctrl+o",
@@ -190,14 +128,8 @@ test("migrates before the first update and does not remigrate afterward", async
}),
)
expect(config).toEqual({
$schema: "https://opencode.ai/v2/cli.json",
theme: { name: "legacy" },
animations: false,
mouse: false,
})
expect(config).toEqual({ theme: { name: "legacy" }, animations: false, mouse: false })
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual({
$schema: "https://opencode.ai/v2/cli.json",
theme: { name: "legacy" },
animations: false,
mouse: false,
-66
View File
@@ -1,66 +0,0 @@
import { describe, expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
describe("debug paths command", () => {
test("is included in troubleshooting help", async () => {
const [debug, paths] = await Promise.all([cli(["debug", "--help"]), cli(["debug", "paths", "--help"])])
expect(debug.exitCode).toBe(0)
expect(debug.stdout).toContain("paths")
expect(debug.stdout).toContain("Show global paths (data, config, cache, state)")
expect(paths.exitCode).toBe(0)
expect(paths.stdout).toContain("opencode debug paths [flags]")
})
test("prints resolved global paths without starting a server", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-debug-paths-"))
try {
const result = await cli(["debug", "paths"], {
XDG_DATA_HOME: path.join(root, "data"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_STATE_HOME: path.join(root, "state"),
})
const paths = Object.fromEntries(
result.stdout
.trim()
.split("\n")
.map((line) => line.trim().split(/\s+/, 2)),
)
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(paths).toMatchObject({
home: os.homedir(),
data: path.join(root, "data", "opencode"),
config: path.join(root, "config", "opencode"),
cache: path.join(root, "cache", "opencode"),
state: path.join(root, "state", "opencode"),
bin: path.join(root, "cache", "opencode", "bin"),
log: path.join(root, "data", "opencode", "log"),
repos: path.join(root, "data", "opencode", "repos"),
})
expect(paths.tmp).toBeTruthy()
expect(await Bun.file(path.join(root, "state", "opencode", "service-local.json")).exists()).toBe(false)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
})
async function cli(args: string[], env?: Record<string, string>) {
const child = Bun.spawn([process.execPath, "run", path.join(import.meta.dir, "../src/index.ts"), ...args], {
cwd: path.join(import.meta.dir, ".."),
env: { ...process.env, ...env },
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
return { stdout, stderr, exitCode }
}
-1
View File
@@ -8,7 +8,6 @@ test("collects each SEA asset key once", async () => {
const keys = assets.map((asset) => asset.key)
expect(new Set(keys).size).toBe(keys.length)
if (process.platform !== "win32") expect(keys.filter((key) => key === "opencode-pty/opencode-pty")).toHaveLength(1)
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
{
key: shellParserWasmAssets.runtime,
+2 -6
View File
@@ -120,7 +120,6 @@ function nodePrelude(input: NodeBuildInput) {
input.target.platform === "darwin"
? `${input.target.nodePtyPackage}/prebuilds/darwin-${input.target.arch}/spawn-helper`
: undefined
const opencodePtyAsset = input.target.opencodePtyAsset
const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const Agent = sdk.Agent
@@ -201,17 +200,13 @@ if (__ocIsSea()) {
const __ocAssetRoot = __ocIsSea()
? __ocPath.join(__ocCacheRoot, ${JSON.stringify(`${input.assetHash}-${input.target.platform}-${input.target.arch}`)})
: __ocFileURLToPath(new URL("./assets/", import.meta.url))
const __ocPersistentPty = ${JSON.stringify(opencodePtyAsset)}
if (__ocIsSea()) {
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
for (const __ocKey of __ocAssetKeys()) {
const __ocTarget = __ocPath.join(__ocAssetRoot, __ocKey)
if (__ocExists(__ocTarget)) continue
__ocMkdir(__ocPath.dirname(__ocTarget), { recursive: true })
const __ocTemporary = \`${"${__ocTarget}"}.${"${process.pid}"}.${"${crypto.randomUUID()}"}.tmp\`
__ocWrite(__ocTemporary, new Uint8Array(__ocRawAsset(__ocKey)))
if ((__ocKey === __ocPtySpawnHelper || __ocKey === __ocPersistentPty) && process.platform !== "win32")
__ocChmod(__ocTemporary, 0o755)
try {
__ocRename(__ocTemporary, __ocTarget)
} catch (__ocError) {
@@ -219,6 +214,8 @@ if (__ocIsSea()) {
if (!__ocExists(__ocTarget)) throw __ocError
}
}
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
if (__ocPtySpawnHelper) __ocChmod(__ocPath.join(__ocAssetRoot, __ocPtySpawnHelper), 0o755)
}
process.env.OPENCODE_NODE_ASSETS_DIR = __ocAssetRoot
process.env.OTUI_ASSET_ROOT = __ocAssetRoot
@@ -230,7 +227,6 @@ process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, $
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
if (__ocPersistentPty && !process.env.OPENCODE_PTY_BIN) process.env.OPENCODE_PTY_BIN = __ocPath.join(__ocAssetRoot, __ocPersistentPty)
try {
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
} catch {}
-10
View File
@@ -1333,15 +1333,6 @@ export type CredentialUpdateOperation<E = never> = (
input: CredentialUpdateInput,
) => Effect.Effect<CredentialUpdateOutput, E>
export type CredentialActivateInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type CredentialActivateOutput = void
export type CredentialActivateOperation<E = never> = (
input: CredentialActivateInput,
) => Effect.Effect<CredentialActivateOutput, E>
export type CredentialRemoveInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -1353,7 +1344,6 @@ export type CredentialRemoveOperation<E = never> = (
export interface CredentialApi<E = never> {
readonly update: CredentialUpdateOperation<E>
readonly activate: CredentialActivateOperation<E>
readonly remove: CredentialRemoveOperation<E>
}
@@ -138,8 +138,6 @@ import type {
McpResourceCatalogOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialActivateInput,
CredentialActivateOutput,
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
@@ -937,14 +935,6 @@ const EndpointCredentialUpdate = (raw: RawClient["server.credential"]) => (input
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointCredentialActivate = (raw: RawClient["server.credential"]) => (input: CredentialActivateInput) =>
preserveEffect<CredentialActivateOutput>()(
raw["credential.activate"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input: CredentialRemoveInput) =>
preserveEffect<CredentialRemoveOutput>()(
raw["credential.remove"]({
@@ -955,7 +945,6 @@ const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input
const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
update: EndpointCredentialUpdate(raw),
activate: EndpointCredentialActivate(raw),
remove: EndpointCredentialRemove(raw),
})
-2
View File
@@ -4,12 +4,10 @@ export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
export type ConfigApi = Client["config"]
export type EventApi = Client["event"]
export type GenerateApi = Client["generate"]
export type IntegrationApi = Client["integration"]
export type McpApi = Client["mcp"]
export type ModelApi = Client["model"]
export type PluginApi = Client["plugin"]
export type PermissionApi = Client["permission"]
export type ProviderApi = Client["provider"]
export type ReferenceApi = Client["reference"]
export type WebSearchApi = Client["websearch"]
@@ -132,8 +132,6 @@ import type {
McpResourceCatalogOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialActivateInput,
CredentialActivateOutput,
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
@@ -1272,18 +1270,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
activate: (input: CredentialActivateInput, requestOptions?: RequestOptions) =>
request<CredentialActivateOutput>(
{
method: "POST",
path: `/api/credential/${encodeURIComponent(input.credentialID)}/activate`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
remove: (input: CredentialRemoveInput, requestOptions?: RequestOptions) =>
request<CredentialRemoveOutput>(
{
+12 -58
View File
@@ -476,7 +476,7 @@ export type PromptFileAttachment = {
export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; text?: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; mention?: PromptMention }
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
@@ -823,24 +823,6 @@ export type ModelsDevRefreshed = {
data: {}
}
export type CredentialUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "credential.updated"
location?: LocationRef
data: {}
}
export type CredentialSwitched = {
id: string
created: number
metadata?: { [x: string]: any }
type: "credential.switched"
location?: LocationRef
data: { integrationID: string; credentialID: string | null }
}
export type IntegrationUpdated = {
id: string
created: number
@@ -850,6 +832,15 @@ export type IntegrationUpdated = {
data: {}
}
export type IntegrationConnectionUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "integration.connection.updated"
location?: LocationRef
data: { integrationID: string }
}
export type CatalogUpdated = {
id: string
created: number
@@ -964,7 +955,7 @@ export type WorktreeUpdated = {
metadata?: { [x: string]: any }
type: "worktree.updated"
location?: LocationRef
data: { projectID: string; directory?: string }
data: { projectID: string }
}
export type WorktreeResolved = {
@@ -1319,10 +1310,8 @@ export type ToolContent1 = ToolTextContent | ToolFileContent1
export type ModelCompatibility = {
reasoningField?: ModelReasoningField
requireReasoning?: boolean
maxTokensField?: ModelMaxTokensField
requireFinishReason?: boolean
requireAssistantAfterTool?: boolean
}
export type ModelCost = {
@@ -1418,24 +1407,6 @@ export type Project = {
sandboxes: Array<string>
}
export type ProjectUpdated = {
id: string
created: number
metadata?: { [x: string]: any }
type: "project.updated"
location?: LocationRef
data: {
id: string
canonical: string
vcs?: ProjectVcs
name?: string
icon?: ProjectIcon
commands?: ProjectCommands
time: ProjectTime
sandboxes: Array<string>
}
}
export type FormAnswer = { [x: string]: FormValue }
export type PermissionRequest = {
@@ -1446,7 +1417,6 @@ export type PermissionRequest = {
save?: Array<string>
metadata?: { [x: string]: JsonValue }
source?: PermissionSource
message?: string
}
export type PermissionAsked = {
@@ -1463,7 +1433,6 @@ export type PermissionAsked = {
save?: Array<string>
metadata?: { [x: string]: any }
source?: PermissionSource
message?: string
}
}
@@ -2133,16 +2102,14 @@ export type SessionMessagesResponse = {
export type IntegrationInfo = {
id: string
name: string
metadata?: { [x: string]: any }
methods: Array<IntegrationMethod>
connections: Array<ConnectionInfo>
}
export type V2Event =
| ModelsDevRefreshed
| CredentialUpdated
| CredentialSwitched
| IntegrationUpdated
| IntegrationConnectionUpdated
| CatalogUpdated
| AgentUpdated
| SessionCreated
@@ -2197,7 +2164,6 @@ export type V2Event =
| PermissionReplied
| PluginAdded
| PluginUpdated
| ProjectUpdated
| WorktreeUpdated
| WorktreeResolved
| CommandUpdated
@@ -2740,7 +2706,6 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -3016,7 +2981,6 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -3292,7 +3256,6 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text?: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -4386,15 +4349,6 @@ export type CredentialUpdateInput = {
export type CredentialUpdateOutput = void
export type CredentialActivateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}["location"]
}
export type CredentialActivateOutput = void
export type CredentialRemoveInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: {
-33
View File
@@ -521,9 +521,6 @@ export function createData(config: CreateDataInput) {
void result.location.vcs.sync().catch((error) => console.error("Failed to preload VCS info", error))
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
return
case "project.updated":
setStore("project", "info", event.data.id, reconcile(event.data))
return
case "session.created":
sessionOutbox.delete(event.data.sessionID)
result.session.invalidate(event.data.sessionID)
@@ -1048,36 +1045,6 @@ export function createData(config: CreateDataInput) {
return
}
if (event.type === "credential.updated" || event.type === "credential.switched") {
Object.keys(store.location).forEach((key) => {
const ref = JSON.parse(key) as [string, string | null]
const location = { directory: ref[0], workspaceID: ref[1] ?? undefined }
if (event.type === "credential.updated") {
result.location.integration.invalidate(location)
void result.location.integration.sync(location)
return
}
setStore("location", key, (data) => ({
...data,
integration: data?.integration?.map((integration) => {
if (integration.id !== event.data.integrationID) return integration
const active = integration.connections.find(
(connection) => connection.type === "credential" && connection.id === event.data.credentialID,
)
if (!active) return integration
return {
...integration,
connections: [active, ...integration.connections.filter((connection) => connection !== active)],
}
}),
}))
result.location.model.invalidate(location)
result.location.provider.invalidate(location)
void Promise.all([result.location.model.sync(location), result.location.provider.sync(location)])
})
return
}
if (!event.location) return
const location = event.location
switch (event.type) {
-19
View File
@@ -26,7 +26,6 @@ test("exposes every standard HTTP API group", () => {
"skill",
"event",
"pty",
"experimental",
"shell",
"reference",
"worktree",
@@ -188,24 +187,6 @@ test("experimental wellknown integration add uses the public HTTP contract", asy
expect(await request?.json()).toEqual({ url: "https://example.com" })
})
test("credential.activate uses the public HTTP contract", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
return new Response(null, { status: 204 })
},
})
await client.credential.activate({ credentialID: "cred_work", location: { directory: "/tmp/project" } })
expect(request?.method).toBe("POST")
expect(request?.url).toBe(
"http://localhost:3000/api/credential/cred_work/activate?location%5Bdirectory%5D=%2Ftmp%2Fproject",
)
})
test("integration connections optionally submit a form answer", async () => {
const requests: Request[] = []
const client = OpenCode.make({
+1 -180
View File
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createData, type CreateDataInput } from "../src/solid"
import { OpenCode, type OpenCodeEvent, type Project, type SessionInfo } from "../src/promise"
import { OpenCode, type OpenCodeEvent, type SessionInfo } from "../src/promise"
const session = (viewed: number): SessionInfo => ({
id: "ses_refresh",
@@ -72,185 +72,6 @@ test("revalidates after an event overtakes an active session read", async () =>
}
})
test("updates authoritative cached project metadata from live events", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const original: Project = {
id: "project_renamed",
canonical: "/projects/original",
name: "Original custom name",
time: { created: 1, updated: 1 },
sandboxes: [],
}
const unrelated: Project = {
id: "project_unrelated",
canonical: "/projects/unrelated",
name: "Unrelated project",
time: { created: 1, updated: 1 },
sandboxes: [],
}
let requests = 0
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (!request.url.endsWith("/api/project")) throw new Error(`Unexpected request: ${request.url}`)
requests++
return Response.json([original, unrelated])
},
})
const event: CreateDataInput["event"] = {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
}
const setup = createRoot((dispose) => ({
data: createData({ api: () => api, directory: "/projects/original", event }),
dispose,
}))
try {
await setup.data.project.sync()
expect(setup.data.project.get(original.id)).toEqual(original)
const updated: OpenCodeEvent = {
id: "evt_project_renamed",
created: 2,
type: "project.updated",
data: {
...original,
canonical: "/projects/renamed",
name: "Updated custom name",
time: { ...original.time, updated: 2 },
},
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
expect(setup.data.project.get(original.id)?.canonical).toBe("/projects/renamed")
expect(setup.data.project.get(original.id)?.name).toBe("Updated custom name")
expect(setup.data.project.get(unrelated.id)).toEqual(unrelated)
expect(requests).toBe(1)
const reset: OpenCodeEvent = {
id: "evt_project_name_reset",
created: 3,
type: "project.updated",
data: {
id: original.id,
canonical: "/projects/renamed-again",
time: { ...original.time, updated: 3 },
sandboxes: [],
},
}
listeners.forEach((listener) => listener({ name: reset.type, details: reset }))
expect(setup.data.project.get(original.id)?.canonical).toBe("/projects/renamed-again")
expect(setup.data.project.get(original.id)?.name).toBeUndefined()
expect(setup.data.project.get(unrelated.id)).toEqual(unrelated)
expect(requests).toBe(1)
} finally {
setup.dispose()
}
})
test("refreshes global credential events across every loaded location and workspace", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const requests: URL[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
requests.push(url)
const directory = url.searchParams.get("location[directory]") ?? "/project"
return Response.json({
location: {
directory,
workspaceID: url.searchParams.get("location[workspace]") ?? undefined,
project: { id: "project", directory, canonical: directory },
},
data: [],
})
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: {
on: () => () => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
},
connection: { status: () => "connected" },
}),
dispose,
}))
const locations = [{ directory: "/project" }, { directory: "/other", workspaceID: "workspace-other" }]
try {
await Promise.all(
locations.flatMap((location) => [
setup.data.location.integration.sync(location),
setup.data.location.model.sync(location),
setup.data.location.provider.sync(location),
]),
)
requests.length = 0
const updated: OpenCodeEvent = {
id: "evt_credential.updated",
created: 1,
type: "credential.updated",
data: {},
}
listeners.forEach((listener) => listener({ name: updated.type, details: updated }))
await wait(() => requests.length === 2)
expect(
requests.map((url) => [
url.pathname,
url.searchParams.get("location[directory]"),
url.searchParams.get("location[workspace]"),
]),
).toEqual([
["/api/integration", "/project", null],
["/api/integration", "/other", "workspace-other"],
])
requests.length = 0
for (const credentialID of ["credential", null]) {
const switched: OpenCodeEvent = {
id: `evt_credential.switched.${credentialID}`,
created: 2,
type: "credential.switched",
data: { credentialID, integrationID: "integration" },
}
listeners.forEach((listener) => listener({ name: switched.type, details: switched }))
await wait(() => requests.length === 4)
expect(
requests.map((url) => [
url.pathname,
url.searchParams.get("location[directory]"),
url.searchParams.get("location[workspace]"),
]),
).toEqual(
expect.arrayContaining([
["/api/model", "/project", null],
["/api/provider", "/project", null],
["/api/model", "/other", "workspace-other"],
["/api/provider", "/other", "workspace-other"],
]),
)
requests.length = 0
}
} finally {
setup.dispose()
}
})
test("reports optimistic sessions as creating until the request settles", async () => {
const release = Promise.withResolvers<void>()
const api = OpenCode.make({
+3 -7
View File
@@ -22,7 +22,7 @@
"benchmark:location": "bun run script/benchmark-location.ts",
"build": "bun run script/build.ts",
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
"test": "bun run script/test.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo -b tsconfig.json tsconfig.tests.json"
},
"exports": {
@@ -41,12 +41,6 @@
"node": "./src/pty/pty.node.ts",
"default": "./src/pty/pty.bun.ts"
},
"#persistent-pty-binary": {
"workerd": "./src/persistent-pty/binary.workerd.ts",
"bun": "./src/persistent-pty/binary.bun.ts",
"node": "./src/persistent-pty/binary.node.ts",
"default": "./src/persistent-pty/binary.bun.ts"
},
"#fff": {
"workerd": "./src/filesystem/fff.workerd.ts",
"bun": "./src/filesystem/fff.bun.ts",
@@ -102,6 +96,7 @@
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.88",
"@ai-sdk/cerebras": "2.0.41",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
@@ -112,6 +107,7 @@
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@aws-sdk/credential-providers": "3.1057.0",
"@lydell/node-pty": "catalog:",
-52
View File
@@ -1,52 +0,0 @@
import fs from "fs/promises"
import path from "path"
import { tmpdir } from "../test/fixture/tmpdir"
await using directory = await tmpdir("oc-")
const home = directory.path
const temporary = path.join(home, "tmp")
await fs.mkdir(temporary)
const environment = {
...Object.fromEntries(
Object.entries(process.env).filter(([name]) => {
if (process.env.RECORD === "true" && name === "OPENAI_API_KEY") return true
if (
/^(?:AWS|AZURE|GOOGLE|GCP|GCLOUD|VERTEX|OPENAI|ANTHROPIC|GEMINI|XAI|CLOUDFLARE|CF_AIG|SNOWFLAKE|AICORE|GITLAB|NPM_CONFIG)_/i.test(
name,
)
) {
return false
}
return !/(?:^|_)(?:API_KEY|AUTHORIZATION|TOKEN|SECRET|PASSWORD|CREDENTIALS?)$/i.test(name)
}),
),
HOME: home,
OPENCODE_TEST_HOME: home,
XDG_CONFIG_HOME: path.join(home, ".config"),
XDG_DATA_HOME: path.join(home, ".local", "share"),
XDG_CACHE_HOME: path.join(home, ".cache"),
XDG_STATE_HOME: path.join(home, ".local", "state"),
OPENCODE_CONFIG_DIR: path.join(home, ".config", "opencode"),
OPENCODE_CONFIG: undefined,
OPENCODE_CONFIG_CONTENT: undefined,
TMPDIR: temporary,
...(process.platform === "win32" ? { USERPROFILE: home, TMP: temporary, TEMP: temporary } : {}),
}
const child = Bun.spawn({
cmd: [process.execPath, "test", "--only-failures", ...process.argv.slice(2)],
env: environment,
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
})
const interrupt = () => child.kill("SIGINT")
const terminate = () => child.kill("SIGTERM")
process.once("SIGINT", interrupt)
process.once("SIGTERM", terminate)
const result = await child.exited
process.off("SIGINT", interrupt)
process.off("SIGTERM", terminate)
process.exitCode = result
-11
View File
@@ -53,17 +53,6 @@ export function map(input: MapInput): Mapping | undefined {
...mapOpenAIOptions(input.settings),
},
}
case "@ai-sdk/cerebras":
case "@ai-sdk/togetherai":
return {
package: `@opencode-ai/ai/providers/${input.packageName === "@ai-sdk/cerebras" ? "cerebras" : "togetherai"}`,
settings: {
...baseSettings,
...mapAPIKey(input.settings),
...mapProviderOptions(input.settings, ["apiKey", "baseURL", "fetch", "headers", "name"]),
},
...(isStringRecord(input.settings.headers) ? { headers: input.settings.headers } : {}),
}
case "@ai-sdk/google":
return {
package: "@opencode-ai/ai/providers/google",
+1 -11
View File
@@ -34,7 +34,6 @@ import {
import { Auth, Endpoint, RequestExecutor, type AnyRoute } from "@opencode-ai/ai/route"
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import { makeParser } from "effect/unstable/encoding/Sse"
import type { ID, Info } from "./model.js"
import { Provider } from "./provider.js"
import { State } from "./state.js"
@@ -66,23 +65,15 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
if (!res.headers.get("content-type")?.includes("text/event-stream")) return res
const reader = res.body.getReader()
const decoder = new TextDecoder()
let deadline: number | undefined
const parser = makeParser((event) => {
if (event._tag === "Event") deadline = Date.now() + ms
})
const body = new ReadableStream<Uint8Array>({
async pull(ctrl) {
const expires = deadline ?? Date.now() + ms
deadline = expires
const part = await new Promise<Awaited<ReturnType<typeof reader.read>>>((resolve, reject) => {
const remaining = Math.max(0, expires - Date.now())
const id = setTimeout(() => {
const err = new Error("SSE read timed out")
ctl.abort(err)
void reader.cancel(err)
reject(err)
}, remaining)
}, ms)
reader.read().then(
(part) => {
@@ -101,7 +92,6 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
return
}
parser.feed(decoder.decode(part.value, { stream: true }))
ctrl.enqueue(part.value)
},
async cancel(reason) {
+10 -13
View File
@@ -89,7 +89,6 @@ export interface PublishOptions {
readonly id?: Event.ID
readonly metadata?: Record<string, unknown>
readonly location?: Location.Ref
readonly global?: boolean
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
readonly commit?: (seq: number) => Effect.Effect<void>
}
@@ -447,12 +446,11 @@ export function configured(options?: Options) {
function publish<D extends Event.Definition>(definition: D, data: Event.Data<D>, options?: PublishOptions) {
return Effect.gen(function* () {
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
const location = options?.global
? undefined
: (options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined))
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return yield* publishEvent(
definition,
{
@@ -482,12 +480,11 @@ export function configured(options?: Options) {
}),
)
}
const location = options?.global
? undefined
: (options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined))
const location =
options?.location ??
(serviceLocation
? { directory: serviceLocation.directory, workspaceID: serviceLocation.workspaceID }
: undefined)
return {
definition,
aggregateID,
+5 -2
View File
@@ -16,6 +16,7 @@ import {
type Entry,
Event,
} from "@opencode-ai/schema/config"
import { Integration } from "@opencode-ai/schema/integration"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { Credential } from "./credential.js"
import { Bus } from "./bus.js"
@@ -158,7 +159,9 @@ export const layer = (options?: Options) =>
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
const auth = entry.manifest.auth
if (!auth) return []
const credential = (yield* credentials.list(entry.integrationID)).at(-1)
const credential = (yield* credentials.list(entry.integrationID)).findLast(
(credential) => credential.value.type === "key",
)
if (!credential || credential.value.type !== "key") return []
const variables = { [auth.env]: credential.value.key }
const configs = yield* wellknown
@@ -340,7 +343,7 @@ export const layer = (options?: Options) =>
),
Effect.forkScoped({ startImmediately: true }),
)
yield* bus.subscribe(Credential.Event.Switched).pipe(
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filterEffect((event) =>
wellknown.entries().pipe(
Effect.map((entries) => entries.some((entry) => entry.integrationID === event.data.integrationID)),
+8 -105
View File
@@ -1,11 +1,10 @@
export * as Credential from "./credential.js"
import { asc, desc, eq } from "drizzle-orm"
import { asc, eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Integration } from "@opencode-ai/schema/integration"
import { Database } from "./database/database.js"
import { Bus } from "./bus.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { CredentialTable } from "./credential/sql.js"
@@ -21,8 +20,6 @@ export type Key = Credential.Key
export const Value = Credential.Value
export type Value = Credential.Value
export const Event = Credential.Event
export class Info extends Schema.Class<Info>("Credential.Info")({
id: ID,
integrationID: Integration.ID,
@@ -37,14 +34,12 @@ export interface Interface {
readonly list: (integrationID: Integration.ID) => Effect.Effect<Info[]>
/** Returns one stored credential by ID. */
readonly get: (id: ID) => Effect.Effect<Info | undefined>
/** Creates a credential for an integration and returns the new record. */
/** Replaces any credential for an integration and returns the new record. */
readonly create: (input: {
readonly integrationID: Integration.ID
readonly value: Value
readonly label?: string
}) => Effect.Effect<Info>
/** Selects a stored credential for its integration. */
readonly activate: (id: ID) => Effect.Effect<void>
/** Updates the label or secret value of a stored credential. */
readonly update: (id: ID, updates: Partial<Pick<Info, "label" | "value">>) => Effect.Effect<void>
/** Removes a stored credential. */
@@ -57,7 +52,6 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const decode = Schema.decodeUnknownSync(Value)
const stored = (row: typeof CredentialTable.$inferSelect) => {
if (!row.integration_id) return
@@ -79,7 +73,7 @@ const layer = Layer.effect(
db
.select()
.from(CredentialTable)
.orderBy(asc(CredentialTable.active), asc(CredentialTable.time_created), asc(CredentialTable.id))
.orderBy(asc(CredentialTable.time_created))
.all()
.pipe(Effect.orDie, Effect.map(storedRows)),
),
@@ -88,7 +82,7 @@ const layer = Layer.effect(
.select()
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, integrationID))
.orderBy(asc(CredentialTable.active), asc(CredentialTable.time_created), asc(CredentialTable.id))
.orderBy(asc(CredentialTable.time_created))
.all()
.pipe(Effect.orDie, Effect.map(storedRows)),
),
@@ -107,8 +101,7 @@ const layer = Layer.effect(
.transaction((tx) =>
Effect.gen(function* () {
yield* tx
.update(CredentialTable)
.set({ active: false })
.delete(CredentialTable)
.where(eq(CredentialTable.integration_id, credential.integrationID))
.run()
yield* tx
@@ -118,117 +111,27 @@ const layer = Layer.effect(
integration_id: credential.integrationID,
label: credential.label,
value: credential.value,
active: true,
})
.run()
}),
)
.pipe(Effect.orDie)
yield* bus.publish(Event.Updated, {}, { global: true })
yield* bus.publish(
Event.Switched,
{ integrationID: credential.integrationID, credentialID: credential.id },
{ global: true },
)
return credential
}),
activate: Effect.fn("Credential.activate")(function* (id) {
const integrationID = yield* db
.transaction((tx) =>
Effect.gen(function* () {
const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
if (!credential?.integration_id) return
const active = yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, credential.integration_id))
.orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id))
.get()
if (active?.id === id) return
yield* tx
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.integration_id, credential.integration_id))
.run()
yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, id)).run()
return credential.integration_id
}),
)
.pipe(Effect.orDie)
if (integrationID) yield* bus.publish(Event.Switched, { integrationID, credentialID: id }, { global: true })
}),
update: Effect.fn("Credential.update")(function* (id, updates) {
if (updates.label === undefined && updates.value === undefined) return
const credential = yield* db
.select({ integrationID: CredentialTable.integration_id, label: CredentialTable.label })
.from(CredentialTable)
.where(eq(CredentialTable.id, id))
.get()
.pipe(Effect.orDie)
if (!credential?.integrationID) return
if (updates.label === credential.label && updates.value === undefined) return
if (!updates.label && !updates.value) return
yield* db
.update(CredentialTable)
.set({ label: updates.label, value: updates.value })
.where(eq(CredentialTable.id, id))
.run()
.pipe(Effect.orDie)
if (updates.label !== undefined && updates.label !== credential.label)
yield* bus.publish(Event.Updated, {}, { global: true })
}),
remove: Effect.fn("Credential.remove")(function* (id) {
const removed = yield* db
.transaction((tx) =>
Effect.gen(function* () {
const credential = yield* tx.select().from(CredentialTable).where(eq(CredentialTable.id, id)).get()
if (!credential) return
const active = credential.integration_id
? yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, credential.integration_id))
.orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id))
.get()
: undefined
yield* tx.delete(CredentialTable).where(eq(CredentialTable.id, id)).run()
if (!credential.integration_id || active?.id !== id) return { switched: false as const }
const replacement = yield* tx
.select({ id: CredentialTable.id })
.from(CredentialTable)
.where(eq(CredentialTable.integration_id, credential.integration_id))
.orderBy(desc(CredentialTable.time_created), desc(CredentialTable.id))
.get()
if (replacement) {
yield* tx
.update(CredentialTable)
.set({ active: false })
.where(eq(CredentialTable.integration_id, credential.integration_id))
.run()
yield* tx
.update(CredentialTable)
.set({ active: true })
.where(eq(CredentialTable.id, replacement.id))
.run()
}
return {
switched: true as const,
integrationID: credential.integration_id,
credentialID: replacement?.id ?? null,
}
}),
)
.pipe(Effect.orDie)
if (!removed) return
yield* bus.publish(Event.Updated, {}, { global: true })
if (removed.switched)
yield* bus.publish(
Event.Switched,
{ integrationID: removed.integrationID, credentialID: removed.credentialID },
{ global: true },
)
yield* db.delete(CredentialTable).where(eq(CredentialTable.id, id)).run().pipe(Effect.orDie)
}),
})
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node, Bus.node] })
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
@@ -0,0 +1,14 @@
/* oxlint-disable */
import type { MigrationConfig } from "drizzle-orm/migrator"
import { readMigrationFiles } from "drizzle-orm/migrator"
import type { AnyRelations } from "drizzle-orm/relations"
import { migrate as coreMigrate } from "../sqlite-core/effect/session.js"
import type { EffectSQLiteDatabase } from "./driver.js"
export function migrate<TRelations extends AnyRelations>(
db: EffectSQLiteDatabase<TRelations>,
config: MigrationConfig,
) {
const migrations = readMigrationFiles(config)
return coreMigrate(migrations, db.session, config)
}
@@ -1,5 +1,6 @@
export { EffectLogger } from "drizzle-orm/effect-core"
export * from "./effect-sqlite/driver.js"
export * from "./effect-sqlite/session.js"
export { migrate } from "./effect-sqlite/migrator.js"
export * as EffectDrizzleSqlite from "./index.js"
@@ -5,10 +5,13 @@ import type { SqlError } from "effect/unstable/sql/SqlError"
import type { EffectCacheShape } from "drizzle-orm/cache/core/cache-effect"
import { NoopCache, strategyFor } from "drizzle-orm/cache/core/cache"
import type { WithCacheConfig } from "drizzle-orm/cache/core/types"
import { MigratorInitError } from "drizzle-orm/effect-core/errors"
import { EffectDrizzleQueryError, EffectTransactionRollbackError } from "drizzle-orm/effect-core/errors"
import type { EffectLoggerShape } from "drizzle-orm/effect-core/logger"
import type { QueryEffectHKTBase, QueryEffectKind } from "drizzle-orm/effect-core/query-effect"
import { entityKind, is } from "drizzle-orm/entity"
import type { MigrationConfig, MigrationMeta } from "drizzle-orm/migrator"
import { getMigrationsToRun } from "drizzle-orm/migrator.utils"
import type {
AnyRelations,
EmptyRelations,
@@ -17,14 +20,17 @@ import type {
} from "drizzle-orm/relations"
import { makeJitRqbMapper } from "drizzle-orm/relations"
import type { PreparedQuery } from "drizzle-orm/session"
import { fillPlaceholders, type Query, type SQL } from "drizzle-orm/sql/sql"
import { fillPlaceholders, type Query, type SQL, sql } from "drizzle-orm/sql/sql"
import type { SQLiteDialect } from "drizzle-orm/sqlite-core/dialect"
import type { SelectedFieldsOrdered } from "drizzle-orm/sqlite-core/query-builders/select.types"
import type { PreparedQueryConfig, SQLiteExecuteMethod, SQLiteTransactionConfig } from "drizzle-orm/sqlite-core/session"
import { upgradeIfNeeded } from "../../up-migrations/effect-sqlite.js"
import { assertUnreachable, makeJitQueryMapper, type RowsMapper } from "drizzle-orm/utils"
import { mapResultRow, resolveNullableObjectPaths } from "../../internal/drizzle-utils.js"
import { SQLiteEffectDatabase } from "./db.js"
type MigrationConfigWithInit = MigrationConfig & { init?: boolean }
type SQLiteEffectExecuteMethod = SQLiteExecuteMethod | "values"
export class SQLiteEffectPreparedQuery<
@@ -423,3 +429,69 @@ export abstract class SQLiteEffectTransaction<
return new EffectTransactionRollbackError()
}
}
export const migrate = Effect.fn("migrate")(function* <TEffectHKT extends QueryEffectHKTBase>(
migrations: MigrationMeta[],
session: SQLiteEffectSession<TEffectHKT>,
config: string | MigrationConfigWithInit,
) {
const migrationsTable =
typeof config === "string" ? "__drizzle_migrations" : (config.migrationsTable ?? "__drizzle_migrations")
const { newDb } = yield* upgradeIfNeeded(migrationsTable, session, migrations)
if (newDb) {
yield* session.run(sql`
CREATE TABLE IF NOT EXISTS ${sql.identifier(migrationsTable)} (
id INTEGER PRIMARY KEY,
hash text NOT NULL,
created_at numeric,
name text,
applied_at TEXT
)
`)
}
const dbMigrations = yield* session.all<{ id: number; hash: string; created_at: string; name: string | null }>(
sql`SELECT id, hash, created_at, name FROM ${sql.identifier(migrationsTable)}`,
)
if (typeof config === "object" && config.init) {
if (dbMigrations.length) {
return yield* new MigratorInitError({ exitCode: "databaseMigrations" })
}
if (migrations.length > 1) {
return yield* new MigratorInitError({ exitCode: "localMigrations" })
}
const [migration] = migrations
if (!migration) return
yield* session.run(
sql`insert into ${sql.identifier(
migrationsTable,
)} ("hash", "created_at", "name", "applied_at") values(${migration.hash}, ${migration.folderMillis}, ${migration.name}, ${new Date().toISOString()})`,
)
return
}
const migrationsToRun = getMigrationsToRun({ localMigrations: migrations, dbMigrations })
if (migrationsToRun.length === 0) return
yield* session.transaction((tx) =>
Effect.gen(function* () {
for (const migration of migrationsToRun) {
for (const stmt of migration.sql) {
yield* tx.run(sql.raw(stmt))
}
yield* tx.run(
sql`insert into ${sql.identifier(
migrationsTable,
)} ("hash", "created_at", "name", "applied_at") values(${migration.hash}, ${migration.folderMillis}, ${migration.name}, ${new Date().toISOString()})`,
)
}
}),
)
})
@@ -0,0 +1,102 @@
/* oxlint-disable */
import * as Effect from "effect/Effect"
import type { SqlError } from "effect/unstable/sql/SqlError"
import { EffectDrizzleError } from "drizzle-orm/effect-core/errors"
import type { QueryEffectHKTBase } from "drizzle-orm/effect-core/query-effect"
import type { MigrationMeta } from "drizzle-orm/migrator"
import { sql } from "drizzle-orm/sql/sql"
import type { SQLiteEffectSession } from "../sqlite-core/effect/session.js"
import {
buildSQLiteMigrationBackfillStatements,
prepareSQLiteMigrationBackfill,
type SQLiteMigrationTableRow,
} from "./sqlite.js"
import { GET_VERSION_FOR, MIGRATIONS_TABLE_VERSIONS, type UpgradeResult } from "./utils.js"
const migrationUpgradeError = (cause: unknown) =>
new EffectDrizzleError({
message:
typeof cause === "object" && cause !== null && "message" in cause && typeof cause.message === "string"
? cause.message
: String(cause),
cause,
})
export const upgradeIfNeeded: <TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
) => Effect.Effect<UpgradeResult, EffectDrizzleError | TEffectHKT["error"] | SqlError, TEffectHKT["context"]> =
Effect.fn("upgradeIfNeeded")(function* <TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
) {
const tableExists = yield* session.all(
sql`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ${migrationsTable}`,
)
if (tableExists.length === 0) {
return { newDb: true }
}
const rows = yield* session.all<{ column_name: string }>(
sql`SELECT name as column_name FROM pragma_table_info(${migrationsTable})`,
)
const version = GET_VERSION_FOR.sqlite(rows.map((r) => r.column_name))
for (let v = version; v < MIGRATIONS_TABLE_VERSIONS.sqlite; v++) {
const upgradeFn = upgradeFunctions[v]
if (!upgradeFn) {
return yield* new EffectDrizzleError({
message: `No upgrade path from migration table version ${v} to ${v + 1}`,
cause: { version: v },
})
}
yield* upgradeFn(migrationsTable, session, localMigrations)
}
return { newDb: false }
})
const upgradeFunctions: Record<
number,
<TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
) => Effect.Effect<void, EffectDrizzleError | TEffectHKT["error"] | SqlError, TEffectHKT["context"]>
> = {
0: upgradeFromV0,
}
function upgradeFromV0<TEffectHKT extends QueryEffectHKTBase>(
migrationsTable: string,
session: SQLiteEffectSession<TEffectHKT>,
localMigrations: MigrationMeta[],
): Effect.Effect<void, EffectDrizzleError | TEffectHKT["error"] | SqlError, TEffectHKT["context"]> {
return Effect.gen(function* () {
const table = sql`${sql.identifier(migrationsTable)}`
const dbRows = yield* session.all<SQLiteMigrationTableRow>(
sql`SELECT id, hash, created_at FROM ${table} ORDER BY id ASC`,
)
const statements = yield* Effect.try({
try: () =>
buildSQLiteMigrationBackfillStatements(
migrationsTable,
prepareSQLiteMigrationBackfill(dbRows, localMigrations),
),
catch: migrationUpgradeError,
})
yield* session.transaction((tx) =>
Effect.gen(function* () {
for (const statement of statements) {
yield* tx.run(statement)
}
}),
)
})
}
@@ -0,0 +1,105 @@
/* oxlint-disable */
import type { MigrationMeta } from "drizzle-orm/migrator"
import { type SQL, sql } from "drizzle-orm/sql/sql"
/** @internal */
export type SQLiteMigrationTableRow = { id: number | null; hash: string; created_at: number }
type SQLiteMigrationBackfillEntry = {
name: string
selector:
| { column: "id"; value: number }
| { column: "created_at"; value: number }
| { column: "hash"; value: string }
}
function unmatchedMigrationError(unmatched: SQLiteMigrationTableRow[]) {
return new Error(
`While upgrading your database migrations table we found ${unmatched.length} (${unmatched
.map((it) => `[id: ${it.id}, created_at: ${it.created_at}]`)
.join(
", ",
)}) migrations in the database that do not match any local migration. This means that some migrations were applied to the database but are missing from the local environment`,
)
}
/** @internal */
export function prepareSQLiteMigrationBackfill(
dbRows: SQLiteMigrationTableRow[],
localMigrations: MigrationMeta[],
): SQLiteMigrationBackfillEntry[] {
const sortedLocalMigrations = [...localMigrations].sort((a, b) =>
a.folderMillis !== b.folderMillis ? a.folderMillis - b.folderMillis : (a.name ?? "").localeCompare(b.name ?? ""),
)
const byMillis = new Map<number, MigrationMeta[]>()
const byHash = new Map<string, MigrationMeta>()
for (const migration of sortedLocalMigrations) {
if (!byMillis.has(migration.folderMillis)) {
byMillis.set(migration.folderMillis, [])
}
byMillis.get(migration.folderMillis)!.push(migration)
byHash.set(migration.hash, migration)
}
const toApply: SQLiteMigrationBackfillEntry[] = []
const unmatched: SQLiteMigrationTableRow[] = []
for (const dbRow of dbRows) {
const stringified = String(dbRow.created_at)
const millis = Number(stringified.substring(0, stringified.length - 3) + "000")
const candidates = byMillis.get(millis)
const matchedByMillis = candidates?.length === 1 ? candidates[0] : undefined
const matchedByCandidateHash =
candidates && candidates.length > 1
? candidates.find((candidate) => candidate.hash && dbRow.hash && candidate.hash === dbRow.hash)
: undefined
const matchedByHash = matchedByMillis || matchedByCandidateHash ? undefined : byHash.get(dbRow.hash)
const matched = matchedByMillis ?? matchedByCandidateHash ?? matchedByHash
if (matched) {
toApply.push({
name: matched.name,
selector:
dbRow.id !== null
? { column: "id", value: dbRow.id }
: matchedByMillis
? { column: "created_at", value: dbRow.created_at }
: { column: "hash", value: dbRow.hash },
})
continue
}
unmatched.push(dbRow)
}
if (unmatched.length > 0) {
throw unmatchedMigrationError(unmatched)
}
return toApply
}
/** @internal */
export function buildSQLiteMigrationBackfillStatements(
migrationsTable: string,
backfillEntries: SQLiteMigrationBackfillEntry[],
) {
const table = sql`${sql.identifier(migrationsTable)}`
const statements: SQL[] = [
sql`ALTER TABLE ${table} ADD COLUMN ${sql.identifier("name")} text`,
sql`ALTER TABLE ${table} ADD COLUMN ${sql.identifier("applied_at")} TEXT`,
]
for (const backfillEntry of backfillEntries) {
const updateQuery = sql`UPDATE ${table} SET ${sql.identifier("name")} = ${backfillEntry.name}, ${sql.identifier(
"applied_at",
)} = NULL WHERE`
updateQuery.append(sql` ${sql.identifier(backfillEntry.selector.column)} = ${backfillEntry.selector.value}`)
statements.push(updateQuery)
}
return statements
}
@@ -0,0 +1,45 @@
/* oxlint-disable */
export interface UpgradeResult {
newDb: boolean
}
export const MIGRATIONS_TABLE_VERSIONS = {
sqlite: 1,
pg: 1,
effect: 1,
mysql: 1,
mssql: 1,
cockroach: 1,
singlestore: 1,
} as const
export const GET_VERSION_FOR = {
mysql: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
pg: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
effect: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
mssql: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
cockroach: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
singlestore: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
sqlite: (columns: string[]): number => {
if (columns.includes("name")) return 1
return 0
},
} as const
+6 -33
View File
@@ -66,39 +66,12 @@ export function applyOnly(db: Database, input: Migration[]) {
if (
yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${"__drizzle_migrations"}`)
) {
const named = (yield* db.all<{ name: string }>(
sql`SELECT name FROM pragma_table_info('__drizzle_migrations')`,
)).some((column) => column.name === "name")
if (named) {
yield* db.run(sql`
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
SELECT name, ${Date.now()}
FROM ${sql.identifier("__drizzle_migrations")}
WHERE name IS NOT NULL
`)
}
if (!named) {
const entries = yield* db.all<{ created_at: number; prefix: string | null }>(sql`
SELECT created_at, strftime('%Y%m%d%H%M%S', created_at / 1000, 'unixepoch') AS prefix
FROM ${sql.identifier("__drizzle_migrations")}
WHERE created_at IS NOT NULL
`)
for (const entry of entries) {
const migration = input.find((item) => item.id.startsWith(`${entry.prefix}_`))
if (!migration) {
return yield* Effect.die(
new Error(`Legacy migration timestamp ${entry.created_at} does not match any known migration`),
)
}
yield* db.run(sql`
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
VALUES (${migration.id}, ${Date.now()})
`)
}
}
yield* db.run(sql`
INSERT OR IGNORE INTO ${sql.identifier("migration")} (id, time_completed)
SELECT name, ${Date.now()}
FROM ${sql.identifier("__drizzle_migrations")}
WHERE name IS NOT NULL
`)
completed = new Set(
(yield* db.all<{ id: string }>(sql`SELECT id FROM ${sql.identifier("migration")}`)).map((row) => row.id),
)
@@ -5,9 +5,6 @@ const migration: DatabaseMigration.Migration = {
id: "20260410174513_workspace-name",
up(tx) {
return Effect.gen(function* () {
const columns = yield* tx.all<{ name: string }>(`PRAGMA table_info(\`workspace\`)`)
const name = columns.some((column) => column.name === "name") ? "`name`" : "''"
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
yield* tx.run(`
CREATE TABLE \`__new_workspace\` (
@@ -22,7 +19,7 @@ const migration: DatabaseMigration.Migration = {
);
`)
yield* tx.run(
`INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, ${name}, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`,
`INSERT INTO \`__new_workspace\`(\`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\`) SELECT \`id\`, \`type\`, \`branch\`, \`name\`, \`directory\`, \`extra\`, \`project_id\` FROM \`workspace\`;`,
)
yield* tx.run(`DROP TABLE \`workspace\`;`)
yield* tx.run(`ALTER TABLE \`__new_workspace\` RENAME TO \`workspace\`;`)
+76 -49
View File
@@ -166,8 +166,6 @@ export interface Interface extends State.Transformable<Draft> {
/** User-facing label for the stored credential. */
readonly label?: string
}) => Effect.Effect<void, AuthorizationError>
/** Selects a stored credential as the active integration connection. */
readonly activate: (credentialID: Credential.ID) => Effect.Effect<void>
/** Updates a stored credential exposed as a connection. */
readonly update: (
credentialID: Credential.ID,
@@ -266,6 +264,24 @@ const layer = Layer.effect(
const scope = yield* Scope.Scope
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
const commandAttempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, CommandAttemptEntry>())
const environmentConnections = new Map<ID, { name: string; value: string }>()
const resolveConnections = (
entry: { readonly methods: readonly Method[] } | undefined,
saved: readonly Credential.Info[],
) => {
const credentials = saved
.map((credential) => ({
type: "credential" as const,
id: credential.id,
label: credential.label,
}))
.toReversed()
const env = (entry?.methods ?? [])
.filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name]))
.map((name) => ({ type: "env" as const, name }))
return [...credentials, ...env]
}
const state = State.create<Data, Draft>({
name: "integration",
initial: () => ({ integrations: new Map<ID, Entry>() }),
@@ -328,40 +344,40 @@ const layer = Layer.effect(
},
},
}),
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
finalize: (draft) =>
Effect.gen(function* () {
const current = new Map(
draft.list().flatMap((integration) => {
const connection = resolveConnections({ methods: draft.method.list(integration.id) }, [])[0]
return connection?.type === "env"
? [[integration.id, { name: connection.name, value: process.env[connection.name] ?? "" }] as const]
: []
}),
)
const changed = Array.from(new Set([...environmentConnections.keys(), ...current.keys()])).filter((id) => {
const previous = environmentConnections.get(id)
const next = current.get(id)
return previous?.name !== next?.name || previous?.value !== next?.value
})
environmentConnections.clear()
current.forEach((connection, id) => environmentConnections.set(id, connection))
yield* Effect.forEach(
changed,
(integrationID) =>
Effect.gen(function* () {
if ((yield* credentials.list(integrationID)).length > 0) return
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
}),
{ discard: true },
)
yield* bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid)
}),
})
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
if (input.label !== undefined) return yield* credentials.create(input)
const name = state.get().integrations.get(input.integrationID)?.ref.name ?? input.integrationID
const labels = new Set((yield* credentials.list(input.integrationID)).map((credential) => credential.label))
const label =
Array.from({ length: labels.size + 1 }, (_, index) => (index === 0 ? name : `${name} ${index + 1}`)).find(
(candidate) => !labels.has(candidate),
) ?? name
return yield* credentials.create({ ...input, label })
})
const resolveConnections = (entry: Entry | undefined, saved: readonly Credential.Info[]) => {
const credentials = saved
.map((credential) => ({
type: "credential" as const,
id: credential.id,
label: credential.label,
}))
.toReversed()
const env = (entry?.methods ?? [])
.filter((method) => method.type === "env")
.flatMap((method) => method.names.filter((name) => process.env[name]))
.map((name) => ({ type: "env" as const, name }))
return [...credentials, ...env]
}
const project = (entry: Entry, connections: IntegrationConnection.Info[]): Info =>
Info.make({
id: entry.ref.id,
name: entry.ref.name,
...(entry.ref.metadata === undefined ? {} : { metadata: entry.ref.metadata }),
methods: entry.methods,
connections,
})
@@ -408,7 +424,7 @@ const layer = Layer.effect(
?.implementations.get(attempt.methodID)
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
Effect.flatMap((label) =>
createCredential({
credentials.create({
integrationID: attempt.integrationID,
label,
value: exit.value,
@@ -435,6 +451,8 @@ const layer = Layer.effect(
// Persisting attempts cannot be cancelled, expired, or claimed again.
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
}).pipe(Effect.ensuring(close(attempt.scope)))
}),
)
@@ -464,11 +482,13 @@ const layer = Layer.effect(
return
}
const persistence = yield* createCredential({
integrationID: attempt.integrationID,
label: attempt.label,
value: Credential.Key.make({ type: "key", key: exit.value }),
}).pipe(Effect.asVoid, Effect.exit)
const persistence = yield* credentials
.create({
integrationID: attempt.integrationID,
label: attempt.label,
value: Credential.Key.make({ type: "key", key: exit.value }),
})
.pipe(Effect.asVoid, Effect.exit)
const settledAt = yield* Clock.currentTimeMillis
const terminal: TerminalCommandAttempt = Exit.isSuccess(persistence)
? {
@@ -486,6 +506,9 @@ const layer = Layer.effect(
}
yield* SynchronizedRef.update(commandAttempts, (current) => new Map(current).set(attemptID, terminal))
yield* close(attempt.scope)
if (Exit.isFailure(persistence)) return
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: attempt.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
}),
)
})
@@ -709,7 +732,7 @@ const layer = Layer.effect(
if (!method.form && Object.keys(answer).length > 0) {
return yield* new AuthorizationError({ cause: new Error("Key method does not accept a form answer") })
}
yield* createCredential({
yield* credentials.create({
integrationID: input.integrationID,
label: input.label,
value: Credential.Key.make({
@@ -718,21 +741,25 @@ const layer = Layer.effect(
...(Object.keys(answer).length > 0 ? { configuration: answer } : {}),
}),
})
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: input.integrationID })
yield* bus.publish(Integration.Event.Updated, {})
}),
activate: Effect.fn("Integration.connection.activate")(function* (credentialID) {
update: Effect.fn("Integration.connection.update")(function* (credentialID, updates) {
const credential = yield* credentials.get(credentialID)
if (!credential) return
const active = resolveConnections(
state.get().integrations.get(credential.integrationID),
yield* credentials.list(credential.integrationID),
)[0]
if (active?.type === "credential" && active.id === credentialID) return
yield* credentials.activate(credentialID)
yield* credentials.update(credentialID, updates)
if (credential) {
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* bus.publish(Integration.Event.Updated, {})
}),
remove: Effect.fn("Integration.connection.remove")(function* (credentialID) {
const credential = yield* credentials.get(credentialID)
yield* credentials.remove(credentialID)
if (credential) {
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID: credential.integrationID })
}
yield* bus.publish(Integration.Event.Updated, {})
}),
update: Effect.fn("Integration.connection.update")((credentialID, updates) =>
credentials.update(credentialID, updates),
),
remove: Effect.fn("Integration.connection.remove")((credentialID) => credentials.remove(credentialID)),
},
oauth: {
connect: connectOAuth,
+10 -7
View File
@@ -140,7 +140,8 @@ export type Draft = {
remove: (server: ServerName | string) => void
}
const cloneConfig = (config: Mcp.ServerConfig) => structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
const cloneConfig = (config: Mcp.ServerConfig) =>
structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
export interface Interface extends State.Transformable<Draft> {
readonly servers: () => Effect.Effect<ServerInfo[]>
@@ -228,7 +229,6 @@ export const layer = (options?: Options) =>
.transform((draft) => {
draft.update(integrationID, (ref) => {
ref.name = name
ref.metadata = { source: "mcp" }
})
draft.method.update({
integrationID,
@@ -264,7 +264,8 @@ export const layer = (options?: Options) =>
// No browser during connect: an auth-gated server surfaces needs_auth instead of opening a browser.
onRedirect: () => {},
}
const found = (yield* credentials.list(entry.integrationID)).at(-1)
const stored = yield* credentials.list(entry.integrationID)
const found = stored.find((credential) => credential.value.type === "oauth")
if (!found || found.value.type !== "oauth")
// No stored credential yet: an empty in-memory store still lets the SDK run the auth handshake, which
// ends in UnauthorizedError -> needs_auth. Returning no provider instead would let the transport throw
@@ -287,8 +288,8 @@ export const layer = (options?: Options) =>
// Drop a credential the SDK rejected so the next connect cleanly reports needs_auth — but only if it is
// still the stored one. Rotating servers hand out a fresh refresh token per use, so a concurrent
// connection may have already replaced ours; deleting then would discard the newer valid credential and
// strand every connection in needs_auth until a manual re-auth. Credential deletion notifies all locations;
// reconnects remain serialized by the server lock.
// strand every connection in needs_auth until a manual re-auth. Uses the raw credential service (no
// integration event) to avoid re-triggering the reconnect subscriber mid-connect.
invalidate: async (scope) => {
if (scope === "verifier" || scope === "discovery") return
const oauth = await readOAuthCredential()
@@ -672,7 +673,7 @@ export const layer = (options?: Options) =>
}).pipe(locks.withLock(name))
})
fork(
bus.subscribe(Credential.Event.Switched).pipe(
bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => owned.has(event.data.integrationID)),
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
Effect.ignore,
@@ -686,7 +687,9 @@ export const layer = (options?: Options) =>
config === false ? [] : [[name, cloneConfig(config)] as const],
),
),
removed: new Set(Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : []))),
removed: new Set(
Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : [])),
),
}),
draft: (draft) => ({
list: () => Array.from(draft.servers),
-4
View File
@@ -333,10 +333,8 @@ function usesAPIKeyAuth(packageName: string | undefined) {
return (
name === "@ai-sdk/openai" ||
name === "@ai-sdk/anthropic" ||
name === "@ai-sdk/cerebras" ||
name === "@ai-sdk/openai-compatible" ||
name === "@ai-sdk/google" ||
name === "@ai-sdk/togetherai" ||
name === "@ai-sdk/xai" ||
name === "@openrouter/ai-sdk-provider" ||
name === "@ai-sdk/azure" ||
@@ -344,10 +342,8 @@ function usesAPIKeyAuth(packageName: string | undefined) {
name?.startsWith("@opencode-ai/ai/providers/openai/") === true ||
name === "@opencode-ai/ai/providers/anthropic" ||
name === "@opencode-ai/ai/providers/anthropic-compatible" ||
name === "@opencode-ai/ai/providers/cerebras" ||
name === "@opencode-ai/ai/providers/openai-compatible" ||
name === "@opencode-ai/ai/providers/google" ||
name === "@opencode-ai/ai/providers/togetherai" ||
name === "@opencode-ai/ai/providers/xai" ||
name === "@opencode-ai/ai/providers/openrouter" ||
name === "@opencode-ai/ai/providers/azure" ||
+56 -49
View File
@@ -11,7 +11,6 @@ import { SessionSchema } from "./session/schema.js"
import { SessionStore } from "./session/store.js"
import { Wildcard } from "./util/wildcard.js"
import { PermissionSaved } from "./permission/saved.js"
import { PluginHooks } from "./plugin/hooks.js"
const PermissionEffect = Permission.Effect
export { PermissionEffect as Effect }
@@ -71,10 +70,9 @@ export class BlockedError extends Schema.TaggedError<BlockedError>()("Permission
rules: Permission.Ruleset,
permission: Schema.String,
resources: Schema.Array(Schema.String),
reason: Schema.String.pipe(Schema.optional),
}) {
override get message() {
return this.reason ?? `Permission denied: ${this.permission}`
return `Permission denied: ${this.permission}`
}
}
@@ -101,6 +99,11 @@ export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
}
export interface Interface {
readonly allowsAll: (input: {
readonly sessionID: SessionSchema.ID
readonly action: string
readonly agent?: Agent.ID
}) => Effect.Effect<boolean, SessionErrors.NotFoundError>
readonly ask: (input: AssertInput) => Effect.Effect<AskResult, SessionErrors.NotFoundError>
readonly assert: (input: AssertInput) => Effect.Effect<void, Error | SessionErrors.NotFoundError>
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
@@ -125,7 +128,6 @@ const layer = Layer.effect(
const agents = yield* Agent.Service
const sessions = yield* SessionStore.Service
const saved = yield* PermissionSaved.Service
const hooks = yield* PluginHooks.Service
const pending = new Map<ID, Pending>()
yield* Effect.addFinalizer(() =>
@@ -157,6 +159,24 @@ const layer = Layer.effect(
return agent?.permissions ?? missingAgentPermissions
})
const allowsAll = Effect.fnUntraced(function* (input: {
readonly sessionID: SessionSchema.ID
readonly action: string
readonly agent?: Agent.ID
}) {
const rules = yield* configured(input.sessionID, input.agent)
const relevant = rules.filter((rule) => Wildcard.match(input.action, rule.action))
for (let index = relevant.length - 1; index >= 0; index--) {
const rule = relevant[index]
if (rule.resource !== "*") {
if (rule.effect !== "allow") return false
continue
}
return rule.effect === "allow"
}
return false
})
function denied(input: Pick<Request, "action" | "resources">, rules: Permission.Ruleset) {
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
}
@@ -171,19 +191,10 @@ const layer = Layer.effect(
const all = [...rules, ...(yield* savedRules())]
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
const event = yield* hooks.trigger("permission", "evaluate", {
sessionID: input.sessionID,
agent: input.agent,
action: input.action,
resources: input.resources,
metadata: input.metadata,
source: input.source,
effect,
})
return { effect: event.effect, message: event.message, rules: all }
return { effect, rules: all }
})
function request(input: AssertInput, message?: string): Request {
function request(input: AssertInput): Request {
return {
id: input.id ?? ID.create(),
sessionID: input.sessionID,
@@ -192,7 +203,6 @@ const layer = Layer.effect(
save: input.save,
metadata: input.metadata,
source: input.source,
message,
}
}
@@ -213,42 +223,39 @@ const layer = Layer.effect(
const ask = Effect.fn("Permission.ask")(function* (input: AssertInput) {
const result = yield* evaluateInput(input)
const value = request(input, result.message)
const value = request(input)
if (result.effect === "ask") yield* create(value, input.agent)
return { id: value.id, effect: result.effect }
})
const assert = Effect.fn("Permission.assert")((input: AssertInput) =>
Effect.gen(function* () {
const result = yield* evaluateInput(input)
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
if (result.effect === "deny") {
return yield* new BlockedError({
rules: relevant(input, result.rules),
permission: input.action,
resources: input.resources,
reason: result.message,
})
}
if (result.effect === "allow") return
const item = yield* create(request(input, result.message), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
pending.delete(item.request.id)
}),
),
)
}),
)
}),
Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const result = yield* evaluateInput(input)
if (result.effect === "deny") {
return yield* new BlockedError({
rules: relevant(input, result.rules),
permission: input.action,
resources: input.resources,
})
}
if (result.effect === "allow") return
const item = yield* create(request(input), input.agent)
return yield* restore(Deferred.await(item.deferred)).pipe(
// Deliberate defect tunnel: leaves wrap execution in blanket `mapError`, which
// must not convert a user's decline into model-facing tool output. The decline
// resurfaces as a typed failure at SessionModelRequest.executeTool. A decline
// WITH feedback (CorrectedError) intentionally stays typed so the leaf can turn
// it into ToolFailure and the model continues.
Effect.catchTag("Permission.DeclinedError", (error) => Effect.die(error)),
Effect.ensuring(
Effect.sync(() => {
pending.delete(item.request.id)
}),
),
)
}),
),
)
const reply = Effect.fn("Permission.reply")((input: ReplyInput) =>
@@ -330,12 +337,12 @@ const layer = Layer.effect(
return Array.from(pending.values(), (item) => item.request).filter((request) => request.sessionID === sessionID)
})
return Service.of({ ask, assert, reply, get, forSession, list })
return Service.of({ allowsAll, ask, assert, reply, get, forSession, list })
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node, PluginHooks.node],
deps: [Bus.node, Location.node, Agent.node, SessionStore.node, PermissionSaved.node],
})
@@ -1,82 +0,0 @@
import { createHash } from "node:crypto"
import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises"
import path from "node:path"
import asset from "./pty-binding.js"
export async function resolveBinary(bin: string) {
if (process.env.OPENCODE_PTY_BIN) return process.env.OPENCODE_PTY_BIN
if (!asset) return "opencode-pty"
return install(bin, asset)
}
async function install(
bin: string,
input: { readonly path: string; readonly version: string; readonly sha256: string },
) {
const root = path.join(bin, "opencode-pty")
await privateDirectory(root)
const directory = path.join(root, `${input.version}-${input.sha256.slice(0, 16)}`)
await privateDirectory(directory)
const destination = path.join(directory, "opencode-pty")
if (await exists(destination, input.sha256)) return destination
const bytes = new Uint8Array(await Bun.file(input.path).arrayBuffer())
if (sha256(bytes) !== input.sha256) throw new Error("Embedded opencode-pty checksum mismatch")
const temporary = path.join(directory, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
try {
const file = await open(temporary, "wx", 0o700)
try {
await file.writeFile(bytes)
await file.sync()
} finally {
await file.close()
}
await chmod(temporary, 0o755)
await rename(temporary, destination).catch(async (error) => {
if (!(await exists(destination, input.sha256))) throw error
})
} finally {
await rm(temporary, { force: true })
}
return validate(destination, input.sha256)
}
async function privateDirectory(directory: string) {
await mkdir(directory, { recursive: true, mode: 0o700 })
const info = await lstat(directory)
if (!info.isDirectory() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty directory: ${directory}`)
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
if (uid !== undefined && info.uid !== uid)
throw new Error(`opencode-pty directory is owned by another user: ${directory}`)
await chmod(directory, 0o700)
}
async function exists(file: string, expected: string) {
try {
await validate(file, expected)
return true
} catch (error) {
if (isMissing(error)) return false
throw error
}
}
async function validate(file: string, expected?: string) {
const info = await lstat(file)
if (!info.isFile() || info.isSymbolicLink()) throw new Error(`Unsafe opencode-pty executable: ${file}`)
const uid = typeof process.getuid === "function" ? process.getuid() : undefined
if (uid !== undefined && info.uid !== uid)
throw new Error(`opencode-pty executable is owned by another user: ${file}`)
if (expected && sha256(await readFile(file)) !== expected)
throw new Error(`Cached opencode-pty checksum mismatch: ${file}`)
await chmod(file, 0o755)
return file
}
function sha256(bytes: Uint8Array) {
return createHash("sha256").update(bytes).digest("hex")
}
function isMissing(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error && error.code === "ENOENT"
}
@@ -1,3 +0,0 @@
export async function resolveBinary() {
return process.env.OPENCODE_PTY_BIN || "opencode-pty"
}
@@ -1,3 +0,0 @@
export async function resolveBinary(): Promise<string> {
throw new Error("Persistent PTYs are unavailable in this runtime")
}
+2 -13
View File
@@ -10,7 +10,6 @@ import { Session } from "@opencode-ai/schema/session"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { Pty } from "@opencode-ai/schema/pty"
import { Global } from "@opencode-ai/util/global"
import {
makeDaemonTransport,
type DaemonTransport,
@@ -19,7 +18,6 @@ import {
type WireResponse,
type WireTerminal,
} from "./daemon.js"
import { resolveBinary } from "#persistent-pty-binary"
export type { Role, StreamEvent } from "./daemon.js"
@@ -122,18 +120,9 @@ export const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const database = yield* Database.Service
const global = yield* Global.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
let binary: Promise<string> | undefined
const daemon = yield* makeDaemonTransport(
runtimeDirectory(databasePath(database.db)),
() =>
(binary ??= resolveBinary(global.bin).catch((error) => {
binary = undefined
throw error
})),
)
const daemon = yield* makeDaemonTransport(runtimeDirectory(databasePath(database.db)))
const removing = new Set<Pty.ID>()
const list = Effect.fn("PersistentPty.list")(function* (sessionID?: Session.ID) {
@@ -328,7 +317,7 @@ export const layer = Layer.effect(
}),
)
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Database.node, Global.node] })
export const node = makeGlobalNode({ service: Service, layer, deps: [Bus.node, Database.node] })
const request = (daemon: DaemonTransport, value: object, start = false) =>
daemon.request(value, start).pipe(Effect.mapError(unavailable))
@@ -1,3 +0,0 @@
const asset: { readonly path: string; readonly version: string; readonly sha256: string } | undefined = undefined
export default asset

Some files were not shown because too many files have changed in this diff Show More