mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-26 11:36:14 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dc2b6336c | ||
|
|
6a23adcd6c |
@@ -71,7 +71,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 +110,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.pull_request.base.sha || github.event.before }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
|
||||
- name: Verify published codemode package
|
||||
if: runner.os == 'Linux'
|
||||
@@ -130,15 +122,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.pull_request.base.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()
|
||||
@@ -179,6 +164,7 @@ jobs:
|
||||
e2e:
|
||||
name: e2e (${{ matrix.settings.name }})
|
||||
needs: affected
|
||||
if: needs.affected.outputs.app == 'true' && github.ref_name != 'v2' && github.head_ref != 'v2'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -189,38 +175,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 +208,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 }}
|
||||
|
||||
@@ -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",
|
||||
@@ -986,7 +986,6 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -996,7 +995,6 @@
|
||||
"@types/node": "catalog:",
|
||||
"@types/npm-package-arg": "6.1.4",
|
||||
"@types/npmcli__arborist": "6.3.3",
|
||||
"@types/pacote": "11.1.8",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
},
|
||||
},
|
||||
@@ -1208,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=="],
|
||||
@@ -5954,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
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-fOM/kGJJ1cipCHQIxioDZEB7NZykpSiqgwm7gIS6THI=",
|
||||
"aarch64-linux": "sha256-XTY2C33HjsBMWO7VIiWc2MynjJrxbTLrOJ+6pM+afI0=",
|
||||
"aarch64-darwin": "sha256-wX6+bC18djtPZ7A9ch+wryM7tDFfrAlT0xx0QTk6EJQ=",
|
||||
"x86_64-darwin": "sha256-dcRRX4bYq5AmG4GcVmYq/M+06dlf4KJHn+clT2JY48g="
|
||||
"x86_64-linux": "sha256-3Jx1Q7hl+Y0Log/k2vd5y6dzBpzFKWlhShPESxn1Rm4=",
|
||||
"aarch64-linux": "sha256-EiiI6g01oBIrExCMAUgT3w82P0fvu4FAJhI32C+ze0I=",
|
||||
"aarch64-darwin": "sha256-s+w49HRp1+ewtiTaU65tPWjUiO1NQw3kzfemMEEQZb0=",
|
||||
"x86_64-darwin": "sha256-/Ee5V7pnL/qm3c4ZHeWEjH7FhGVXArXryOugbG5vsz8="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -373,7 +317,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 +327,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.
|
||||
@@ -403,6 +346,7 @@ export interface ParserState {
|
||||
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 +528,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 +554,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 +590,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 +615,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", [
|
||||
@@ -714,11 +662,10 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
|
||||
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 } : {}),
|
||||
@@ -944,23 +891,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 +964,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 +1051,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 +1116,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(
|
||||
@@ -1288,11 +1245,10 @@ 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 } }
|
||||
event.output_index !== undefined && event.item?.id
|
||||
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
|
||||
: state,
|
||||
event,
|
||||
),
|
||||
@@ -1335,6 +1291,7 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
messageItems: new Set<string>(),
|
||||
messagePhases: {},
|
||||
reasoningItems: {},
|
||||
store: OpenResponsesOptions.resolve(request).store,
|
||||
})
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
-32
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-32
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-50
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+9
-5
File diff suppressed because one or more lines are too long
+2
-2
@@ -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",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+3
-3
@@ -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",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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,
|
||||
|
||||
Vendored
+1
-1
@@ -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,
|
||||
|
||||
Vendored
+2
-2
@@ -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,44 +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({
|
||||
@@ -288,43 +250,6 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
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",
|
||||
@@ -1594,8 +1597,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: [
|
||||
@@ -2117,47 +2120,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
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(
|
||||
@@ -2336,25 +2298,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 +2758,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 +2776,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 +2786,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 +2891,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 +2901,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 +2909,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 +2931,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 +2943,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 +2968,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 +2981,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 +2990,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 +3046,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 +3073,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 +3119,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(
|
||||
@@ -3507,43 +3301,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 +3525,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 +3828,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 +3847,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 +3855,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"}' }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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" }))
|
||||
@@ -106,70 +106,16 @@ describe("xAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
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 +127,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" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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"}`)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"] })
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -88,35 +88,8 @@ export type PluginListInput = {
|
||||
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
||||
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type PluginCheckOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.UpdateInfo> }
|
||||
export type PluginCheckOperation<E = never> = (input?: PluginCheckInput) => Effect.Effect<PluginCheckOutput, E>
|
||||
|
||||
export type PluginUpdateInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly name: string
|
||||
}
|
||||
export type PluginUpdateOutput = { readonly location: Location.Info; readonly data: Plugin.UpdateResult }
|
||||
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
|
||||
|
||||
export type PluginUpdateAllInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type PluginUpdateAllOutput = {
|
||||
readonly location: Location.Info
|
||||
readonly data: ReadonlyArray<Plugin.UpdateResult>
|
||||
}
|
||||
export type PluginUpdateAllOperation<E = never> = (
|
||||
input?: PluginUpdateAllInput,
|
||||
) => Effect.Effect<PluginUpdateAllOutput, E>
|
||||
|
||||
export interface PluginApi<E = never> {
|
||||
readonly list: PluginListOperation<E>
|
||||
readonly check: PluginCheckOperation<E>
|
||||
readonly update: PluginUpdateOperation<E>
|
||||
readonly updateAll: PluginUpdateAllOperation<E>
|
||||
}
|
||||
|
||||
export type SessionListInput = {
|
||||
@@ -1360,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
|
||||
@@ -1380,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>
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
PluginUpdateAllInput,
|
||||
PluginUpdateAllOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -144,8 +138,6 @@ import type {
|
||||
McpResourceCatalogOutput,
|
||||
CredentialUpdateInput,
|
||||
CredentialUpdateOutput,
|
||||
CredentialActivateInput,
|
||||
CredentialActivateOutput,
|
||||
CredentialRemoveInput,
|
||||
CredentialRemoveOutput,
|
||||
ProjectListOutput,
|
||||
@@ -319,29 +311,7 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
|
||||
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: PluginCheckInput) =>
|
||||
preserveEffect<PluginCheckOutput>()(
|
||||
raw["plugin.check"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
|
||||
preserveEffect<PluginUpdateOutput>()(
|
||||
raw["plugin.update"]({ query: { location: input["location"] }, payload: { name: input["name"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointPluginUpdateAll = (raw: RawClient["server.plugin"]) => (input?: PluginUpdateAllInput) =>
|
||||
preserveEffect<PluginUpdateAllOutput>()(
|
||||
raw["plugin.updateAll"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
|
||||
list: EndpointPluginList(raw),
|
||||
check: EndpointPluginCheck(raw),
|
||||
update: EndpointPluginUpdate(raw),
|
||||
updateAll: EndpointPluginUpdateAll(raw),
|
||||
})
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({ list: EndpointPluginList(raw) })
|
||||
|
||||
const EndpointSessionList = (raw: RawClient["server.session"]) => (input?: SessionListInput) =>
|
||||
preserveEffect<SessionListOutput>()(
|
||||
@@ -965,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"]({
|
||||
@@ -983,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),
|
||||
})
|
||||
|
||||
|
||||
@@ -9,12 +9,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
PluginUpdateOutput,
|
||||
PluginUpdateAllInput,
|
||||
PluginUpdateAllOutput,
|
||||
SessionListInput,
|
||||
SessionListOutput,
|
||||
SessionStatsInput,
|
||||
@@ -138,8 +132,6 @@ import type {
|
||||
McpResourceCatalogOutput,
|
||||
CredentialUpdateInput,
|
||||
CredentialUpdateOutput,
|
||||
CredentialActivateInput,
|
||||
CredentialActivateOutput,
|
||||
CredentialRemoveInput,
|
||||
CredentialRemoveOutput,
|
||||
ProjectListOutput,
|
||||
@@ -463,43 +455,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
check: (input?: PluginCheckInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginCheckOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/plugin/update`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
update: (input: PluginUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginUpdateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/update`,
|
||||
query: { location: input["location"] },
|
||||
body: { name: input["name"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
updateAll: (input?: PluginUpdateAllInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginUpdateAllOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/update-all`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
session: {
|
||||
list: (input?: SessionListInput, requestOptions?: RequestOptions) =>
|
||||
@@ -1315,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>(
|
||||
{
|
||||
|
||||
@@ -426,24 +426,6 @@ export type PluginInfo =
|
||||
| { id: string; source: PluginSource; status: "active"; tui: boolean }
|
||||
| { id?: string; source: PluginSource; status: "failed"; error: string; tui: boolean }
|
||||
|
||||
export type PluginUpdateInfo = {
|
||||
name: string
|
||||
source: PluginSource
|
||||
status: "not-updateable" | "pinned" | "up-to-date" | "available" | "failed"
|
||||
currentVersion?: string
|
||||
latestVersion?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type PluginUpdateResult = {
|
||||
name: string
|
||||
source: PluginSource
|
||||
status: "not-updateable" | "pinned" | "up-to-date" | "updated" | "failed"
|
||||
previousVersion?: string
|
||||
version?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type SessionMessageLocationSwitched = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
@@ -841,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
|
||||
@@ -868,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
|
||||
@@ -1337,10 +1310,8 @@ export type ToolContent1 = ToolTextContent | ToolFileContent1
|
||||
|
||||
export type ModelCompatibility = {
|
||||
reasoningField?: ModelReasoningField
|
||||
requireReasoning?: boolean
|
||||
maxTokensField?: ModelMaxTokensField
|
||||
requireFinishReason?: boolean
|
||||
requireAssistantAfterTool?: boolean
|
||||
}
|
||||
|
||||
export type ModelCost = {
|
||||
@@ -1436,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 = {
|
||||
@@ -2151,16 +2104,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
|
||||
@@ -2215,7 +2166,6 @@ export type V2Event =
|
||||
| PermissionReplied
|
||||
| PluginAdded
|
||||
| PluginUpdated
|
||||
| ProjectUpdated
|
||||
| WorktreeUpdated
|
||||
| WorktreeResolved
|
||||
| CommandUpdated
|
||||
@@ -2478,40 +2428,6 @@ export type PluginListOutput = {
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type PluginCheckOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<PluginUpdateInfo>
|
||||
}
|
||||
|
||||
export type PluginUpdateInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly name: { readonly name: string }["name"]
|
||||
}
|
||||
|
||||
export type PluginUpdateOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: PluginUpdateResult
|
||||
}
|
||||
|
||||
export type PluginUpdateAllInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type PluginUpdateAllOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<PluginUpdateResult>
|
||||
}
|
||||
|
||||
export type SessionListInput = {
|
||||
readonly workspace?: {
|
||||
readonly workspace?: string | undefined
|
||||
@@ -4435,15 +4351,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?: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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({
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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
|
||||
@@ -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\`;`)
|
||||
|
||||
@@ -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,
|
||||
@@ -331,17 +329,6 @@ const layer = Layer.effect(
|
||||
finalize: () => 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) => ({
|
||||
@@ -361,7 +348,6 @@ const layer = Layer.effect(
|
||||
Info.make({
|
||||
id: entry.ref.id,
|
||||
name: entry.ref.name,
|
||||
...(entry.ref.metadata === undefined ? {} : { metadata: entry.ref.metadata }),
|
||||
methods: entry.methods,
|
||||
connections,
|
||||
})
|
||||
@@ -408,7 +394,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 +421,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 +452,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 +476,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 +702,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 +711,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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,5 +1,5 @@
|
||||
export * as Plugin from "./plugin.js"
|
||||
export { Event, ID, Info, Source, UpdateInfo, UpdateResult } from "@opencode-ai/schema/plugin"
|
||||
export { Event, ID, Info, Source } from "@opencode-ai/schema/plugin"
|
||||
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
|
||||
@@ -26,6 +26,7 @@ import { OpencodePlugin } from "./provider/opencode.js"
|
||||
import { OpenRouterPlugin } from "./provider/openrouter.js"
|
||||
import { PerplexityPlugin } from "./provider/perplexity.js"
|
||||
import { SapAICorePlugin } from "./provider/sap-ai-core.js"
|
||||
import { TogetherAIPlugin } from "./provider/togetherai.js"
|
||||
import { VercelPlugin } from "./provider/vercel.js"
|
||||
import { VenicePlugin } from "./provider/venice.js"
|
||||
import { VLLMPlugin } from "./provider/vllm.js"
|
||||
@@ -61,6 +62,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
OpenRouterPlugin,
|
||||
PerplexityPlugin,
|
||||
SapAICorePlugin,
|
||||
TogetherAIPlugin,
|
||||
VercelPlugin,
|
||||
VenicePlugin,
|
||||
VLLMPlugin,
|
||||
|
||||
@@ -7,12 +7,20 @@ export const CerebrasPlugin = define({
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.catalog.transform((evt) => {
|
||||
for (const item of evt.provider.list()) {
|
||||
const name = Provider.packageName(item.provider.package)
|
||||
if (name !== "@ai-sdk/cerebras" && name !== "@opencode-ai/ai/providers/cerebras") continue
|
||||
if (!Provider.isAISDK(item.provider.package)) continue
|
||||
if (Provider.packageName(item.provider.package) !== "@ai-sdk/cerebras") continue
|
||||
evt.provider.update(item.provider.id, (provider) => {
|
||||
provider.headers = { ...provider.headers, "X-Cerebras-3rd-Party-Integration": "opencode" }
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.aisdk.hook(
|
||||
"sdk",
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/cerebras") return
|
||||
const mod = yield* Effect.promise(() => import("@ai-sdk/cerebras"))
|
||||
evt.sdk = mod.createCerebras(evt.options)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -221,7 +221,7 @@ export const GithubCopilotPlugin = define({
|
||||
}
|
||||
})
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("github-copilot")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
|
||||
@@ -301,7 +301,7 @@ export const OpenAIPlugin = define({
|
||||
{ providerID: Provider.ID.openai },
|
||||
)
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
|
||||
@@ -193,7 +193,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
})
|
||||
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createProviderPlugin } from "./factory.js"
|
||||
|
||||
export const TogetherAIPlugin = createProviderPlugin({
|
||||
id: "opencode.provider.togetherai",
|
||||
package: "@ai-sdk/togetherai",
|
||||
load: async (options) => {
|
||||
const { createTogetherAI } = await import("@ai-sdk/togetherai")
|
||||
return createTogetherAI(options)
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user