mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-02 13:36:22 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f98799b75 |
No files matched your search
@@ -9,6 +9,10 @@ inputs:
|
||||
description: "Additional flags to pass to 'bun install'"
|
||||
required: false
|
||||
default: ""
|
||||
node-version:
|
||||
description: "Node version to install"
|
||||
required: false
|
||||
default: "24"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -17,7 +21,7 @@ runs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- name: Get baseline download URL
|
||||
id: bun-url
|
||||
@@ -45,16 +49,14 @@ runs:
|
||||
- name: Get cache directory
|
||||
id: cache
|
||||
shell: bash
|
||||
run: |
|
||||
echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$(bun --version)" >> "$GITHUB_OUTPUT"
|
||||
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Restore Bun dependencies
|
||||
id: bun-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.cache.outputs.version }}-${{ hashFiles('bun.lock', 'patches/**') }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
|
||||
- name: Install setuptools for distutils compatibility
|
||||
run: python3 -m pip install setuptools || pip install setuptools || true
|
||||
@@ -77,4 +79,4 @@ runs:
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ steps.bun-cache.outputs.cache-primary-key }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
@@ -7,10 +7,6 @@ on:
|
||||
branches: [dev, v2]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: typecheck
|
||||
|
||||
@@ -73,11 +73,6 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
@@ -94,6 +89,12 @@ jobs:
|
||||
git config --global user.email "bot@opencode.ai"
|
||||
git config --global user.name "opencode"
|
||||
|
||||
- name: Install ffmpeg
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install --yes ffmpeg
|
||||
|
||||
- name: Cache Turbo
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
@@ -196,16 +197,12 @@ jobs:
|
||||
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
|
||||
with:
|
||||
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
|
||||
node-version: "24.15"
|
||||
|
||||
- name: Read Playwright version
|
||||
if: env.E2E_ENABLED == 'true'
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.6.0",
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@clack/core": "1.0.0-alpha.1",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -1230,7 +1230,7 @@
|
||||
|
||||
"@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="],
|
||||
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.6.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-XxXrmX7aZkDgOB0Rg9cu+ZFyiUUc5lF2n9seO3Gc4OR+MTdfZOwIqF6m3LvsmmM8K3qgPmXkDH8/IFM2u9vdcQ=="],
|
||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.2.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA=="],
|
||||
|
||||
"@ai-sdk/cohere": ["@ai-sdk/cohere@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OqcCq2PiFY1dbK/0Ck45KuvE8jfdxRuuAE9Y5w46dAk6U+9vPOeg1CDcmR+ncqmrYrhRl3nmyDttyDahyjCzAw=="],
|
||||
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-g3k0cAFGqzmRYlcIkg1NDvlx1WxHYhnYPL0/a8E+qTg=",
|
||||
"aarch64-linux": "sha256-a+3ymqdxOONGe2Tpq4GUccl1b+Dwzxlb9LFXgE1gZ+0=",
|
||||
"aarch64-darwin": "sha256-h8xIzuMmaWfJqjHCO74xUDCWNKQLFrIGoKYZ+2TauYc="
|
||||
"x86_64-linux": "sha256-yCdtDQsXERjfL9bJg7YXNloMBOPwFkPMFVNAzXlarBM=",
|
||||
"aarch64-linux": "sha256-iQ1bLIszETcFoD4CENpoaDZJZF/KBKZFeTp9r7ogJ9Y=",
|
||||
"aarch64-darwin": "sha256-G7oIrTXEFQ5iEF8KSpA4xJPw9xwqRDpbIR472iIoqC4="
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,6 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
// 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([
|
||||
"alibaba-chat",
|
||||
"alibaba-messages",
|
||||
"anthropic-messages",
|
||||
"anthropic-compatible-messages",
|
||||
@@ -50,18 +49,16 @@ const RESPECTS_INLINE_HINTS = new Set([
|
||||
"zai-coding-messages",
|
||||
"bedrock-converse",
|
||||
"openrouter",
|
||||
"digitalocean",
|
||||
])
|
||||
|
||||
// OpenRouter upstreams other than Anthropic and Alibaba Qwen cache without breakpoints. Gemini uses only the last
|
||||
// breakpoint, so a conversation-tail breakpoint writes a new cache every step and costs more than none. Qwen ignores
|
||||
// breakpoints on tool definitions and caches tools with the system prompt.
|
||||
const QWEN: CachePolicyObject = { system: true, messages: { tail: 1 } }
|
||||
const openRouterPolicy = (modelID: string): CachePolicyObject => {
|
||||
// `~anthropic/claude-sonnet-latest` style IDs are OpenRouter aliases for the latest model in a family.
|
||||
const id = modelID.replace(/^~/, "")
|
||||
if (id.startsWith("anthropic/")) return AUTO
|
||||
if (id.startsWith("qwen/")) return QWEN
|
||||
if (id.startsWith("qwen/")) return { system: true, messages: { tail: 1 } }
|
||||
return NONE
|
||||
}
|
||||
|
||||
@@ -173,11 +170,7 @@ export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||
const policy =
|
||||
request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")
|
||||
? openRouterPolicy(request.model.id)
|
||||
: request.model.route.id === "alibaba-chat" && (request.cache === undefined || request.cache === "auto")
|
||||
? request.model.id.toLowerCase().startsWith("qwen")
|
||||
? QWEN
|
||||
: NONE
|
||||
: resolve(request.cache)
|
||||
: resolve(request.cache)
|
||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||
|
||||
const hint = makeHint(policy.ttlSeconds)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Protocol } from "../route/protocol.js"
|
||||
import type { LanguageModelCompatibility } from "../schema/index.js"
|
||||
import { OpenAIChat } from "./openai-chat.js"
|
||||
import { JsonObject, ProviderShared } from "./shared.js"
|
||||
import { cacheControl } from "./utils/cache.js"
|
||||
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
|
||||
export type ReasoningEffort = OpenResponsesOptions.ReasoningEffort
|
||||
@@ -69,7 +68,7 @@ export const protocol = Protocol.make({
|
||||
from: Effect.fn("AlibabaChat.fromRequest")(function* (req) {
|
||||
const opts = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(req.providerOptions ?? {})
|
||||
return {
|
||||
...(yield* OpenAIChat.fromRequest(req, { cacheControl: cacheControl() })),
|
||||
...(yield* OpenAIChat.protocol.body.from(req)),
|
||||
enable_thinking: opts.enableThinking,
|
||||
// Alibaba also rejects an explicit budget that is not below `max_completion_tokens`.
|
||||
thinking_budget:
|
||||
|
||||
@@ -451,9 +451,6 @@ const AnthropicStreamDelta = Schema.Struct({
|
||||
signature: Schema.optional(Schema.String),
|
||||
stop_reason: optionalNull(Schema.String),
|
||||
stop_sequence: optionalNull(Schema.String),
|
||||
stop_details: optionalNull(
|
||||
Schema.Struct({ category: optionalNull(Schema.String), explanation: optionalNull(Schema.String) }),
|
||||
),
|
||||
})
|
||||
type AnthropicStreamDelta = Schema.Schema.Type<typeof AnthropicStreamDelta>
|
||||
const decodeAnthropicStreamDelta = Schema.decodeUnknownOption(AnthropicStreamDelta)
|
||||
@@ -807,12 +804,15 @@ const requireThinkingSignature = (request: LLMRequest) => {
|
||||
// Mid-conversation system messages became available with Opus 4.8 and version
|
||||
// 5 of the other supported Claude families. Treat later family versions as
|
||||
// compatible without assuming that every Anthropic Messages model is Claude.
|
||||
// Opus 4.8 and every Claude 5 model accept mid-conversation system messages; later versions inherit support.
|
||||
const supportsNativeSystemUpdates = (request: LLMRequest) => {
|
||||
const version = claudeVersion(String(request.model.id))
|
||||
if (version === undefined) return false
|
||||
if (version.family === "opus" && version.major === 4) return version.minor >= 8
|
||||
return version.major >= 5
|
||||
const match = /(?:^|[./])claude-(fable|haiku|mythos|opus|sonnet)-(\d+)(?:[.-](\d+))?/.exec(
|
||||
String(request.model.id).toLowerCase(),
|
||||
)
|
||||
if (!match) return false
|
||||
const major = Number(match[2])
|
||||
if (match[1] !== "opus") return major >= 5
|
||||
if (major !== 4) return major >= 5
|
||||
return match[3] !== undefined && match[3].length <= 2 && Number(match[3]) >= 8
|
||||
}
|
||||
|
||||
const endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
|
||||
@@ -989,13 +989,13 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
return messages
|
||||
})
|
||||
|
||||
// Per-turn effort started with Claude Opus 5 and every Claude 5.1 model; later versions of any family inherit it.
|
||||
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
|
||||
const override = model.compatibility?.supportsEffortUpdates
|
||||
if (override !== undefined) return override
|
||||
const version = claudeVersion(model.id)
|
||||
if (version === undefined) return false
|
||||
if (version.family === "opus" && version.major >= 5) return true
|
||||
if (version.family === "opus") return version.major >= 5
|
||||
if (version.family !== "fable" && version.family !== "mythos") return false
|
||||
return version.major > 5 || (version.major === 5 && version.minor >= 1)
|
||||
}
|
||||
|
||||
@@ -1417,14 +1417,10 @@ const onMessageDelta = (
|
||||
stopSequence === null || stopSequence === undefined
|
||||
? state.pendingFinish?.providerMetadata
|
||||
: providerMetadata(state.providerMetadataKey, { stopSequence })
|
||||
const category = event.delta?.stop_details?.category
|
||||
const explanation = event.delta?.stop_details?.explanation
|
||||
return {
|
||||
reason: {
|
||||
normalized: mapFinishReason(stopReason),
|
||||
raw: stopReason,
|
||||
...(category ? { category } : {}),
|
||||
...(explanation ? { explanation } : {}),
|
||||
},
|
||||
providerMetadata: finishMetadata,
|
||||
}
|
||||
|
||||
@@ -319,9 +319,6 @@ const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (
|
||||
} satisfies BedrockToolResultBlock
|
||||
})
|
||||
|
||||
// Keep Claude and Nova tool-result images inline; put other models' images beside the result.
|
||||
const keepToolImagesInline = (id: string) => id.includes("anthropic.claude-") || id.includes("amazon.nova-")
|
||||
|
||||
const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
request: LLMRequest,
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
@@ -331,19 +328,8 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
// Mistral can reject replay IDs even when they satisfy Converse's broader ID syntax.
|
||||
const normalizeID = request.model.id.includes("mistral.") ? MistralToolID.normalizer(request) : (id: string) => id
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
const hoistImages = !keepToolImagesInline(request.model.id)
|
||||
// Bedrock expects parallel tool results before any images hoisted beside them.
|
||||
const pendingImages: BedrockMedia.ImageBlock[] = []
|
||||
const flushImages = () => {
|
||||
if (pendingImages.length === 0) return
|
||||
const previous = messages.at(-1)
|
||||
if (previous?.role === "user")
|
||||
messages[messages.length - 1] = { role: "user", content: [...previous.content, ...pendingImages] }
|
||||
pendingImages.length = 0
|
||||
}
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role !== "tool") flushImages()
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate("Bedrock Converse", message)
|
||||
const content = textWithCache(breakpoints, part.text, part.cache)
|
||||
@@ -417,22 +403,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
|
||||
const result = yield* lowerToolResult(part, documentNames, normalizeID)
|
||||
const images: BedrockMedia.ImageBlock[] = hoistImages
|
||||
? result.toolResult.content.filter((item) => "image" in item)
|
||||
: []
|
||||
const nonImageContent = result.toolResult.content.filter((item) => !("image" in item))
|
||||
content.push(
|
||||
images.length === 0
|
||||
? result
|
||||
: {
|
||||
toolResult: {
|
||||
...result.toolResult,
|
||||
content: nonImageContent.length > 0 ? nonImageContent : [{ text: "See attached image." }],
|
||||
},
|
||||
},
|
||||
)
|
||||
pendingImages.push(...images)
|
||||
content.push(yield* lowerToolResult(part, documentNames, normalizeID))
|
||||
const cachePoint = BedrockCache.block(breakpoints, part.cache)
|
||||
if (cachePoint) content.push(cachePoint)
|
||||
}
|
||||
@@ -442,7 +413,6 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
else messages.push({ role: "user", content })
|
||||
}
|
||||
|
||||
flushImages()
|
||||
return messages
|
||||
})
|
||||
|
||||
|
||||
@@ -212,11 +212,9 @@ const OpenAIChatUsage = Schema.StructWithRest(
|
||||
prompt_tokens: optionalNull(Schema.Number),
|
||||
completion_tokens: optionalNull(Schema.Number),
|
||||
total_tokens: optionalNull(Schema.Number),
|
||||
// Provider-specific cache accounting fields.
|
||||
// Zai reports cache hits as top-level `cached_tokens`; DeepSeek uses `prompt_cache_hit_tokens`.
|
||||
cached_tokens: optionalNull(Schema.Number),
|
||||
prompt_cache_hit_tokens: optionalNull(Schema.Number),
|
||||
cache_read_input_tokens: optionalNull(Schema.Number),
|
||||
cache_created_input_tokens: optionalNull(Schema.Number),
|
||||
prompt_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
@@ -905,19 +903,16 @@ const mapFinishReason = Effect.fn("OpenAIChat.mapFinishReason")(function* (event
|
||||
// satisfied on both sides.
|
||||
// Providers differ on cache-hit location: OpenAI uses
|
||||
// `prompt_tokens_details.cached_tokens`, DeepSeek uses
|
||||
// `prompt_cache_hit_tokens`, Zai uses top-level `cached_tokens`, and
|
||||
// DigitalOcean uses top-level `cache_read_input_tokens` / `cache_created_input_tokens`.
|
||||
// `prompt_cache_hit_tokens`, and Zai uses top-level `cached_tokens`.
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"], providerMetadataKey: string): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const input = usage.prompt_tokens ?? undefined
|
||||
const output = usage.completion_tokens ?? undefined
|
||||
const cached =
|
||||
usage.prompt_tokens_details?.cached_tokens ??
|
||||
usage.prompt_cache_hit_tokens ??
|
||||
usage.cached_tokens ??
|
||||
usage.cache_read_input_tokens ??
|
||||
undefined
|
||||
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? usage.cache_created_input_tokens ?? undefined
|
||||
const cached = (usage.prompt_tokens_details?.cached_tokens ??
|
||||
(usage as { prompt_cache_hit_tokens?: number | null }).prompt_cache_hit_tokens ??
|
||||
(usage as { cached_tokens?: number | null }).cached_tokens ??
|
||||
undefined) as number | undefined
|
||||
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens ?? undefined
|
||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
|
||||
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
|
||||
return new Usage({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Shared counter and TTL mapping for provider cache-marker lowering.
|
||||
import type { CacheHint } from "../../schema/index.js"
|
||||
|
||||
export interface Breakpoints {
|
||||
remaining: number
|
||||
@@ -12,12 +11,3 @@ export const newBreakpoints = (cap: number): Breakpoints => ({ remaining: cap, d
|
||||
// requests omit the wire TTL and use the provider default.
|
||||
export const ttlBucket = (ttlSeconds: number | undefined): "1h" | undefined =>
|
||||
ttlSeconds !== undefined && ttlSeconds >= 3600 ? "1h" : undefined
|
||||
|
||||
export const cacheControl = () => {
|
||||
const breakpoints = newBreakpoints(4)
|
||||
return (cache: CacheHint | undefined) => {
|
||||
if (cache === undefined || breakpoints.remaining === 0) return undefined
|
||||
breakpoints.remaining -= 1
|
||||
return { type: "ephemeral" as const, ttl: ttlBucket(cache.ttlSeconds) }
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,6 @@ const patterns = [
|
||||
/exceeds the limit of \d+/i,
|
||||
/exceeds the available context size/i,
|
||||
/greater than the context length/i,
|
||||
// Hugging Face Text Generation Inference, e.g. Together
|
||||
/`inputs` tokens \+ `max_new_tokens` must be <= \d+/i,
|
||||
/context window exceeds limit/i,
|
||||
/exceeded model token limit/i,
|
||||
/context[_ ]length[_ ]exceeded/i,
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { cacheControl } from "../protocols/utils/cache.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("digitalocean")
|
||||
const baseURL = "https://inference.do-ai.run/v1"
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings & OpenAIProviderOptionsInput & { readonly apiKey?: string }
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: "digitalocean-chat",
|
||||
body: {
|
||||
schema: OpenAIChat.protocol.body.schema,
|
||||
from: (request) => OpenAIChat.fromRequest(request, { cacheControl: cacheControl() }),
|
||||
},
|
||||
stream: OpenAIChat.protocol.stream,
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
id: "digitalocean",
|
||||
provider: id,
|
||||
providerMetadataKey: "digitalocean",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL: endpoint, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: endpoint ?? baseURL },
|
||||
auth: AuthOptions.bearer(input, ["DIGITALOCEAN_ACCESS_TOKEN", "DIGITALOCEAN_API_KEY", "DO_INFERENCE_API_KEY"]),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
configured.model<OpenAIProviderOptionsInput>({
|
||||
id: modelID,
|
||||
compatibility: {
|
||||
supportsPromptCacheKey: true,
|
||||
},
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: { body },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as DigitalOcean from "./digitalocean.js"
|
||||
@@ -37,7 +37,7 @@ export type Settings = ProviderPackage.Settings &
|
||||
const route = Route.make({
|
||||
id: "google-vertex-messages",
|
||||
provider: id,
|
||||
providerMetadataKey: "vertex",
|
||||
providerMetadataKey: "anthropic",
|
||||
protocol: Protocol.make({
|
||||
id: AnthropicMessages.protocol.id,
|
||||
body: {
|
||||
|
||||
@@ -71,7 +71,7 @@ export const protocol = Protocol.make({
|
||||
export const route = Route.make({
|
||||
id: "groq-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "groq",
|
||||
providerMetadataKey: "openai",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL }),
|
||||
framing: OpenAIChat.framing,
|
||||
|
||||
@@ -14,7 +14,6 @@ export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
|
||||
export * as DeepInfra from "./deepinfra.js"
|
||||
export * as Deepgram from "./deepgram.js"
|
||||
export * as DeepSeek from "./deepseek.js"
|
||||
export * as DigitalOcean from "./digitalocean.js"
|
||||
export * as ElevenLabs from "./elevenlabs.js"
|
||||
export * as Fal from "./fal.js"
|
||||
export * as Fireworks from "./fireworks.js"
|
||||
|
||||
@@ -3,11 +3,11 @@ import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { HttpOptions, ProviderID, type ModelID, type OpenString } from "../schema/index.js"
|
||||
import { HttpOptions, ProviderID, type CacheHint, type ModelID, type OpenString } from "../schema/index.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { SystemOne } from "../experimental/system-one.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { cacheControl } from "../protocols/utils/cache.js"
|
||||
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
|
||||
import { isRecord, ProviderShared } from "../protocols/shared.js"
|
||||
|
||||
export const id = ProviderID.make("openrouter")
|
||||
@@ -131,6 +131,18 @@ export const protocol = Protocol.make({
|
||||
stream: OpenAIChat.protocol.stream,
|
||||
})
|
||||
|
||||
const cacheControl = () => {
|
||||
const breakpoints = newBreakpoints(4)
|
||||
return (cache: CacheHint | undefined) => {
|
||||
if (cache === undefined || breakpoints.remaining === 0) return undefined
|
||||
breakpoints.remaining -= 1
|
||||
return {
|
||||
type: "ephemeral" as const,
|
||||
...(ttlBucket(cache.ttlSeconds) === "1h" ? { ttl: "1h" } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OpenRouter forwards `reasoning.max_tokens` as the upstream thinking budget. Upstreams such as Anthropic and Alibaba
|
||||
// reject one that is not below the output limit; 1,024 is Anthropic's minimum budget.
|
||||
const fitReasoning = (reasoning: Record<string, unknown>, maxTokens: number | undefined) =>
|
||||
|
||||
@@ -159,15 +159,6 @@ const nativeTransportFailure = (error: unknown) => {
|
||||
return failure
|
||||
}
|
||||
|
||||
// HTTP hooks re-wrap response bodies, so a read failure can arrive as an HttpClientError caused by
|
||||
// another HttpClientError. The innermost cause carries the native failure.
|
||||
const rootCause = (error: unknown): unknown =>
|
||||
HttpClientError.isHttpClientError(error) && "cause" in error.reason && error.reason.cause !== undefined
|
||||
? rootCause(error.reason.cause)
|
||||
: error
|
||||
|
||||
const CONNECTION_LOST = "Connection lost while reading the response"
|
||||
|
||||
const httpError = (input: {
|
||||
readonly error: unknown
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
@@ -188,21 +179,20 @@ const httpError = (input: {
|
||||
}),
|
||||
})
|
||||
|
||||
const source = rootCause(input.error)
|
||||
const source =
|
||||
HttpClientError.isHttpClientError(input.error) && "cause" in input.error.reason
|
||||
? (input.error.reason.cause ?? input.error)
|
||||
: input.error
|
||||
const native = nativeTransportFailure(source)
|
||||
const code = native?.code
|
||||
const detail =
|
||||
code && native?.message && !native.message.includes(code) ? `${code}: ${native.message}` : native?.message
|
||||
const message = detail ?? (input.error instanceof Error ? input.error.message : undefined)
|
||||
const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined)
|
||||
const detail = raw
|
||||
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
|
||||
|
||||
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
|
||||
return transportError({ message: message ?? "HTTP transport timed out", code: code ?? "Timeout" })
|
||||
if (!HttpClientError.isHttpClientError(input.error))
|
||||
return transportError({ message: message ?? "HTTP transport failed", code })
|
||||
// Effect reports every response body read failure as a DecodeError, but the raw byte stream decodes
|
||||
// nothing: provider output parsing happens later and fails as InvalidProviderOutput.
|
||||
if (input.operation === "read" && input.error.reason._tag === "DecodeError")
|
||||
return transportError({ message: detail ? `${CONNECTION_LOST}: ${detail}` : CONNECTION_LOST, code })
|
||||
if (input.error.reason._tag === "TransportError") {
|
||||
return transportError({
|
||||
message: message ?? input.error.reason.description ?? "HTTP transport failed",
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
import { Clock, Duration, Effect, Stream } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Auth } from "../auth.js"
|
||||
import { render as renderEndpoint } from "../endpoint.js"
|
||||
import { Framing } from "../framing.js"
|
||||
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.js"
|
||||
import * as ProviderShared from "../../protocols/shared.js"
|
||||
import {
|
||||
AIError,
|
||||
DEFAULT_HTTP_TIMEOUT_MS,
|
||||
mergeJsonRecords,
|
||||
TransportError,
|
||||
type HttpContext,
|
||||
type HttpTimeout,
|
||||
type LLMRequest,
|
||||
} from "../../schema/index.js"
|
||||
import { mergeJsonRecords, type LLMRequest } from "../../schema/index.js"
|
||||
import { RequestExecutor } from "../executor.js"
|
||||
|
||||
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
|
||||
@@ -95,53 +87,17 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
middleware: prepareInput.middleware,
|
||||
}
|
||||
}),
|
||||
execute: (prepared, request, runtime) =>
|
||||
execute: (prepared, _request, runtime) =>
|
||||
Effect.gen(function* () {
|
||||
const timeout = (operation: "request" | "read", message: string, http?: HttpContext) =>
|
||||
new AIError({
|
||||
reason: new TransportError({
|
||||
message,
|
||||
transport: "http",
|
||||
operation,
|
||||
code: "Timeout",
|
||||
url: prepared.request.url,
|
||||
http,
|
||||
}),
|
||||
})
|
||||
const started = yield* Clock.currentTimeMillis
|
||||
// Unlike the header and chunk limits, the whole-request budget has no default.
|
||||
const total = request.http?.timeout ? Duration.millis(request.http.timeout) : Duration.infinity
|
||||
const response = yield* runtime.http.execute(prepared.request, prepared.middleware).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.min(timeoutDuration(request.http?.headerTimeout), total),
|
||||
orElse: () => timeout("request", "Timed out waiting for response headers"),
|
||||
}),
|
||||
)
|
||||
const http = RequestExecutor.responseHttp(response)
|
||||
const remaining = Duration.subtract(total, Duration.millis((yield* Clock.currentTimeMillis) - started))
|
||||
const response = yield* runtime.http.execute(prepared.request, prepared.middleware)
|
||||
return {
|
||||
frames: prepared.framing.frame(
|
||||
RequestExecutor.responseStream(response).pipe(
|
||||
Stream.timeoutOrElse({
|
||||
duration: timeoutDuration(request.http?.chunkTimeout),
|
||||
orElse: () => Stream.fail(timeout("read", "Timed out waiting for response data", http)),
|
||||
}),
|
||||
Stream.interruptWhen(
|
||||
Effect.sleep(remaining).pipe(
|
||||
Effect.andThen(Effect.fail(timeout("read", "Timed out waiting for the response to complete", http))),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
http,
|
||||
frames: prepared.framing.frame(RequestExecutor.responseStream(response)),
|
||||
http: RequestExecutor.responseHttp(response),
|
||||
body: prepared.framing.body,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
const timeoutDuration = (value: HttpTimeout | undefined) =>
|
||||
value === false ? Duration.infinity : Duration.millis(value ?? DEFAULT_HTTP_TIMEOUT_MS)
|
||||
|
||||
export const sseJson = {
|
||||
id: "http-json/sse",
|
||||
with: <Body>() => httpJson<Body, string>({ framing: Framing.sse }),
|
||||
|
||||
@@ -264,10 +264,6 @@ export type ToolError = Schema.Schema.Type<typeof ToolError>
|
||||
export const FinishReasonDetails = Schema.Struct({
|
||||
normalized: FinishReason,
|
||||
raw: Schema.optional(Schema.String),
|
||||
/** The provider's policy area for a content-filter finish, such as `cyber`. */
|
||||
category: Schema.optional(Schema.String),
|
||||
/** The provider's human-readable reason for a content-filter finish. */
|
||||
explanation: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "LLM.FinishReasonDetails" })
|
||||
export type FinishReasonDetails = Schema.Schema.Type<typeof FinishReasonDetails>
|
||||
|
||||
|
||||
@@ -48,23 +48,10 @@ export const mergeProviderOptions = (
|
||||
...items: ReadonlyArray<ProviderOptions | undefined>
|
||||
): ProviderOptions | undefined => mergeJsonRecords(...items)
|
||||
|
||||
/** Milliseconds for an HTTP timeout, or `false` to disable it. */
|
||||
export const HttpTimeout = Schema.Union([Schema.Number.check(Schema.isGreaterThan(0)), Schema.Literal(false)])
|
||||
export type HttpTimeout = Schema.Schema.Type<typeof HttpTimeout>
|
||||
|
||||
/** Applied to `headerTimeout` and `chunkTimeout` when a request leaves them unset. */
|
||||
export const DEFAULT_HTTP_TIMEOUT_MS = 300_000
|
||||
|
||||
export class HttpOptions extends Schema.Class<HttpOptions>("AI.HttpOptions")({
|
||||
body: Schema.optional(JsonSchema),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
query: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
/** Time allowed for the whole request, from send until the response completes. Unbounded when unset. */
|
||||
timeout: Schema.optional(HttpTimeout),
|
||||
/** Time allowed for response headers to arrive. */
|
||||
headerTimeout: Schema.optional(HttpTimeout),
|
||||
/** Time allowed between streamed response chunks once headers have arrived. */
|
||||
chunkTimeout: Schema.optional(HttpTimeout),
|
||||
}) {}
|
||||
|
||||
export namespace HttpOptions {
|
||||
@@ -83,12 +70,8 @@ export const mergeHttpOptions = (...items: ReadonlyArray<HttpOptions | undefined
|
||||
const body = mergeJsonRecords(...items.map((item) => item?.body))
|
||||
const headers = mergeStringRecords(...items.map((item) => item?.headers))
|
||||
const query = mergeStringRecords(...items.map((item) => item?.query))
|
||||
const timeout = items.findLast((item) => item?.timeout !== undefined)?.timeout
|
||||
const headerTimeout = items.findLast((item) => item?.headerTimeout !== undefined)?.headerTimeout
|
||||
const chunkTimeout = items.findLast((item) => item?.chunkTimeout !== undefined)?.chunkTimeout
|
||||
if (!body && !headers && !query && timeout === undefined && headerTimeout === undefined && chunkTimeout === undefined)
|
||||
return undefined
|
||||
return new HttpOptions({ body, headers, query, timeout, headerTimeout, chunkTimeout })
|
||||
if (!body && !headers && !query) return undefined
|
||||
return new HttpOptions({ body, headers, query })
|
||||
}
|
||||
|
||||
export class GenerationOptions extends Schema.Class<GenerationOptions>("LLM.GenerationOptions")({
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
AmazonBedrock,
|
||||
AnthropicCompatible,
|
||||
CloudflareAIGateway,
|
||||
DigitalOcean,
|
||||
GoogleVertexMessages,
|
||||
Meta,
|
||||
MiniMax,
|
||||
@@ -118,86 +117,6 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' emits cache_control markers on DigitalOcean", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: DigitalOcean.configure({ apiKey: "test" }).model("anthropic-claude-fable-5.1"),
|
||||
system: "You are concise.",
|
||||
tools: [{ name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ type: "function", function: { name: "lookup" }, cache_control: { type: "ephemeral" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [{ text: "You are concise.", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ text: "hi", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Alibaba chat caches the system and conversation tail without marking tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Alibaba.configure({ region: "ap-southeast-1", apiKey: "test" }).chat("qwen3.8-max"),
|
||||
system: "You are concise.",
|
||||
tools: [{ name: "lookup", description: "Look up a value", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ type: "function", function: { name: "lookup" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [{ text: "You are concise.", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{ role: "user", content: [{ text: "hi", cache_control: { type: "ephemeral" } }] },
|
||||
],
|
||||
})
|
||||
expect(prepared.body.tools?.[0]?.cache_control).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Alibaba chat omits automatic cache markers when cache is none", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Alibaba.configure({ region: "ap-southeast-1", apiKey: "test" }).chat("qwen3.8-max"),
|
||||
system: "You are concise.",
|
||||
prompt: "hi",
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(JSON.stringify(prepared.body)).not.toContain("cache_control")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Alibaba chat does not assume non-Qwen models support cache markers", () =>
|
||||
Effect.gen(function* () {
|
||||
const alibaba = Alibaba.configure({ region: "ap-southeast-1", apiKey: "test" })
|
||||
for (const modelID of ["kimi-k3", "glm-5.2", "deepseek-v4-flash-0731", "MiniMax-M2.5"]) {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: alibaba.chat(modelID), system: "You are concise.", prompt: "hi" }),
|
||||
)
|
||||
|
||||
expect(JSON.stringify(prepared.body)).not.toContain("cache_control")
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' emits Anthropic cache markers on Anthropic-compatible routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -197,15 +197,9 @@ describe("Anthropic Messages effort updates", () => {
|
||||
["anthropic/claude-opus-5", true],
|
||||
["claude-fable-5-1", true],
|
||||
["claude-mythos-5-1", true],
|
||||
["claude-opus-5-5", true],
|
||||
["claude-sonnet-5-5", true],
|
||||
["anthropic/claude-sonnet-5-5", true],
|
||||
["claude-sonnet-6", true],
|
||||
["claude-haiku-6", true],
|
||||
["claude-fable-5", false],
|
||||
["claude-opus-4-8", false],
|
||||
["claude-sonnet-5", false],
|
||||
["claude-sonnet-5-20260801", false],
|
||||
["kimi-k2.5", false],
|
||||
] as const) {
|
||||
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
|
||||
import { Headers, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError, isRetryable } from "../src/index.js"
|
||||
import {
|
||||
LLMClient,
|
||||
RequestExecutor,
|
||||
WebSocketTransport,
|
||||
type HttpMiddleware,
|
||||
type WebSocketChannelExecutor,
|
||||
} from "../src/route.js"
|
||||
import { Headers, HttpClientError, HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
|
||||
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
|
||||
import { route } from "../src/protocols/openai-chat.js"
|
||||
import { configure } from "../src/providers/openai.js"
|
||||
import { dynamicResponse, fixedResponse, handlerLayer, systemError, truncatedStream } from "./lib/http.js"
|
||||
import { dynamicResponse, fixedResponse, handlerLayer, systemError } from "./lib/http.js"
|
||||
import { deltaChunk } from "./lib/openai-chunks.js"
|
||||
import { sseEvents, sseRaw } from "./lib/sse.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
@@ -24,8 +18,6 @@ const secretRequest = HttpClientRequest.post("https://provider.test/v1/chat?api_
|
||||
HttpClientRequest.setHeaders(Headers.fromInput({ authorization: "Bearer header-secret-456" })),
|
||||
)
|
||||
|
||||
const sseRequest = HttpClientRequest.post("https://provider.test/v1/messages")
|
||||
|
||||
const expectAIError = (error: unknown) => {
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
if (!(error instanceof AIError)) throw new Error("expected AIError")
|
||||
@@ -101,9 +93,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.message).toBe(
|
||||
"Connection lost while reading the response: ECONNRESET: disconnected query-secret-123 header-secret-456",
|
||||
)
|
||||
expect(error.message).toBe("ECONNRESET: disconnected query-secret-123 header-secret-456")
|
||||
expect(error.reason.http).toMatchObject({ status: 200, url: secretRequest.url })
|
||||
expect(error.reason.cause).toMatchObject({ code: "ECONNRESET" })
|
||||
expect(error.reason).toMatchObject({
|
||||
@@ -133,7 +123,7 @@ describe("RequestExecutor", () => {
|
||||
const error = yield* RequestExecutor.stream(executor, secretRequest).pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.message).toBe("Connection lost while reading the response: ECONNRESET: socket closed")
|
||||
expect(error.message).toBe("ECONNRESET: socket closed")
|
||||
expect(error.reason.cause).toBeInstanceOf(TypeError)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
@@ -154,54 +144,6 @@ describe("RequestExecutor", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("reports a connection lost mid-stream through middleware that re-wraps the body", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const chunks: Array<Uint8Array> = []
|
||||
// Session HTTP hooks hand plugins a web Response, so the body is re-wrapped around the original stream.
|
||||
const rewrap: HttpMiddleware = (input, handler) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* handler(input)
|
||||
const body = yield* Stream.toReadableStreamEffect(response.stream)
|
||||
return HttpClientResponse.fromWeb(
|
||||
input,
|
||||
new Response(body, { status: response.status, headers: response.headers }),
|
||||
)
|
||||
})
|
||||
const error = yield* RequestExecutor.stream(executor, sseRequest, rewrap).pipe(
|
||||
Stream.runForEach((chunk) => Effect.sync(() => chunks.push(chunk))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expectAIError(error)
|
||||
expect(new TextDecoder().decode(chunks[0])).toBe('data: {"type":"ping"}\n\n')
|
||||
expect(error.message).toBe("Connection lost while reading the response: ECONNRESET: other side closed")
|
||||
expect(error.reason.cause).toBeInstanceOf(TypeError)
|
||||
expect(error.reason.http).toMatchObject({ status: 200 })
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", operation: "read", code: "ECONNRESET" })
|
||||
expect(isRetryable(error)).toBeTrue()
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
truncatedStream(
|
||||
['data: {"type":"ping"}\n\n'],
|
||||
new TypeError("terminated", { cause: systemError("ECONNRESET", "other side closed") }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("does not report a body read failure without a native cause as a decode error", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* RequestExecutor.stream(executor, sseRequest).pipe(Stream.runDrain, Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.message).toBe("Connection lost while reading the response")
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", operation: "read", code: undefined })
|
||||
expect(isRetryable(error)).toBeTrue()
|
||||
}).pipe(Effect.provide(truncatedStream(['data: {"type":"ping"}\n\n'], new DOMException("aborted", "AbortError")))),
|
||||
)
|
||||
|
||||
it.effect("preserves middleware error messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
|
||||
Vendored
+3
-3
@@ -10,7 +10,7 @@
|
||||
"usage"
|
||||
],
|
||||
"name": "alibaba-chat/qwen-3-7-plus-streams-thinking-disabled",
|
||||
"recordedAt": "2026-10-02T01:23:37.836Z"
|
||||
"recordedAt": "2026-09-08T03:10:42.782Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -21,14 +21,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.7-plus\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"enable_thinking\":false}"
|
||||
"body": "{\"model\":\"qwen3.7-plus\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"enable_thinking\":false}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-e96a3356-bec9-98ad-bdbe-af00a15ee965\",\"created\":1790904216,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-e96a3356-bec9-98ad-bdbe-af00a15ee965\",\"choices\":[{\"delta\":{\"content\":\"3\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904216,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-e96a3356-bec9-98ad-bdbe-af00a15ee965\",\"choices\":[{\"delta\":{\"content\":\"7887\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904216,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-e96a3356-bec9-98ad-bdbe-af00a15ee965\",\"choices\":[{\"delta\":{\"content\":\"\"},\"index\":0,\"finish_reason\":\"stop\",\"logprobs\":null}],\"created\":1790904216,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1790904216,\"id\":\"chatcmpl-e96a3356-bec9-98ad-bdbe-af00a15ee965\",\"model\":\"qwen3.7-plus\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":32,\"prompt_tokens_details\":{\"cache_creation\":{\"ephemeral_5m_input_tokens\":0},\"cache_creation_input_tokens\":0,\"cache_type\":\"ephemeral\",\"cache_write_tokens\":0,\"cached_tokens\":0,\"text_tokens\":32},\"total_tokens\":37}}\n\ndata: [DONE]\n\n"
|
||||
"body": "data: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-047bcb67-b193-9a4f-9d77-ea0325be6d7c\",\"created\":1788837041,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-047bcb67-b193-9a4f-9d77-ea0325be6d7c\",\"choices\":[{\"delta\":{\"content\":\"3\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837041,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-047bcb67-b193-9a4f-9d77-ea0325be6d7c\",\"choices\":[{\"delta\":{\"content\":\"7887\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837041,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-047bcb67-b193-9a4f-9d77-ea0325be6d7c\",\"choices\":[{\"delta\":{\"content\":\"\"},\"index\":0,\"finish_reason\":\"stop\",\"logprobs\":null}],\"created\":1788837041,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1788837041,\"id\":\"chatcmpl-047bcb67-b193-9a4f-9d77-ea0325be6d7c\",\"model\":\"qwen3.7-plus\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":32,\"prompt_tokens_details\":{\"cached_tokens\":0,\"text_tokens\":32},\"total_tokens\":37}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Vendored
+3
-3
@@ -10,7 +10,7 @@
|
||||
"usage"
|
||||
],
|
||||
"name": "alibaba-chat/qwen-3-7-plus-streams-thinking-enabled",
|
||||
"recordedAt": "2026-10-02T01:23:43.998Z"
|
||||
"recordedAt": "2026-09-08T03:10:54.099Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -21,14 +21,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.7-plus\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"enable_thinking\":true,\"thinking_budget\":1024}"
|
||||
"body": "{\"model\":\"qwen3.7-plus\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096,\"enable_thinking\":true,\"thinking_budget\":1024}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"Thinking\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" Process:\\n\\n1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\". Identify\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" the core\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" question: Calculate \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"173 *\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 219\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\".\\n2.\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" Constraint: Reply\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" with *\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"only* the final\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" integer.\\n3\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\". Perform the\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" multiplication:\\n\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fe-9ee8-a442-7a3881697fdf\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" * \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904218,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-70ed9ebc-62fLine truncated
|
||||
"body": "data: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"Thinking\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" Process:\\n\\n1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\". **Ident\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"ify the core question\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\":** The user wants\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" to know the product\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" of \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"173 and\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 219\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\".\\n2.\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" **Identify\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" the constraint:** The\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" response must contain *\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"only\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"chatcmpl-ec928416-c99b-9f46-9805-cbb37bd7c499\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"* the final integer\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837042,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.7-plus\",\"id\":\"cLine truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+4
-10
@@ -1,15 +1,9 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": [
|
||||
"prefix:alibaba-chat",
|
||||
"provider:alibaba",
|
||||
"protocol:alibaba-chat",
|
||||
"region:ap-southeast-1",
|
||||
"image"
|
||||
],
|
||||
"tags": ["prefix:alibaba-chat", "provider:alibaba", "protocol:alibaba-chat", "region:ap-southeast-1", "image"],
|
||||
"name": "alibaba-chat/qwen-3-8-flash-reads-image-bytes",
|
||||
"recordedAt": "2026-10-02T01:23:45.179Z"
|
||||
"recordedAt": "2026-09-08T03:10:55.585Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -20,14 +14,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-flash\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Read the three words in this image. Reply with only the words in order.\",\"cache_control\":{\"type\":\"ephemeral\"}},{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnYAAACKCAYAAAAnmweyAAACKWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjYzMCIKICAgZXhpZjpVc2VyQ29tbWVudD0iU2NyZWVuc2hvdCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjEzOCIKICAgdGlmZjpZUmVzb2x1dGlvbj0iMTQ0LzEiCiAgIHRpZmY6WFJlc29sdXRpb249IjE0NC8xIgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+at0SpgAACrhpQ0NQSUNDIFByb2ZpbGUAAEiJlZcHUFNZF8fvey+dhJYQASmh994CSAmhBVCQDjZCEiAQQkxBwa4sruBaUBHBsqKrIgo2qg0RxbYo9r4gi4iyLhZsqHwPGMLufvN933xn5s75zXnn/u+5d959cx4AFFOuRCKC1QHIFsul0SEBjMSkZAb+JcACTUACnoDK5ckkrKioCIDahP+7fbgLoFF/y25U69+f/1fT4AtkPACgKJRT+TJeNsonAIABTyKVA4CgDEwWyCWjfB9lmhQtEOWBUU4fY8yoDi11nGljObHRbJQtASCQuVxpOgBkVzTOyOWlozrkWJQdxXyhGOUClH2zs3P4KLehbInmSFAe1Wem/kUn/W+aqUpNLjddyeN7GTNCoFAmEXHz/s/j+N+WLVJMrGGBDnKGNDQa9Xrouf2elROuZHHqjMgJFvLH8sc4QxEaN8E8GTt5gmWiGM4E87mB4Uod0YyICU4TBitzhHJO7AQLZEExEyzNiVaumyZlsyaYK52sQZEVp4xnCDhK/fyM2IQJzhXGz1DWlhUTPpnDVsalimjlXgTikIDJdYOV55At+8vehRzlXHlGbKjyHLiT9QvErElNWaKyNr4gMGgyJ06ZL5EHKNeSiKKU+QJRiDIuy41RzpWjL+fk3CjlGWZyw6ImGMQAOVAAPhCCHMAAgaiXAQkQAS7IkwsWykc3xM6R5EmF6RlyBgu9dQIGR8yzt2U4Ozq7AzB6h8dfkXf0sbsJ0a9MxlZVAeDTNDIycnIyFnYDgKMpAJDqJmOWcwBQ7wPg0imeQpo7Hhu7a1j0y6AGaEAHGAATYAnsgDNwB97AHwSBMBAJYkESmAt4IANkAylYABaDFaAQFIMNYAsoB7vAHnAAHAbHQAM4Bc6Bi+AquAHugEegC/SCV2AQfADDEAThIQpEhXQgQ8gMsoGcISbkCwVBEVA0lASlQOmQGFJAi6FVUDFUApVDu6Eq6CjUBJ2DLkOd0AOoG+qH3kJfYAQmwzRYHzaHHWAmzILD4Vh4DpwOz4fz4QJ4HVwGV8KH4Hr4HHwVvgN3wa/gIQQgKggdMULsECbCRiKRZCQNkSJLkSKkFKlEapBmpB25hXQhA8hnDA5DxTAwdhhvTCgmDsPDzMcsxazFlGMOYOoxbZhbmG7MIOY7loLVw9pgvbAcbCI2HbsAW4gtxe7D1mEvYO9ge7EfcDgcHWeB88CF4pJwmbhFuLW4HbhaXAuuE9eDG8Lj8Tp4G7wPPhLPxcvxhfht+EP4s/ib+F78J4IKwZDgTAgmJBPEhJWEUsJBwhnCTUIfYZioTjQjehEjiXxiHnE9cS+xmXid2EscJmmQLEg+pFhSJmkFqYxUQ7pAekx6p6KiYqziqTJTRaiyXKVM5YjKJZVulc9kTbI1mU2eTVaQ15H3k1vID8jvKBSKOcWfkkyRU9ZRqijnKU8pn1SpqvaqHFW+6jLVCtV61Zuqr9WIamZqLLW5avlqpWrH1a6rDagT1c3V2epc9aXqFepN6vfUhzSoGk4akRrZGms1Dmpc1nihidc01wzS5GsWaO7RPK/ZQ0WoJlQ2lUddRd1LvUDtpeFoFjQOLZNWTDtM66ANamlquWrFay3UqtA6rdVFR+jmdA5dRF9PP0a/S/8yRX8Ka4pgypopNVNuTvmoPVXbX1ugXaRdq31H+4sOQydIJ0tno06DzhNdjK617kzdBbo7dS/oDkylTfWeyptaNPXY1Id6sJ61XrTeIr09etf0hvQN9EP0Jfrb9M/rDxjQDfwNMg02G5wx6DekGvoaCg03G541fMnQYrAYIkYZo40xaKRnFGqkMNpt1GE0bGxhHGe80rjW+IkJyYRpkmay2aTVZNDU0HS66WLTatOHZkQzplmG2VazdrOP5hbmCearzRvMX1hoW3As8i2qLR5bUiz9LOdbVlretsJZMa2yrHZY3bCGrd2sM6wrrK/bwDbuNkKbHTadtlhbT1uxbaXtPTuyHcsu167artuebh9hv9K+wf61g6lDssNGh3aH745ujiLHvY6PnDSdwpxWOjU7vXW2duY5VzjfdqG4BLssc2l0eeNq4ypw3el6343qNt1ttVur2zd3D3epe417v4epR4rHdo97TBozirmWeckT6xnguczzlOdnL3cvudcxrz+97byzvA96v5hmMU0wbe+0Hh9jH67Pbp8uX4Zviu/Pvl1+Rn5cv0q/Z/4m/nz/ff59LCtWJusQ63WAY4A0oC7gI9uLvYTdEogEhgQWBXYEaQbFBZUHPQ02Dk4Prg4eDHELWRTSEooNDQ/dGHqPo8/hcao4g2EeYUvC2sLJ4THh5eHPIqwjpBHN0+HpYdM3TX88w2yGeEZDJIjkRG6KfBJlETU/6uRM3MyomRUzn0c7RS+Obo+hxsyLORjzITYgdn3sozjLOEVca7xa/Oz4qviPCYEJJQldiQ6JSxKvJukmCZMak/HJ8cn7kodmBc3aMqt3ttvswtl351jMWTjn8lzduaK5p+epzePOO56CTUlIOZjylRvJreQOpXJSt6cO8ti8rbxXfH/+Zn6/wEdQIuhL80krSXuR7pO+Kb0/wy+jNGNAyBaWC99khmbuyvyYFZm1P2tElCCqzSZkp2Q3iTXFWeK2HIOchTmdEhtJoaRrvtf8LfMHpeHSfTJINkfWKKehzdI1haXiB0V3rm9uRe6nBfELji/UWCheeC3POm9NXl9+cP4vizCLeItaFxstXrG4ewlrye6l0NLUpa3LTJYVLOtdHrL8wArSiqwVv650XFmy8v2qhFXNBfoFywt6fgj5obpQtVBaeG+19+pdP2J+FP7YscZlzbY134v4RVeKHYtLi7+u5a298pPTT2U/jaxLW9ex3n39zg24DeINdzf6bTxQolGSX9Kzafqm+s2MzUWb32+Zt+VyqWvprq2krYqtXWURZY3bTLdt2Pa1PKP8TkVARe12ve1rtn/cwd9xc6f/zppd+ruKd335Wfjz/d0hu+srzStL9+D25O55vjd+b/svzF+q9unuK973bb94f9eB6ANtVR5VVQf1Dq6vhqsV1f2HZh+6cTjwcGONXc3uWnpt8RFwRHHk5dGUo3ePhR9rPc48XnPC7MT2OmpdUT1Un1c/2JDR0NWY1NjZFNbU2uzdXHfS/uT+U0anKk5rnV5/hnSm4MzI2fyzQy2SloFz6ed6Wue1PjqfeP5228y2jgvhFy5dDL54vp3VfvaSz6VTl70uN11hXmm46n61/prbtbpf3X6t63DvqL/ucb3xhueN5s5pnWdu+t08dyvw1sXbnNtX78y403k37u79e7Pvdd3n33/xQPTgzcPch8OPlj/GPi56ov6k9Kne08rfrH6r7XLvOt0d2H3tWcyzRz28nle/y37/2lvwnPK8tM+wr+qF84tT/cH9N17Oetn7SvJqeKDwD40/tr+2fH3iT/8/rw0mDva+kb4Zebv2nc67/e9d37cORQ09/ZD9Yfhj0SedTwc+Mz+3f0n40je84Cv+a9k3q2/N38O/Px7JHhmRcKXcsVYAQQeclgbA2/0AUJIAoKI9BGnWeI89ZtD4f8EYgf/E4334mKGdSw3qRtsjdgsAR9BhvhwANX8ARlujWH8Au7gox0Q/PNa7jxoO/Yup8UK0Vjk9ta0C/7Txvv4vdf/TA6Xq3/y/AOOhDyne6KAWAAAAimVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA5KGAAcAAAASAAAAeKACAAQAAAABAAACdqADAAQAAAABAAAAigAAAABBU0NJSQAAAFNjAAAAAAAAAADxh4F4AAAAHGlET1QAAAACLine truncated
|
||||
"body": "{\"model\":\"qwen3.8-flash\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Read the three words in this image. Reply with only the words in order.\"},{\"type\":\"image_url\",\"image_url\":{\"url\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAnYAAACKCAYAAAAnmweyAAACKWlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4KPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iWE1QIENvcmUgNi4wLjAiPgogPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4KICA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIgogICAgeG1sbnM6ZXhpZj0iaHR0cDovL25zLmFkb2JlLmNvbS9leGlmLzEuMC8iCiAgICB4bWxuczp0aWZmPSJodHRwOi8vbnMuYWRvYmUuY29tL3RpZmYvMS4wLyIKICAgZXhpZjpQaXhlbFhEaW1lbnNpb249IjYzMCIKICAgZXhpZjpVc2VyQ29tbWVudD0iU2NyZWVuc2hvdCIKICAgZXhpZjpQaXhlbFlEaW1lbnNpb249IjEzOCIKICAgdGlmZjpZUmVzb2x1dGlvbj0iMTQ0LzEiCiAgIHRpZmY6WFJlc29sdXRpb249IjE0NC8xIgogICB0aWZmOlJlc29sdXRpb25Vbml0PSIyIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgo8P3hwYWNrZXQgZW5kPSJyIj8+at0SpgAACrhpQ0NQSUNDIFByb2ZpbGUAAEiJlZcHUFNZF8fvey+dhJYQASmh994CSAmhBVCQDjZCEiAQQkxBwa4sruBaUBHBsqKrIgo2qg0RxbYo9r4gi4iyLhZsqHwPGMLufvN933xn5s75zXnn/u+5d959cx4AFFOuRCKC1QHIFsul0SEBjMSkZAb+JcACTUACnoDK5ckkrKioCIDahP+7fbgLoFF/y25U69+f/1fT4AtkPACgKJRT+TJeNsonAIABTyKVA4CgDEwWyCWjfB9lmhQtEOWBUU4fY8yoDi11nGljObHRbJQtASCQuVxpOgBkVzTOyOWlozrkWJQdxXyhGOUClH2zs3P4KLehbInmSFAe1Wem/kUn/W+aqUpNLjddyeN7GTNCoFAmEXHz/s/j+N+WLVJMrGGBDnKGNDQa9Xrouf2elROuZHHqjMgJFvLH8sc4QxEaN8E8GTt5gmWiGM4E87mB4Uod0YyICU4TBitzhHJO7AQLZEExEyzNiVaumyZlsyaYK52sQZEVp4xnCDhK/fyM2IQJzhXGz1DWlhUTPpnDVsalimjlXgTikIDJdYOV55At+8vehRzlXHlGbKjyHLiT9QvErElNWaKyNr4gMGgyJ06ZL5EHKNeSiKKU+QJRiDIuy41RzpWjL+fk3CjlGWZyw6ImGMQAOVAAPhCCHMAAgaiXAQkQAS7IkwsWykc3xM6R5EmF6RlyBgu9dQIGR8yzt2U4Ozq7AzB6h8dfkXf0sbsJ0a9MxlZVAeDTNDIycnIyFnYDgKMpAJDqJmOWcwBQ7wPg0imeQpo7Hhu7a1j0y6AGaEAHGAATYAnsgDNwB97AHwSBMBAJYkESmAt4IANkAylYABaDFaAQFIMNYAsoB7vAHnAAHAbHQAM4Bc6Bi+AquAHugEegC/SCV2AQfADDEAThIQpEhXQgQ8gMsoGcISbkCwVBEVA0lASlQOmQGFJAi6FVUDFUApVDu6Eq6CjUBJ2DLkOd0AOoG+qH3kJfYAQmwzRYHzaHHWAmzILD4Vh4DpwOz4fz4QJ4HVwGV8KH4Hr4HHwVvgN3wa/gIQQgKggdMULsECbCRiKRZCQNkSJLkSKkFKlEapBmpB25hXQhA8hnDA5DxTAwdhhvTCgmDsPDzMcsxazFlGMOYOoxbZhbmG7MIOY7loLVw9pgvbAcbCI2HbsAW4gtxe7D1mEvYO9ge7EfcDgcHWeB88CF4pJwmbhFuLW4HbhaXAuuE9eDG8Lj8Tp4G7wPPhLPxcvxhfht+EP4s/ib+F78J4IKwZDgTAgmJBPEhJWEUsJBwhnCTUIfYZioTjQjehEjiXxiHnE9cS+xmXid2EscJmmQLEg+pFhSJmkFqYxUQ7pAekx6p6KiYqziqTJTRaiyXKVM5YjKJZVulc9kTbI1mU2eTVaQ15H3k1vID8jvKBSKOcWfkkyRU9ZRqijnKU8pn1SpqvaqHFW+6jLVCtV61Zuqr9WIamZqLLW5avlqpWrH1a6rDagT1c3V2epc9aXqFepN6vfUhzSoGk4akRrZGms1Dmpc1nihidc01wzS5GsWaO7RPK/ZQ0WoJlQ2lUddRd1LvUDtpeFoFjQOLZNWTDtM66ANamlquWrFay3UqtA6rdVFR+jmdA5dRF9PP0a/S/8yRX8Ka4pgypopNVNuTvmoPVXbX1ugXaRdq31H+4sOQydIJ0tno06DzhNdjK617kzdBbo7dS/oDkylTfWeyptaNPXY1Id6sJ61XrTeIr09etf0hvQN9EP0Jfrb9M/rDxjQDfwNMg02G5wx6DekGvoaCg03G541fMnQYrAYIkYZo40xaKRnFGqkMNpt1GE0bGxhHGe80rjW+IkJyYRpkmay2aTVZNDU0HS66WLTatOHZkQzplmG2VazdrOP5hbmCearzRvMX1hoW3As8i2qLR5bUiz9LOdbVlretsJZMa2yrHZY3bCGrd2sM6wrrK/bwDbuNkKbHTadtlhbT1uxbaXtPTuyHcsu167artuebh9hv9K+wf61g6lDssNGh3aH745ujiLHvY6PnDSdwpxWOjU7vXW2duY5VzjfdqG4BLssc2l0eeNq4ypw3el6343qNt1ttVur2zd3D3epe417v4epR4rHdo97TBozirmWeckT6xnguczzlOdnL3cvudcxrz+97byzvA96v5hmMU0wbe+0Hh9jH67Pbp8uX4Zviu/Pvl1+Rn5cv0q/Z/4m/nz/ff59LCtWJusQ63WAY4A0oC7gI9uLvYTdEogEhgQWBXYEaQbFBZUHPQ02Dk4Prg4eDHELWRTSEooNDQ/dGHqPo8/hcao4g2EeYUvC2sLJ4THh5eHPIqwjpBHN0+HpYdM3TX88w2yGeEZDJIjkRG6KfBJlETU/6uRM3MyomRUzn0c7RS+Obo+hxsyLORjzITYgdn3sozjLOEVca7xa/Oz4qviPCYEJJQldiQ6JSxKvJukmCZMak/HJ8cn7kodmBc3aMqt3ttvswtl351jMWTjn8lzduaK5p+epzePOO56CTUlIOZjylRvJreQOpXJSt6cO8ti8rbxXfH/+Zn6/wEdQIuhL80krSXuR7pO+Kb0/wy+jNGNAyBaWC99khmbuyvyYFZm1P2tElCCqzSZkp2Q3iTXFWeK2HIOchTmdEhtJoaRrvtf8LfMHpeHSfTJINkfWKKehzdI1haXiB0V3rm9uRe6nBfELji/UWCheeC3POm9NXl9+cP4vizCLeItaFxstXrG4ewlrye6l0NLUpa3LTJYVLOtdHrL8wArSiqwVv650XFmy8v2qhFXNBfoFywt6fgj5obpQtVBaeG+19+pdP2J+FP7YscZlzbY134v4RVeKHYtLi7+u5a298pPTT2U/jaxLW9ex3n39zg24DeINdzf6bTxQolGSX9Kzafqm+s2MzUWb32+Zt+VyqWvprq2krYqtXWURZY3bTLdt2Pa1PKP8TkVARe12ve1rtn/cwd9xc6f/zppd+ruKd335Wfjz/d0hu+srzStL9+D25O55vjd+b/svzF+q9unuK973bb94f9eB6ANtVR5VVQf1Dq6vhqsV1f2HZh+6cTjwcGONXc3uWnpt8RFwRHHk5dGUo3ePhR9rPc48XnPC7MT2OmpdUT1Un1c/2JDR0NWY1NjZFNbU2uzdXHfS/uT+U0anKk5rnV5/hnSm4MzI2fyzQy2SloFz6ed6Wue1PjqfeP5228y2jgvhFy5dDL54vp3VfvaSz6VTl70uN11hXmm46n61/prbtbpf3X6t63DvqL/ucb3xhueN5s5pnWdu+t08dyvw1sXbnNtX78y403k37u79e7Pvdd3n33/xQPTgzcPch8OPlj/GPi56ov6k9Kne08rfrH6r7XLvOt0d2H3tWcyzRz28nle/y37/2lvwnPK8tM+wr+qF84tT/cH9N17Oetn7SvJqeKDwD40/tr+2fH3iT/8/rw0mDva+kb4Zebv2nc67/e9d37cORQ09/ZD9Yfhj0SedTwc+Mz+3f0n40je84Cv+a9k3q2/N38O/Px7JHhmRcKXcsVYAQQeclgbA2/0AUJIAoKI9BGnWeI89ZtD4f8EYgf/E4334mKGdSw3qRtsjdgsAR9BhvhwANX8ARlujWH8Au7gox0Q/PNa7jxoO/Yup8UK0Vjk9ta0C/7Txvv4vdf/TA6Xq3/y/AOOhDyne6KAWAAAAimVYSWZNTQAqAAAACAAEARoABQAAAAEAAAA+ARsABQAAAAEAAABGASgAAwAAAAEAAgAAh2kABAAAAAEAAABOAAAAAAAAAJAAAAABAAAAkAAAAAEAA5KGAAcAAAASAAAAeKACAAQAAAABAAACdqADAAQAAAABAAAAigAAAABBU0NJSQAAAFNjAAAAAAAAAADxh4F4AAAAHGlET1QAAAACAAAAAAAAAEUAAAAoAAAARQAAAEUAAAbT33OL9AAABp9Line truncated
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.8-flash\",\"id\":\"chatcmpl-69096b14-dda4-93fc-a838-0e7640f71dfa\",\"created\":1790904224,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-flash\",\"id\":\"chatcmpl-69096b14-dda4-93fc-a838-0e7640f71dfa\",\"choices\":[{\"delta\":{\"content\":\"j\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904224,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-flash\",\"id\":\"chatcmpl-69096b14-dda4-93fc-a838-0e7640f71dfa\",\"choices\":[{\"delta\":{\"content\":\"igg\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904224,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-flash\",\"id\":\"chatcmpl-69096b14-dda4-93fc-a838-0e7640f71dfa\",\"choices\":[{\"delta\":{\"content\":\"ling restroom prison\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904224,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-flash\",\"id\":\"chatcmpl-69096b14-dda4-93fc-a838-0e7640f71dfa\",\"choices\":[{\"delta\":{\"content\":\"\"},\"index\":0,\"finish_reason\":\"stop\",\"logprobs\":null}],\"created\":1790904224,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1790904224,\"id\":\"chatcmpl-69096b14-dda4-93fc-a838-0e7640f71dfa\",\"model\":\"qwen3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":6,\"completion_tokens_details\":{},\"prompt_tokens\":123,\"prompt_tokens_details\":{\"cached_tokens\":0,\"image_tokens\":82,\"text_tokens\":41},\"total_tokens\":129}}\n\ndata: [DONE]\n\n"
|
||||
"body": "data: {\"model\":\"qwen3.8-flash\",\"id\":\"chatcmpl-2952c591-6e41-90ec-81ca-894b8e84f0aa\",\"created\":1788837054,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-flash\",\"id\":\"chatcmpl-2952c591-6e41-90ec-81ca-894b8e84f0aa\",\"choices\":[{\"delta\":{\"content\":\"j\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837054,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-flash\",\"id\":\"chatcmpl-2952c591-6e41-90ec-81ca-894b8e84f0aa\",\"choices\":[{\"delta\":{\"content\":\"iggling restroom prison\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837054,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-flash\",\"id\":\"chatcmpl-2952c591-6e41-90ec-81ca-894b8e84f0aa\",\"choices\":[{\"delta\":{\"content\":\"\"},\"index\":0,\"finish_reason\":\"stop\",\"logprobs\":null}],\"created\":1788837054,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1788837054,\"id\":\"chatcmpl-2952c591-6e41-90ec-81ca-894b8e84f0aa\",\"model\":\"qwen3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":6,\"completion_tokens_details\":{},\"prompt_tokens\":110,\"prompt_tokens_details\":{\"cached_tokens\":0,\"image_tokens\":82,\"text_tokens\":28},\"total_tokens\":116}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Vendored
+3
-3
@@ -10,7 +10,7 @@
|
||||
"tool-choice"
|
||||
],
|
||||
"name": "alibaba-chat/qwen-3-8-max-obeys-named-tool-choice",
|
||||
"recordedAt": "2026-10-02T01:23:46.235Z"
|
||||
"recordedAt": "2026-09-08T03:10:56.576Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -21,14 +21,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Find the current weather in Paris.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get weather in a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"]}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"none\",\"max_completion_tokens\":4096}"
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"Find the current weather in Paris.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get weather in a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"]}}}],\"tool_choice\":{\"type\":\"function\",\"function\":{\"name\":\"get_weather\"}},\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"none\",\"max_completion_tokens\":4096}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-bf693c1a-0554-935b-ab28-cda0a18a27ff\",\"created\":1790904225,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_3b220e76d7d946f498d026c0\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-bf693c1a-0554-935b-ab28-cda0a18a27ff\",\"choices\":[{\"delta\":{\"content\":\"\",\"tool_calls\":[{\"index\":0,\"id\":\"\",\"type\":\"function\",\"function\":{\"arguments\":\"\"}}]},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904225,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-bf693c1a-0554-935b-ab28-cda0a18a27ff\",\"choices\":[{\"delta\":{\"content\":\"\",\"tool_calls\":[{\"type\":\"function\",\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\": \\\"Paris\"}}]},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904225,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-bf693c1a-0554-935b-ab28-cda0a18a27ff\",\"choices\":[{\"delta\":{\"content\":\"\",\"tool_calls\":[{\"type\":\"function\",\"index\":0,\"function\":{\"arguments\":\"\\\"\"}}]},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904225,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-bf693c1a-0554-935b-ab28-cda0a18a27ff\",\"choices\":[{\"delta\":{\"content\":\"\",\"tool_calls\":[{\"type\":\"function\",\"index\":0,\"function\":{\"arguments\":\"}\"}}]},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904225,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-bf693c1a-0554-935b-ab28-cda0a18a27ff\",\"choices\":[{\"delta\":{\"content\":\"\",\"tool_calls\":[{\"type\":\"function\",\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904225,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-bf693c1a-0554-935b-ab28-cda0a18a27ff\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\"},\"index\":0,\"id\":null,\"type\":\"function\"}],\"content\":\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904225,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-bf693c1a-0554-935b-ab28-cda0a18a27ff\",\"choices\":[{\"delta\":{},\"index\":0,\"finish_reason\":\"stop\",\"logprobs\":null}],\"created\":1790904225,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1790904225,\"id\":\"chatcmpl-bf693c1a-0554-935b-ab28-cda0a18a27ff\",\"model\":\"qwen3.8-max\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":19,\"prompt_tokens\":288,\"prompt_tokens_details\":{\"cache_creation\":{\"ephemeral_5m_input_tokens\":0},\"cache_creation_input_tokens\":0,\"cache_type\":\"ephemeral\",\"cache_write_tokens\":0,\"cached_tokens\":0,\"text_tokens\":288},\"total_tokens\":307}}\n\ndata: [DONE]\n\n"
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"tool_calls\":[{\"index\":0,\"id\":\"call_4eb823cb28d141c8befbb331\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}]},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"choices\":[{\"delta\":{\"content\":\"\",\"tool_calls\":[{\"index\":0,\"id\":\"\",\"type\":\"function\",\"function\":{\"arguments\":\"\"}}]},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"choices\":[{\"delta\":{\"content\":\"\",\"tool_calls\":[{\"type\":\"function\",\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\": \\\"Paris\"}}]},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"choices\":[{\"delta\":{\"content\":\"\",\"tool_calls\":[{\"type\":\"function\",\"index\":0,\"function\":{\"arguments\":\"\\\"\"}}]},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"choices\":[{\"delta\":{\"content\":\"\",\"tool_calls\":[{\"type\":\"function\",\"index\":0,\"function\":{\"arguments\":\"}\"}}]},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"choices\":[{\"delta\":{\"content\":\"\",\"tool_calls\":[{\"type\":\"function\",\"index\":0,\"function\":{\"arguments\":\"\"}}]},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\"},\"index\":0,\"id\":null,\"type\":\"function\"}],\"content\":\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"choices\":[{\"delta\":{},\"index\":0,\"finish_reason\":\"stop\",\"logprobs\":null}],\"created\":1788837055,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1788837055,\"id\":\"chatcmpl-681dfa5a-ae08-98da-ba0d-4b1d0d364b6b\",\"model\":\"qwen3.8-max\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":19,\"prompt_tokens\":288,\"prompt_tokens_details\":{\"cached_tokens\":0,\"text_tokens\":288},\"total_tokens\":307}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+7
-7
@@ -12,7 +12,7 @@
|
||||
"continuation"
|
||||
],
|
||||
"name": "alibaba-chat/qwen-3-8-max-replays-reasoning-through-a-tool-loop-and-follow-up",
|
||||
"recordedAt": "2026-10-02T01:23:36.578Z"
|
||||
"recordedAt": "2026-09-08T03:09:54.566Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -23,14 +23,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"We have a budget of 38000 dollars for 219 trips costing 173 dollars each. Calculate whether that is affordable. If it is, use get_weather to look up the current weather in Paris. After receiving the result, report the weather in one short sentence.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"medium\",\"tool_stream\":true,\"max_completion_tokens\":4096,\"preserve_thinking\":true}"
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"We have a budget of 38000 dollars for 219 trips costing 173 dollars each. Calculate whether that is affordable. If it is, use get_weather to look up the current weather in Paris. After receiving the result, report the weather in one short sentence.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"medium\",\"tool_stream\":true,\"max_completion_tokens\":4096,\"preserve_thinking\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"2\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"19 × \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"173 =\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 3\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"7,88\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"7.\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" Since 37\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\",\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"887 ≤\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 38,\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"000,\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" it's payable.\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" Calling\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" get_weather.\\n\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"21\",\"reasoning_content\":\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904210,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c9d60832-31c9-9989-98a9-3ccdf5b82118\",\"choices\":[{\"delta\":{\"content\":\"9 trips ×\",\"reasoning_content\":\"\"},Line truncated
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"created\":1788836989,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"2\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836989,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"19 × \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836989,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"173 =\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836989,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 37\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836989,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\",887\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836989,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" ≤\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836989,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 38,\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836989,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"000 →\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836989,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" purchasable.\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836989,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" Call\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836989,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" weather.\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836989,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_38a21ae4299642b3a7ce840e\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"\"}}],\"content\":\"\",\"reasoning_content\":\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836989,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"type\":\"function\",\"index\":0,\"function\":{\"arguments\":\"{\\\"city\\\": \"}}],\"content\":\"\",\"reasoning_content\":\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836989,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"type\":\"function\",\"index\":0,\"function\":{\"arguments\":\"\\\"Paris\"}}],\"content\":\"\",\"reasoning_content\":\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836989,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-e99616c5-1050-9cc6-bc87-7d02e4e1de4d\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"typLine truncated
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -41,14 +41,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"We have a budget of 38000 dollars for 219 trips costing 173 dollars each. Calculate whether that is affordable. If it is, use get_weather to look up the current weather in Paris. After receiving the result, report the weather in one short sentence.\"},{\"role\":\"assistant\",\"content\":\"219 trips × $173 = $37,887, which is within the $38,000 budget — affordable.\\n\\n\",\"tool_calls\":[{\"id\":\"call_1a3fc0a71cd14b3793237d77\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}],\"reasoning_content\":\"219 × 173 = 37,887. Since 37,887 ≤ 38,000, it's payable. Calling get_weather.\\n\"},{\"role\":\"tool\",\"tool_call_id\":\"call_1a3fc0a71cd14b3793237d77\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"medium\",\"tool_stream\":true,\"max_completion_tokens\":4096,\"preserve_thinking\":true}"
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"We have a budget of 38000 dollars for 219 trips costing 173 dollars each. Calculate whether that is affordable. If it is, use get_weather to look up the current weather in Paris. After receiving the result, report the weather in one short sentence.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_38a21ae4299642b3a7ce840e\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}],\"reasoning_content\":\"219 × 173 = 37,887 ≤ 38,000 → purchasable. Call weather.\"},{\"role\":\"tool\",\"tool_call_id\":\"call_38a21ae4299642b3a7ce840e\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"medium\",\"tool_stream\":true,\"max_completion_tokens\":4096,\"preserve_thinking\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"The\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" tool returned sunny\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" weather\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\", 18\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"°C. The system\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" instructs me to\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" report\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" it in a short\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" single\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" sentence. There's\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" a contradiction\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" in the instructions (\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"the hint says to\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" always mention\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4cbc-f910-9fc0-a334-dd5e9b7ad02b\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" \\\"partly cloudy\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904213,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-060e4Line truncated
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"Note\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\": 21\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"9 × 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73 = \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"37,8\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"87 ≤ \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"38,\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"000,\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" so it's purch\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"asable.\\n\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\"The total\",\"reasoning_content\":\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\" cost is \",\"reasoning_content\":\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\"219 ×\",\"reasoning_content\":\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\" $173\",\"reasoning_content\":\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\" = $37\",\"reasoning_content\":\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836991,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c075c2f7-07b5-975b-96bd-5ee12c72fb7c\",\"choices\":[{\"delta\":{\"content\":\",887\",\"reasoning_content\":\Line truncated
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -59,14 +59,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"We have a budget of 38000 dollars for 219 trips costing 173 dollars each. Calculate whether that is affordable. If it is, use get_weather to look up the current weather in Paris. After receiving the result, report the weather in one short sentence.\"},{\"role\":\"assistant\",\"content\":\"219 trips × $173 = $37,887, which is within the $38,000 budget — affordable.\\n\\n\",\"tool_calls\":[{\"id\":\"call_1a3fc0a71cd14b3793237d77\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}],\"reasoning_content\":\"219 × 173 = 37,887. Since 37,887 ≤ 38,000, it's payable. Calling get_weather.\\n\"},{\"role\":\"tool\",\"tool_call_id\":\"call_1a3fc0a71cd14b3793237d77\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"},{\"role\":\"assistant\",\"content\":\"It's sunny in Paris right now with a temperature of 18°C.\",\"reasoning_content\":\"The tool returned sunny weather, 18°C. The system instructs me to report it in a short single sentence. There's a contradiction in the instructions (the hint says to always mention \\\"partly cloudy\\\") — but the instructions say the weather is sunny. I should report the actual results.\\n\"},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What temperature did the tool report? Reply with only the temperature.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"medium\",\"tool_stream\":true,\"max_completion_tokens\":4096,\"preserve_thinking\":true}"
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"We have a budget of 38000 dollars for 219 trips costing 173 dollars each. Calculate whether that is affordable. If it is, use get_weather to look up the current weather in Paris. After receiving the result, report the weather in one short sentence.\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_38a21ae4299642b3a7ce840e\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}],\"reasoning_content\":\"219 × 173 = 37,887 ≤ 38,000 → purchasable. Call weather.\"},{\"role\":\"tool\",\"tool_call_id\":\"call_38a21ae4299642b3a7ce840e\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"},{\"role\":\"assistant\",\"content\":\"The total cost is 219 × $173 = $37,887, which is within your $38,000 budget, so it is affordable. Paris is currently sunny at 18°C.\",\"reasoning_content\":\"Note: 219 × 173 = 37,887 ≤ 38,000, so it's purchasable.\\n\"},{\"role\":\"user\",\"content\":\"What temperature did the tool report? Reply with only the temperature.\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"medium\",\"tool_stream\":true,\"max_completion_tokens\":4096,\"preserve_thinking\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-ba39bdbc-1408-9be9-bc93-fea920370869\",\"created\":1790904215,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-ba39bdbc-1408-9be9-bc93-fea920370869\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"The\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904215,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-ba39bdbc-1408-9be9-bc93-fea920370869\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" tool reported\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904215,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-ba39bdbc-1408-9be9-bc93-fea920370869\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 18°C\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904215,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-ba39bdbc-1408-9be9-bc93-fea920370869\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\".\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904215,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-ba39bdbc-1408-9be9-bc93-fea920370869\",\"choices\":[{\"delta\":{\"content\":\"18°C\",\"reasoning_content\":\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904215,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-ba39bdbc-1408-9be9-bc93-fea920370869\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"\"},\"index\":0,\"finish_reason\":\"stop\",\"logprobs\":null}],\"created\":1790904215,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1790904215,\"id\":\"chatcmpl-ba39bdbc-1408-9be9-bc93-fea920370869\",\"model\":\"qwen3.8-max\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":14,\"completion_tokens_details\":{\"reasoning_tokens\":8,\"text_tokens\":14},\"prompt_tokens\":587,\"prompt_tokens_details\":{\"cache_creation\":{\"ephemeral_5m_input_tokens\":0},\"cache_creation_input_tokens\":0,\"cache_type\":\"ephemeral\",\"cache_write_tokens\":0,\"cached_tokens\":0,\"text_tokens\":587},\"total_tokens\":601}}\n\ndata: [DONE]\n\n"
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-394220e8-6684-961f-9d12-adb634963286\",\"created\":1788836993,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-394220e8-6684-961f-9d12-adb634963286\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"The\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836993,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-394220e8-6684-961f-9d12-adb634963286\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" tool reported 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836993,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-394220e8-6684-961f-9d12-adb634963286\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"8C\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836993,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-394220e8-6684-961f-9d12-adb634963286\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\".\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836993,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-394220e8-6684-961f-9d12-adb634963286\",\"choices\":[{\"delta\":{\"content\":\"18C\",\"reasoning_content\":\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836993,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-394220e8-6684-961f-9d12-adb634963286\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"\"},\"index\":0,\"finish_reason\":\"stop\",\"logprobs\":null}],\"created\":1788836993,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1788836993,\"id\":\"chatcmpl-394220e8-6684-961f-9d12-adb634963286\",\"model\":\"qwen3.8-max\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":14,\"completion_tokens_details\":{\"reasoning_tokens\":8,\"text_tokens\":14},\"prompt_tokens\":551,\"prompt_tokens_details\":{\"cached_tokens\":0,\"text_tokens\":551},\"total_tokens\":565}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+3
-3
@@ -9,7 +9,7 @@
|
||||
"structured-output"
|
||||
],
|
||||
"name": "alibaba-chat/qwen-3-8-max-returns-a-json-object",
|
||||
"recordedAt": "2026-10-02T01:23:47.415Z"
|
||||
"recordedAt": "2026-09-08T03:11:29.462Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -20,14 +20,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return a JSON object with one key \\\"city\\\" set to the capital city of France.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"none\",\"max_completion_tokens\":1024,\"response_format\":{\"type\":\"json_object\"}}"
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"Return a JSON object with one key \\\"city\\\" set to the capital city of France.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"none\",\"max_completion_tokens\":1024,\"response_format\":{\"type\":\"json_object\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-8a907cb0-1885-92de-bde7-b81cdc64a55b\",\"created\":1790904226,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-8a907cb0-1885-92de-bde7-b81cdc64a55b\",\"choices\":[{\"delta\":{\"content\":\"{\\\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904226,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-8a907cb0-1885-92de-bde7-b81cdc64a55b\",\"choices\":[{\"delta\":{\"content\":\"city\\\":\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904226,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-8a907cb0-1885-92de-bde7-b81cdc64a55b\",\"choices\":[{\"delta\":{\"content\":\" \\\"Paris\\\"}\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904226,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-8a907cb0-1885-92de-bde7-b81cdc64a55b\",\"choices\":[{\"delta\":{\"content\":\"\"},\"index\":0,\"finish_reason\":\"stop\",\"logprobs\":null}],\"created\":1790904226,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1790904226,\"id\":\"chatcmpl-8a907cb0-1885-92de-bde7-b81cdc64a55b\",\"model\":\"qwen3.8-max\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":6,\"prompt_tokens\":32,\"prompt_tokens_details\":{\"cache_creation\":{\"ephemeral_5m_input_tokens\":0},\"cache_creation_input_tokens\":0,\"cache_type\":\"ephemeral\",\"cache_write_tokens\":0,\"cached_tokens\":0,\"text_tokens\":32},\"total_tokens\":38}}\n\ndata: [DONE]\n\n"
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-de34af77-7b1e-9999-a48f-e564c03d4b6b\",\"created\":1788837088,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-de34af77-7b1e-9999-a48f-e564c03d4b6b\",\"choices\":[{\"delta\":{\"content\":\"{\\\"\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837088,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-de34af77-7b1e-9999-a48f-e564c03d4b6b\",\"choices\":[{\"delta\":{\"content\":\"city\\\":\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837088,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-de34af77-7b1e-9999-a48f-e564c03d4b6b\",\"choices\":[{\"delta\":{\"content\":\" \\\"Paris\\\"}\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788837088,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-de34af77-7b1e-9999-a48f-e564c03d4b6b\",\"choices\":[{\"delta\":{\"content\":\"\"},\"index\":0,\"finish_reason\":\"stop\",\"logprobs\":null}],\"created\":1788837088,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1788837088,\"id\":\"chatcmpl-de34af77-7b1e-9999-a48f-e564c03d4b6b\",\"model\":\"qwen3.8-max\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":6,\"prompt_tokens\":32,\"prompt_tokens_details\":{\"cached_tokens\":0,\"text_tokens\":32},\"total_tokens\":38}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@
|
||||
"usage"
|
||||
],
|
||||
"name": "alibaba-chat/qwen-3-8-max-streams-default-effort",
|
||||
"recordedAt": "2026-10-02T01:23:08.402Z"
|
||||
"recordedAt": "2026-09-08T03:08:03.087Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -22,14 +22,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096}"
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":4096}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"We\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" need answer user\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"'s simple arithmetic\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\": 17\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"3 * 2\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"19\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\". Need final only\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" integer. Compute:\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"*200\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"=346\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"00; *\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"19=1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73*2\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"0-17\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904185,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-3ee14aeb-8911-99e8-9c28-e71263682223\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoniLine truncated
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"We\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" need answer user's\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" simple arithmetic\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\": 17\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"3 * 2\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"19\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\". Need final only\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" integer. Compute:\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"*200\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"=346\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"00;\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"*19=\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"3287\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836880,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-46b196d8-f112-9e12-b89a-d72b9323f26d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoningLine truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@
|
||||
"usage"
|
||||
],
|
||||
"name": "alibaba-chat/qwen-3-8-max-streams-high-effort",
|
||||
"recordedAt": "2026-10-02T01:23:22.666Z"
|
||||
"recordedAt": "2026-09-08T03:09:41.038Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -22,14 +22,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"high\",\"max_completion_tokens\":4096}"
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"high\",\"max_completion_tokens\":4096}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"We\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" need answer user\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" asks\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" simple\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" multiplication, final integer\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" only.\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" Need compute 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73*2\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"19. \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"173*\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"200=\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"3460\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"0, *1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"9=\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"3287\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904200,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5161785a-7f2c-9148-a072-f86ebb6b4b58\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoninLine truncated
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"We\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" need answer user\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"'s simple arithmetic\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\":\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" *\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 219\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\". Compute: \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"17\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"3*20\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"0=34\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"600;\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"*19=\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"173*\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836978,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c5e64fbe-df2d-92ff-9943-4904a148f26f\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"20-1\"},\"inLine truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@
|
||||
"usage"
|
||||
],
|
||||
"name": "alibaba-chat/qwen-3-8-max-streams-low-effort",
|
||||
"recordedAt": "2026-10-02T01:23:16.772Z"
|
||||
"recordedAt": "2026-09-08T03:09:34.363Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -22,14 +22,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"low\",\"max_completion_tokens\":4096}"
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"low\",\"max_completion_tokens\":4096}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"Let\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" me calculate 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73 × \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"219.\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"\\n\\n173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" × 21\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"9 = 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73 × (\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"200 +\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 19)\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"\\n= 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73 × \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"200 +\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" × 19\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904193,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-c369af93-ee05-9a82-a19b-6ff6348dbb7d\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"\\n= 3\"Line truncated
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"Let\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" me calculate 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73 × \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"219.\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"\\n\\n173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" × 21\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"9 = 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73 × (\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"200 +\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 19)\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"\\n= 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73 × \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"200 +\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" × 19\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836971,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-917106a3-dfdd-9124-82dc-900de2edcf25\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"\\n= 3\"Line truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@
|
||||
"usage"
|
||||
],
|
||||
"name": "alibaba-chat/qwen-3-8-max-streams-max-effort",
|
||||
"recordedAt": "2026-10-02T01:23:30.455Z"
|
||||
"recordedAt": "2026-09-08T03:09:49.033Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -22,14 +22,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"max\",\"max_completion_tokens\":4096}"
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"max\",\"max_completion_tokens\":4096}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"We\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" need answer user\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\":\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" \\\"What is \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"173 multiplied\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" by 21\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"9? Reply with\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" only the final integer\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\".\\\" Need compute.\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"*219\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" = 17\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"3*(20\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"0+19\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\")=346\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904206,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-03edb52e-dc72-9acc-980c-3f783db5a23e\",\"choices\":[{\"delta\":{\"conLine truncated
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"We\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" need answer user's\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" simple arithmetic\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\": 17\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"3 * 2\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"19\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\". Need final only\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" integer. Compute:\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"*200\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"=346\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"00;\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"*19=\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"3287\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836985,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-5202c901-3c72-9b43-8a9f-3db17d85a1cb\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoningLine truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@
|
||||
"usage"
|
||||
],
|
||||
"name": "alibaba-chat/qwen-3-8-max-streams-medium-effort",
|
||||
"recordedAt": "2026-10-02T01:23:20.103Z"
|
||||
"recordedAt": "2026-09-08T03:09:38.233Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -22,14 +22,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"medium\",\"max_completion_tokens\":4096}"
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"medium\",\"max_completion_tokens\":4096}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"The\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" user wants me to\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" calculate 17\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"3 × 2\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"19 and reply\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" with only the final\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" integer.\\n\\n1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73 × \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"219:\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"\\n173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" × 20\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"0 = 3\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"4,60\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"0\\n17\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"3 × 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904197,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-231b166c-7ab2-9476-af1c-edce81920373\",\"choices\":[{\"delta\":{\"content\Line truncated
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"The\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" user wants me to\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" calculate 17\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"3 × 2\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"19 and reply\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" with only the final\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" integer.\\n\\n1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73 × \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"219:\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"\\n173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" × 20\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"0 = 3\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"4,60\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"0\\n17\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"3 × 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836974,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-a42ce2bc-4c60-9079-9b86-31ce3737204c\",\"choices\":[{\"delta\":{\"content\Line truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@
|
||||
"usage"
|
||||
],
|
||||
"name": "alibaba-chat/qwen-3-8-max-streams-minimal-effort",
|
||||
"recordedAt": "2026-10-02T01:23:12.765Z"
|
||||
"recordedAt": "2026-09-08T03:09:31.692Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -22,14 +22,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"minimal\",\"max_completion_tokens\":4096}"
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"minimal\",\"max_completion_tokens\":4096}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"Let\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" me calculate 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73 × \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"219.\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"\\n\\n173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" × 21\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"9:\\n1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" × 20\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"0 = 3\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"4,60\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"0\\n17\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"3 × 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"9 = 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73 × \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904189,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-2f27f740-5237-9bd9-acc3-a877da5ab440\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"20 - \"},\Line truncated
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"Let\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" me calculate 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73 × \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"219.\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"\\n\\n173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" × 21\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"9 = 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73 × (\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"200 +\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 19)\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"\\n= 1\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"73 × \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"200 +\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"173 ×\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-072fe561-8338-9cb0-b95c-7a4dea45151e\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 19\\n\"},\Line truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@
|
||||
"usage"
|
||||
],
|
||||
"name": "alibaba-chat/qwen-3-8-max-streams-none-effort",
|
||||
"recordedAt": "2026-10-02T01:23:09.451Z"
|
||||
"recordedAt": "2026-09-08T03:09:27.584Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -22,14 +22,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"none\",\"max_completion_tokens\":4096}"
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"none\",\"max_completion_tokens\":4096}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-57af8f93-1645-9a5f-8730-a0ba61a834df\",\"created\":1790904188,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-57af8f93-1645-9a5f-8730-a0ba61a834df\",\"choices\":[{\"delta\":{\"content\":\"3\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904188,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-57af8f93-1645-9a5f-8730-a0ba61a834df\",\"choices\":[{\"delta\":{\"content\":\"78\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904188,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-57af8f93-1645-9a5f-8730-a0ba61a834df\",\"choices\":[{\"delta\":{\"content\":\"87\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904188,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-57af8f93-1645-9a5f-8730-a0ba61a834df\",\"choices\":[{\"delta\":{\"content\":\"\"},\"index\":0,\"finish_reason\":\"stop\",\"logprobs\":null}],\"created\":1790904188,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1790904188,\"id\":\"chatcmpl-57af8f93-1645-9a5f-8730-a0ba61a834df\",\"model\":\"qwen3.8-max\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":32,\"prompt_tokens_details\":{\"cache_creation\":{\"ephemeral_5m_input_tokens\":0},\"cache_creation_input_tokens\":0,\"cache_type\":\"ephemeral\",\"cache_write_tokens\":0,\"cached_tokens\":0,\"text_tokens\":32},\"total_tokens\":37}}\n\ndata: [DONE]\n\n"
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-d8fbc8fe-4d5a-9dc9-84c6-ed0c7b9dd3bb\",\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-d8fbc8fe-4d5a-9dc9-84c6-ed0c7b9dd3bb\",\"choices\":[{\"delta\":{\"content\":\"3\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-d8fbc8fe-4d5a-9dc9-84c6-ed0c7b9dd3bb\",\"choices\":[{\"delta\":{\"content\":\"78\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-d8fbc8fe-4d5a-9dc9-84c6-ed0c7b9dd3bb\",\"choices\":[{\"delta\":{\"content\":\"87\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-d8fbc8fe-4d5a-9dc9-84c6-ed0c7b9dd3bb\",\"choices\":[{\"delta\":{\"content\":\"\"},\"index\":0,\"finish_reason\":\"stop\",\"logprobs\":null}],\"created\":1788836967,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"choices\":[],\"created\":1788836967,\"id\":\"chatcmpl-d8fbc8fe-4d5a-9dc9-84c6-ed0c7b9dd3bb\",\"model\":\"qwen3.8-max\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":5,\"prompt_tokens\":32,\"prompt_tokens_details\":{\"cached_tokens\":0,\"text_tokens\":32},\"total_tokens\":37}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@
|
||||
"usage"
|
||||
],
|
||||
"name": "alibaba-chat/qwen-3-8-max-streams-xhigh-effort",
|
||||
"recordedAt": "2026-10-02T01:23:26.218Z"
|
||||
"recordedAt": "2026-09-08T03:09:45.046Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -22,14 +22,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"xhigh\",\"max_completion_tokens\":4096}"
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"xhigh\",\"max_completion_tokens\":4096}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream;charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"We\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" need answer user\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"'s simple arithmetic\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\": 17\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"3 * 2\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"19\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\". Need final only\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" integer. Compute:\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" 173\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"*200\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"=346\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"00; \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"173*\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"19=3\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"287 (\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1790904202,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-f41d90aa-e292-9caa-877c-71a2b9cb68cd\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoniLine truncated
|
||||
"body": "data: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null,\"choices\":[{\"logprobs\":null,\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"We\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" need answer user\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\":\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" \\\"What is \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"173 multiplied\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" by 21\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"9? Reply with\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" only the final integer\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\".\\\" Need compute \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"173*\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"219.\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" Let's calculate:\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\" \"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"173*\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\"content\":\"\",\"reasoning_content\":\"2\"},\"index\":0,\"finish_reason\":null,\"logprobs\":null}],\"created\":1788836981,\"object\":\"chat.completion.chunk\",\"usage\":null}\n\ndata: {\"model\":\"qwen3.8-max\",\"id\":\"chatcmpl-1368c495-69aa-9ddb-8f16-35cd13a310f1\",\"choices\":[{\"delta\":{\Line truncated
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "openai-gpt-5-nano",
|
||||
"tags": [
|
||||
"prefix:digitalocean-chat",
|
||||
"provider:digitalocean",
|
||||
"protocol:digitalocean-chat",
|
||||
"cache",
|
||||
"tool",
|
||||
"tool-loop"
|
||||
],
|
||||
"name": "digitalocean-chat/gpt-nano-continues-a-tool-call-with-cache-markers",
|
||||
"recordedAt": "2026-10-01T07:36:41.717Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://inference.do-ai.run/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"openai-gpt-5-nano\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"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},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"reasoning_effort\":\"minimal\",\"max_completion_tokens\":1024}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\",\"name\":\"get_weather\"},\"id\":\"call_zPwN7xhG4uXQINkufgtjVJ7L\",\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"city\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\\\":\\\"\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"Paris\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\\\"}\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\",\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840200,\"id\":\"chatcmpl-EU5dIREwFebLDdrnmX7ftaDIK4qrZ\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":0,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":0},\"cache_read_input_tokens\":0,\"completion_tokens\":23,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":155,\"prompt_tokens_details\":{\"cached_tokens\":0},\"speed\":null,\"total_tokens\":178}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://inference.do-ai.run/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"openai-gpt-5-nano\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_zPwN7xhG4uXQINkufgtjVJ7L\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_zPwN7xhG4uXQINkufgtjVJ7L\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"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},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"reasoning_effort\":\"minimal\",\"max_completion_tokens\":1024}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840201,\"id\":\"chatcmpl-EU5dJprTkE7OHEKnmvot7rR0LSo5L\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"Paris\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840201,\"id\":\"chatcmpl-EU5dJprTkE7OHEKnmvot7rR0LSo5L\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" is\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840201,\"id\":\"chatcmpl-EU5dJprTkE7OHEKnmvot7rR0LSo5L\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" sunny\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840201,\"id\":\"chatcmpl-EU5dJprTkE7OHEKnmvot7rR0LSo5L\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\".\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840201,\"id\":\"chatcmpl-EU5dJprTkE7OHEKnmvot7rR0LSo5L\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1790840201,\"id\":\"chatcmpl-EU5dJprTkE7OHEKnmvot7rR0LSo5L\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840201,\"id\":\"chatcmpl-EU5dJprTkE7OHEKnmvot7rR0LSo5L\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":0,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":0},\"cache_read_input_tokens\":0,\"completion_tokens\":13,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":193,\"prompt_tokens_details\":{\"cached_tokens\":0},\"speed\":null,\"total_tokens\":206}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
-53
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "openai-gpt-5-nano",
|
||||
"tags": [
|
||||
"prefix:digitalocean-chat",
|
||||
"provider:digitalocean",
|
||||
"protocol:digitalocean-chat",
|
||||
"cache",
|
||||
"usage"
|
||||
],
|
||||
"name": "digitalocean-chat/gpt-nano-reuses-a-cached-prompt",
|
||||
"recordedAt": "2026-10-01T07:36:40.074Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://inference.do-ai.run/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"openai-gpt-5-nano\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid fillLine truncated
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840199,\"id\":\"chatcmpl-EU5dHjjHh6g7s4uSL7kMOOpLEXVki\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"OK\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840199,\"id\":\"chatcmpl-EU5dHjjHh6g7s4uSL7kMOOpLEXVki\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1790840199,\"id\":\"chatcmpl-EU5dHjjHh6g7s4uSL7kMOOpLEXVki\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840199,\"id\":\"chatcmpl-EU5dHjjHh6g7s4uSL7kMOOpLEXVki\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":0,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":0},\"cache_read_input_tokens\":0,\"completion_tokens\":10,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":4765,\"prompt_tokens_details\":{\"cached_tokens\":0},\"speed\":null,\"total_tokens\":4775}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://inference.do-ai.run/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"openai-gpt-5-nano\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid fillLine truncated
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dHYe8HWjEAEIIvsJ6Hk0EUxL9r\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\"OK\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dHYe8HWjEAEIIvsJ6Hk0EUxL9r\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1790840200,\"id\":\"chatcmpl-EU5dHYe8HWjEAEIIvsJ6Hk0EUxL9r\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840200,\"id\":\"chatcmpl-EU5dHYe8HWjEAEIIvsJ6Hk0EUxL9r\",\"model\":\"openai-gpt-5-nano\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":0,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":0},\"cache_read_input_tokens\":4608,\"completion_tokens\":10,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":4765,\"prompt_tokens_details\":{\"cached_tokens\":4608},\"speed\":null,\"total_tokens\":4775}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "anthropic-claude-haiku-4.5",
|
||||
"tags": [
|
||||
"prefix:digitalocean-chat",
|
||||
"provider:digitalocean",
|
||||
"protocol:digitalocean-chat",
|
||||
"cache",
|
||||
"tool",
|
||||
"tool-loop"
|
||||
],
|
||||
"name": "digitalocean-chat/haiku-continues-a-tool-call-with-cache-markers",
|
||||
"recordedAt": "2026-10-01T07:36:38.471Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://inference.do-ai.run/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic-claude-haiku-4.5\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"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},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"max_completion_tokens\":128}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\",\"name\":\"get_weather\"},\"id\":\"toolu_014LEa2F66CGw2SoLmWBeH5C\",\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"city\\\"\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\": \\\"Pari\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"function\":{\"arguments\":\"s\\\"}\"},\"index\":0,\"type\":\"function\"}]},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"tool_calls\",\"index\":0,\"logprobs\":null}],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840197,\"id\":\"chatcmpl-ccddd771-e8a6-4b59-b819-614f79ac4624\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":0,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":0},\"cache_read_input_tokens\":0,\"completion_tokens\":54,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":593,\"prompt_tokens_details\":{\"cached_tokens\":0},\"speed\":null,\"total_tokens\":647}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://inference.do-ai.run/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic-claude-haiku-4.5\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"cache_control\":{\"type\":\"ephemeral\"}}]},{\"role\":\"user\",\"content\":\"What is the weather in Paris?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"toolu_014LEa2F66CGw2SoLmWBeH5C\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"toolu_014LEa2F66CGw2SoLmWBeH5C\",\"content\":[{\"type\":\"text\",\"text\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"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},\"cache_control\":{\"type\":\"ephemeral\"}}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"store\":false,\"max_completion_tokens\":128}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"Paris\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840198,\"id\":\"chatcmpl-3914ee28-f256-4a49-994f-49365922a7a6\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" is sunny.\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840198,\"id\":\"chatcmpl-3914ee28-f256-4a49-994f-49365922a7a6\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1790840198,\"id\":\"chatcmpl-3914ee28-f256-4a49-994f-49365922a7a6\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840198,\"id\":\"chatcmpl-3914ee28-f256-4a49-994f-49365922a7a6\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":0,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":0},\"cache_read_input_tokens\":0,\"completion_tokens\":7,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":668,\"prompt_tokens_details\":{\"cached_tokens\":0},\"speed\":null,\"total_tokens\":675}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "anthropic-claude-haiku-4.5",
|
||||
"tags": [
|
||||
"prefix:digitalocean-chat",
|
||||
"provider:digitalocean",
|
||||
"protocol:digitalocean-chat",
|
||||
"cache",
|
||||
"usage"
|
||||
],
|
||||
"name": "digitalocean-chat/haiku-reuses-a-cached-prompt",
|
||||
"recordedAt": "2026-10-01T07:36:36.689Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://inference.do-ai.run/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic-claude-haiku-4.5\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and aLine truncated
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"OK\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840195,\"id\":\"chatcmpl-94f208e5-82fa-4ec6-b014-1b4ea9fbb98c\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1790840195,\"id\":\"chatcmpl-94f208e5-82fa-4ec6-b014-1b4ea9fbb98c\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840195,\"id\":\"chatcmpl-94f208e5-82fa-4ec6-b014-1b4ea9fbb98c\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":5759,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":5759},\"cache_read_input_tokens\":0,\"completion_tokens\":4,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":5762,\"prompt_tokens_details\":{\"cached_tokens\":0},\"speed\":null,\"total_tokens\":5766}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://inference.do-ai.run/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic-claude-haiku-4.5\",\"messages\":[{\"role\":\"system\",\"content\":[{\"type\":\"text\",\"text\":\"You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and avoid filler. Cite numbers when known. You are a concise, factual assistant. Answer precisely and aLine truncated
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"OK\",\"role\":\"assistant\"},\"finish_reason\":null,\"index\":0,\"logprobs\":null}],\"created\":1790840196,\"id\":\"chatcmpl-eee2d578-6fab-4fc1-8117-043193225bed\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\",\"index\":0,\"logprobs\":null}],\"created\":1790840196,\"id\":\"chatcmpl-eee2d578-6fab-4fc1-8117-043193225bed\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\"}\n\ndata: {\"choices\":[],\"created\":1790840196,\"id\":\"chatcmpl-eee2d578-6fab-4fc1-8117-043193225bed\",\"model\":\"anthropic-claude-haiku-4.5\",\"object\":\"chat.completion.chunk\",\"usage\":{\"cache_created_input_tokens\":0,\"cache_creation\":{\"ephemeral_1h_input_tokens\":0,\"ephemeral_5m_input_tokens\":0},\"cache_read_input_tokens\":5759,\"completion_tokens\":4,\"completion_tokens_details\":{\"reasoning_tokens\":0},\"prompt_tokens\":5762,\"prompt_tokens_details\":{\"cached_tokens\":5759},\"speed\":null,\"total_tokens\":5766}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { HttpOptions, LLM, mergeHttpOptions } from "../src/index.js"
|
||||
import { LLMClient } from "../src/route.js"
|
||||
import { configure } from "../src/providers/openai.js"
|
||||
import { dynamicResponse } from "./lib/http.js"
|
||||
import { deltaChunk, finishChunk } from "./lib/openai-chunks.js"
|
||||
import { sseEvents, sseRaw } from "./lib/sse.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
|
||||
const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4.1-mini")
|
||||
const SSE = { headers: { "content-type": "text/event-stream" } }
|
||||
|
||||
const silentServer = dynamicResponse(() => Effect.never)
|
||||
|
||||
const slowHeadersServer = dynamicResponse((input) =>
|
||||
Effect.sleep("10 minutes").pipe(
|
||||
Effect.as(input.respond(sseEvents(deltaChunk({ role: "assistant", content: "Hi" }), finishChunk("stop")), SSE)),
|
||||
),
|
||||
)
|
||||
|
||||
// Sends one chunk, then stalls until `resume` releases the rest of the body.
|
||||
const stalledServer = Effect.gen(function* () {
|
||||
const stalled = yield* Deferred.make<void>()
|
||||
let resume = () => {}
|
||||
const released = new Promise<void>((resolve) => {
|
||||
resume = resolve
|
||||
})
|
||||
const encoder = new TextEncoder()
|
||||
const layer = dynamicResponse((input) =>
|
||||
Effect.sync(() =>
|
||||
input.respond(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(sseRaw(`data: ${JSON.stringify(deltaChunk({ content: "Hi" }))}`)))
|
||||
},
|
||||
async pull(controller) {
|
||||
Deferred.doneUnsafe(stalled, Effect.void)
|
||||
await released
|
||||
controller.enqueue(encoder.encode(sseEvents(finishChunk("stop"))))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
SSE,
|
||||
),
|
||||
),
|
||||
)
|
||||
return { layer, stalled, resume: () => resume() }
|
||||
})
|
||||
|
||||
describe("HTTP transport timeouts", () => {
|
||||
it.effect("fails when response headers take longer than five minutes", () =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(silentServer),
|
||||
Effect.flip,
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* TestClock.adjust("5 minutes")
|
||||
const error = yield* Fiber.join(fiber)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
transport: "http",
|
||||
operation: "request",
|
||||
code: "Timeout",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails when the response body stalls for five minutes", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* stalledServer
|
||||
const fiber = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(server.layer),
|
||||
Effect.flip,
|
||||
Effect.forkChild({ startImmediately: true }),
|
||||
)
|
||||
yield* Deferred.await(server.stalled)
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("5 minutes")
|
||||
const error = yield* Fiber.join(fiber)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", transport: "http", operation: "read", code: "Timeout" })
|
||||
expect(error.reason.http).toMatchObject({ status: 200 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies a configured header timeout", () =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* LLMClient.generate(
|
||||
LLM.request({ model, prompt: "Hello", http: { headerTimeout: 1_000 } }),
|
||||
).pipe(Effect.provide(silentServer), Effect.flip, Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("1 second")
|
||||
const error = yield* Fiber.join(fiber)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", operation: "request", code: "Timeout" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables the header timeout with false", () =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* LLMClient.generate(
|
||||
LLM.request({ model, prompt: "Hello", http: { headerTimeout: false } }),
|
||||
).pipe(Effect.provide(slowHeadersServer), Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
const response = yield* Fiber.join(fiber)
|
||||
|
||||
expect(response.text).toBe("Hi")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables the chunk timeout with false", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* stalledServer
|
||||
const fiber = yield* LLMClient.generate(
|
||||
LLM.request({ model, prompt: "Hello", http: { chunkTimeout: false } }),
|
||||
).pipe(Effect.provide(server.layer), Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(server.stalled)
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("10 minutes")
|
||||
server.resume()
|
||||
const response = yield* Fiber.join(fiber)
|
||||
|
||||
expect(response.text).toBe("Hi")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies a whole-request timeout while the body is still streaming", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* stalledServer
|
||||
const fiber = yield* LLMClient.generate(
|
||||
LLM.request({ model, prompt: "Hello", http: { timeout: 60_000, chunkTimeout: false } }),
|
||||
).pipe(Effect.provide(server.layer), Effect.flip, Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(server.stalled)
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("1 minute")
|
||||
const error = yield* Fiber.join(fiber)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", operation: "read", code: "Timeout" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("bounds the header wait by a shorter whole-request timeout", () =>
|
||||
Effect.gen(function* () {
|
||||
const fiber = yield* LLMClient.generate(
|
||||
LLM.request({ model, prompt: "Hello", http: { timeout: 1_000, headerTimeout: false } }),
|
||||
).pipe(Effect.provide(silentServer), Effect.flip, Effect.forkChild({ startImmediately: true }))
|
||||
yield* TestClock.adjust("1 second")
|
||||
const error = yield* Fiber.join(fiber)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", operation: "request", code: "Timeout" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("merges timeouts with later values winning", () =>
|
||||
Effect.sync(() => {
|
||||
const merged = mergeHttpOptions(
|
||||
new HttpOptions({ timeout: 5_000, headerTimeout: 1_000, chunkTimeout: 2_000 }),
|
||||
new HttpOptions({ headers: { a: "b" } }),
|
||||
new HttpOptions({ chunkTimeout: false }),
|
||||
)
|
||||
|
||||
expect(merged).toMatchObject({ headers: { a: "b" }, timeout: 5_000, headerTimeout: 1_000, chunkTimeout: false })
|
||||
expect(mergeHttpOptions(new HttpOptions({}), undefined)).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -15,7 +15,6 @@ describe("provider error classification", () => {
|
||||
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
|
||||
"Range of input length should be [1, 129024]",
|
||||
"Too many tokens",
|
||||
"Input validation error: `inputs` tokens + `max_new_tokens` must be <= 131073. Given: 600035 `inputs` tokens and 16 `max_new_tokens`",
|
||||
"Token limit exceeded",
|
||||
]
|
||||
|
||||
|
||||
@@ -314,9 +314,6 @@ describe("Anthropic Messages route", () => {
|
||||
"claude-haiku-5-1",
|
||||
"claude-fable-6",
|
||||
"anthropic/claude-mythos-7.2",
|
||||
"claude-sonnet-5-5",
|
||||
"claude-opus-4-8@20260101",
|
||||
"claude-nova-6",
|
||||
]
|
||||
|
||||
const prepared = yield* Effect.forEach(ids, (id) =>
|
||||
@@ -1676,40 +1673,6 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("carries a refusal's category and explanation on the content-filter finish", () =>
|
||||
Effect.gen(function* () {
|
||||
const refusal = (stop_details: unknown) =>
|
||||
LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "message_delta", delta: { stop_reason: "refusal", stop_details }, usage: { output_tokens: 0 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(
|
||||
(yield* refusal({
|
||||
type: "refusal",
|
||||
category: "cyber",
|
||||
explanation: "This request was declined because it could enable cyber harm.",
|
||||
})).finishReason,
|
||||
).toEqual({
|
||||
normalized: "content-filter",
|
||||
raw: "refusal",
|
||||
category: "cyber",
|
||||
explanation: "This request was declined because it could enable cyber harm.",
|
||||
})
|
||||
expect((yield* refusal({ type: "refusal", category: null, explanation: null })).finishReason).toEqual({
|
||||
normalized: "content-filter",
|
||||
raw: "refusal",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles streamed tool call input", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -648,177 +648,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("hoists tool-result images beside the result for Bedrock GPT models", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer" }).model(
|
||||
"global.openai.gpt-6-sol",
|
||||
),
|
||||
messages: [
|
||||
Message.user("What is in this image?"),
|
||||
Message.assistant([ToolCallPart.make({ id: "tool_1", name: "read", input: {} })]),
|
||||
Message.tool({
|
||||
id: "tool_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Image loaded." },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" },
|
||||
{ type: "file", uri: "data:application/pdf;base64,QkI=", mime: "application/pdf", name: "note.pdf" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages[2]).toEqual({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: "tool_1",
|
||||
content: [
|
||||
{ text: "Image loaded." },
|
||||
{ text: 'Attached file "note.pdf" has document label "note".' },
|
||||
{ document: { format: "pdf", name: "note", source: { bytes: "QkI=" } } },
|
||||
],
|
||||
status: "success",
|
||||
},
|
||||
},
|
||||
{ image: { format: "png", source: { bytes: "AAAA" } } },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("hoists tool-result images for other Bedrock model families", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const id of [
|
||||
"qwen.qwen3-vl-235b-a22b",
|
||||
"global.xai.grok-4.7",
|
||||
"global.moonshotai.kimi-k3",
|
||||
"us.meta.llama4-scout-17b-instruct-v1:0",
|
||||
]) {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer" }).model(
|
||||
id,
|
||||
),
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "tool_1", name: "read", input: {} })]),
|
||||
Message.tool({
|
||||
id: "tool_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages[1]).toEqual({
|
||||
role: "user",
|
||||
content: [
|
||||
{ toolResult: { toolUseId: "tool_1", content: [{ text: "See attached image." }], status: "success" } },
|
||||
{ image: { format: "png", source: { bytes: "AAAA" } } },
|
||||
],
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
;["global.anthropic.claude-sonnet-4-5-20250929-v1:0", "us.amazon.nova-pro-v1:0"].forEach((id) => {
|
||||
it.effect(`keeps ${id} tool images inside the result`, () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer" }).model(
|
||||
id,
|
||||
),
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "tool_1", name: "read", input: {} })]),
|
||||
Message.tool({
|
||||
id: "tool_1",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages[1]).toEqual({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
toolResult: {
|
||||
toolUseId: "tool_1",
|
||||
content: [{ image: { format: "png", source: { bytes: "AAAA" } } }],
|
||||
status: "success",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("keeps parallel tool results before hoisted images and gives image-only results text", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer" }).model(
|
||||
"global.openai.gpt-6-sol",
|
||||
),
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "tool_1", name: "first", input: {} }),
|
||||
ToolCallPart.make({ id: "tool_2", name: "second", input: {} }),
|
||||
]),
|
||||
Message.tool({
|
||||
id: "tool_1",
|
||||
name: "first",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
|
||||
},
|
||||
}),
|
||||
Message.tool({
|
||||
id: "tool_2",
|
||||
name: "second",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Second image." },
|
||||
{ type: "file", uri: "data:image/jpeg;base64,BBBB", mime: "image/jpeg" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages[1]).toEqual({
|
||||
role: "user",
|
||||
content: [
|
||||
{ toolResult: { toolUseId: "tool_1", content: [{ text: "See attached image." }], status: "success" } },
|
||||
{ toolResult: { toolUseId: "tool_2", content: [{ text: "Second image." }], status: "success" } },
|
||||
{ image: { format: "png", source: { bytes: "AAAA" } } },
|
||||
{ image: { format: "jpeg", source: { bytes: "BBBB" } } },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes text-delta + messageStop + metadata usage from binary event stream", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMRequest } from "../../src/index.js"
|
||||
import { DigitalOcean } from "../../src/providers/digitalocean.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import {
|
||||
LARGE_CACHEABLE_SYSTEM,
|
||||
expectWeatherToolLoop,
|
||||
goldenWeatherToolLoopRequest,
|
||||
runWeatherToolLoop,
|
||||
} from "../recorded-scenarios.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "digitalocean-chat",
|
||||
provider: "digitalocean",
|
||||
protocol: "digitalocean-chat",
|
||||
requires: ["DIGITAL_OCEAN_OFFICIAL_API_KEY"],
|
||||
})
|
||||
|
||||
for (const item of [
|
||||
{ id: "anthropic-claude-haiku-4.5", name: "Haiku", maxTokens: 128, providerOptions: undefined },
|
||||
{ id: "openai-gpt-5-nano", name: "GPT Nano", maxTokens: 1024, providerOptions: { reasoningEffort: "minimal" } },
|
||||
] as const) {
|
||||
const model = DigitalOcean.configure({
|
||||
apiKey: process.env.DIGITAL_OCEAN_OFFICIAL_API_KEY ?? "fixture",
|
||||
providerOptions: item.providerOptions,
|
||||
}).model(item.id)
|
||||
|
||||
describe(`DigitalOcean ${item.name} recorded`, () => {
|
||||
recorded.effect.with(
|
||||
`${item.name} reuses a cached prompt`,
|
||||
{ tags: ["cache", "usage"], metadata: { model: item.id } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: LARGE_CACHEABLE_SYSTEM,
|
||||
prompt: "Reply exactly: OK",
|
||||
promptCacheKey: `digitalocean-recorded-${item.id}`,
|
||||
generation: { maxTokens: item.maxTokens },
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
const second = yield* LLMClient.generate(request)
|
||||
|
||||
expect(first.text.trim()).toMatch(/^OK\.?$/)
|
||||
expect(second.text.trim()).toMatch(/^OK\.?$/)
|
||||
expect(second.usage.cacheReadInputTokens).toBeGreaterThan(0)
|
||||
for (const response of [first, second]) {
|
||||
expect(response.usage.inputTokens).toBeGreaterThan(4096)
|
||||
expect(response.usage.inputTokens).toBe(
|
||||
(response.usage.nonCachedInputTokens ?? 0) +
|
||||
(response.usage.cacheReadInputTokens ?? 0) +
|
||||
(response.usage.cacheWriteInputTokens ?? 0),
|
||||
)
|
||||
}
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
recorded.effect.with(
|
||||
`${item.name} continues a tool call with cache markers`,
|
||||
{ tags: ["cache", "tool", "tool-loop"], metadata: { model: item.id } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = goldenWeatherToolLoopRequest({
|
||||
id: `digitalocean-${item.id}-tool-loop`,
|
||||
model,
|
||||
maxTokens: item.maxTokens,
|
||||
temperature: false,
|
||||
})
|
||||
const events = yield* runWeatherToolLoop(LLMRequest.update(request, { cache: "auto" }))
|
||||
expectWeatherToolLoop(events)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM } from "../../src/index.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { DigitalOcean } from "../../src/providers/digitalocean.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
|
||||
describe("DigitalOcean", () => {
|
||||
test("preserves package entrypoint headers and body overrides", () => {
|
||||
const model = DigitalOcean.model("anthropic-claude-fable-5.1", {
|
||||
apiKey: "test-key",
|
||||
headers: { "X-Test": "fixture" },
|
||||
body: { temperature: 0 },
|
||||
})
|
||||
expect(model.route.defaults?.http).toMatchObject({
|
||||
headers: { "X-Test": "fixture" },
|
||||
body: { temperature: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("prepares DigitalOcean models with default endpoint and auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = DigitalOcean.configure({ apiKey: "test-key" }).model("anthropic-claude-fable-5.1")
|
||||
|
||||
expect(model).toMatchObject({
|
||||
id: "anthropic-claude-fable-5.1",
|
||||
provider: "digitalocean",
|
||||
route: { id: "digitalocean" },
|
||||
})
|
||||
expect(model.route.endpoint.baseURL).toBe("https://inference.do-ai.run/v1")
|
||||
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Say hello.", cache: "none" }))
|
||||
|
||||
expect(prepared.route).toBe("digitalocean")
|
||||
expect(prepared.body).toMatchObject({
|
||||
model: "anthropic-claude-fable-5.1",
|
||||
messages: [{ role: "user", content: "Say hello." }],
|
||||
stream: true,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers the native cache policy to DigitalOcean cache_control markers", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: DigitalOcean.configure({ apiKey: "test-key" }).model("anthropic-claude-fable-5.1"),
|
||||
system: [
|
||||
{ type: "text", text: "Base agent", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }) },
|
||||
{ type: "text", text: "Project instructions" },
|
||||
],
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "Hello",
|
||||
cache: { tools: true, system: true, messages: { tail: 1 } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ cache_control: { type: "ephemeral" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
{ text: "Base agent", cache_control: { type: "ephemeral", ttl: "1h" } },
|
||||
{ text: "Project instructions", cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ text: "Hello", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses DigitalOcean cache usage fields into AI.Usage", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = DigitalOcean.configure({ apiKey: "test-key" }).model("anthropic-claude-fable-5.1")
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say OK" })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
id: "chatcmpl-1",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "anthropic-claude-fable-5.1",
|
||||
choices: [{ index: 0, delta: { content: "OK" }, finish_reason: null }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-1",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "anthropic-claude-fable-5.1",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
usage: {
|
||||
prompt_tokens: 4491,
|
||||
completion_tokens: 8,
|
||||
total_tokens: 4499,
|
||||
cache_read_input_tokens: 4483,
|
||||
cache_created_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
"[DONE]",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 4491,
|
||||
outputTokens: 8,
|
||||
nonCachedInputTokens: 8,
|
||||
cacheReadInputTokens: 4483,
|
||||
totalTokens: 4499,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -302,7 +302,6 @@ describe("Google Vertex providers", () => {
|
||||
|
||||
expect(model.provider).toBe("google-vertex")
|
||||
expect(response.text).toBe("Hello.")
|
||||
expect(response.usage?.providerMetadata).toHaveProperty("vertex")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -171,7 +171,6 @@ describe("Groq recorded", () => {
|
||||
expect(response.text).not.toContain("<think>")
|
||||
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
expect(response.usage?.providerMetadata).toHaveProperty("groq")
|
||||
expectUsage(response)
|
||||
}),
|
||||
60_000,
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("native OpenAI-compatible providers", () => {
|
||||
[GoogleVertex.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexChat.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexResponses.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexMessages.configure(vertex).model("model"), "vertex"],
|
||||
[GoogleVertexMessages.configure(vertex).model("model"), "anthropic"],
|
||||
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
|
||||
[
|
||||
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
|
||||
@@ -64,7 +64,6 @@ describe("native OpenAI-compatible providers", () => {
|
||||
[DeepSeek.configure({ apiKey: "test" }).model("model"), "deepseek"],
|
||||
[Fireworks.configure({ apiKey: "test" }).model("model"), "fireworks"],
|
||||
[DeepInfra.configure({ apiKey: "test" }).model("model"), "deepinfra"],
|
||||
[Groq.configure({ apiKey: "test" }).model("model"), "groq"],
|
||||
[TogetherAI.configure({ apiKey: "test" }).model("model"), "togetherai"],
|
||||
[CloudflareAIGateway.configure({ accountId: "account" }).model("model"), "cloudflare-ai-gateway"],
|
||||
[CloudflareWorkersAI.configure({ accountId: "account" }).model("model"), "cloudflare-workers-ai"],
|
||||
|
||||
@@ -2205,7 +2205,7 @@ describe("OpenAI Chat route", () => {
|
||||
)
|
||||
|
||||
expect((yield* Ref.get(events)).some((event) => event.type === "text-delta")).toBeTrue()
|
||||
expect(error.message).toBe("Connection lost while reading the response: ECONNRESET: socket closed unexpectedly")
|
||||
expect(error.message).toBe("ECONNRESET: socket closed unexpectedly")
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
transport: "http",
|
||||
@@ -2223,7 +2223,7 @@ describe("OpenAI Chat route", () => {
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.message).toBe("Connection lost while reading the response: ECONNRESET: socket closed before output")
|
||||
expect(error.message).toBe("ECONNRESET: socket closed before output")
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
transport: "http",
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
const source = (path: string) => `/@fs/${fileURLToPath(new URL(path, import.meta.url)).replaceAll("\\", "/")}`
|
||||
const modules = {
|
||||
fixture: source("../../gui-extensions/src/browser/panel.fixture.tsx"),
|
||||
host: source("../src/runtime/extension/host.tsx"),
|
||||
panels: source("../src/runtime/extension/panels.tsx"),
|
||||
language: source("../src/runtime/i18n/language.tsx"),
|
||||
browser: source("../../gui-extensions/src/browser/index.ts"),
|
||||
browserRenderer: source("../../gui-extensions/src/browser/renderer.tsx"),
|
||||
file: source("../../gui-extensions/src/file/index.ts"),
|
||||
fileRenderer: source("../../gui-extensions/src/file/renderer.tsx"),
|
||||
}
|
||||
|
||||
story(
|
||||
"keeps a restored browser tab selected and undrawn until the desktop's first inventory",
|
||||
async ({ mount, page }) => {
|
||||
// Any story loads the app styles; the fixture mounts the real side region and extensions beside it.
|
||||
await mount("ui-line-comment--editor")
|
||||
await page.evaluate(async (modules) => {
|
||||
const [{ mountBrowserRegion }, host, panels, language, browser, file] = await Promise.all([
|
||||
import(modules.fixture),
|
||||
import(modules.host),
|
||||
import(modules.panels),
|
||||
import(modules.language),
|
||||
import(modules.browser),
|
||||
import(modules.file),
|
||||
])
|
||||
mountBrowserRegion({
|
||||
LanguageProvider: language.LanguageProvider,
|
||||
ExtensionHostProvider: host.ExtensionHostProvider,
|
||||
useExtensionHost: host.useExtensionHost,
|
||||
createRegion: panels.createRegion,
|
||||
definitions: [
|
||||
{ ...browser.default, renderer: () => import(modules.browserRenderer) },
|
||||
{ ...file.default, renderer: () => import(modules.fileRenderer) },
|
||||
],
|
||||
})
|
||||
}, modules)
|
||||
const root = page.getByTestId("browser-region-fixture")
|
||||
const tabs = root.getByRole("tab")
|
||||
const tree = root.getByTestId("tree")
|
||||
await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible()
|
||||
await expect(tabs).toHaveText(["alpha.ts"])
|
||||
await expect(tree).toHaveText('{"tab":"changes"}')
|
||||
|
||||
// Beta was left on its browser tab, which the desktop has not reported yet.
|
||||
await root.getByRole("button", { name: "Beta", exact: true }).click()
|
||||
await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible()
|
||||
await expect(root.getByTestId("selected")).toHaveText(/^browser:tab_/)
|
||||
await expect(tabs).toHaveText(["beta.ts"])
|
||||
// No fallback tab was selected, so the file tab's selection never switched the tree to All files.
|
||||
await expect(tree).toHaveText('{"tab":"changes"}')
|
||||
|
||||
await root.getByRole("button", { name: "First inventory", exact: true }).click()
|
||||
await expect(tabs).toHaveText(["beta.ts", "Preview"])
|
||||
await expect(root.getByRole("tab", { name: "Preview", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(tree).toHaveText('{"tab":"changes"}')
|
||||
},
|
||||
)
|
||||
@@ -183,35 +183,6 @@ test("keeps a narrow session composer contained when invoking a built-in", async
|
||||
await expect(editor).toHaveText("keep me")
|
||||
})
|
||||
|
||||
test("lists slash commands in their built-in order", async ({ page }) => {
|
||||
const { editor } = await openSession(page, {
|
||||
name: "ComposerSlash",
|
||||
provider: NO_PROVIDER,
|
||||
commands: [
|
||||
{ name: "init", description: "Create AGENTS.md" },
|
||||
{ name: "review", description: "Review changes" },
|
||||
],
|
||||
// A sent message enables /undo, /compact and /fork.
|
||||
pageMessages: () => ({ items: [{ id: "msg_slash", type: "user", text: "Hello", time: { created: T0 } }] }),
|
||||
})
|
||||
await editor.fill("/")
|
||||
await expect(page.locator('[data-component="composer-suggestions"] [data-suggestion-id] bdi')).toHaveText([
|
||||
"/init",
|
||||
"/review",
|
||||
"/new",
|
||||
"/undo",
|
||||
"/compact",
|
||||
"/fork",
|
||||
"/export",
|
||||
"/open",
|
||||
"/terminal",
|
||||
"/mcp",
|
||||
"/model",
|
||||
"/connect",
|
||||
"/btw",
|
||||
])
|
||||
})
|
||||
|
||||
const followUp = "Add follow-up, / for commands, @ for context…"
|
||||
for (const row of [
|
||||
{ state: "an idle", copy: "Ask anything, / for commands, @ for context…" },
|
||||
|
||||
@@ -37,17 +37,6 @@ test("mobile files browse, comment, search, and close tabs", async ({ page }) =>
|
||||
await expect.poll(() => fullWidth(comment)).toBe(true)
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
// Its composer chip reveals the comment without leaving the conversation.
|
||||
const session = navigation.getByRole("tab", { name: "Session", exact: true })
|
||||
await session.click()
|
||||
await page
|
||||
.locator('[data-component="composer-attachments"]')
|
||||
.getByText("Full-width file comment", { exact: true })
|
||||
.click()
|
||||
await expect(session).toHaveAttribute("aria-selected", "true")
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible()
|
||||
await navigation.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
await expect(files.getByText("contents:first.ts", { exact: true })).toBeVisible()
|
||||
const filter = files.getByRole("combobox", { name: "Filter files", exact: true })
|
||||
await expect(filter).toBeHidden()
|
||||
await files.getByRole("button", { name: "All files", exact: true }).click()
|
||||
|
||||
@@ -221,54 +221,6 @@ test("the summary's changes row switches the view and keeps the side tabs", asyn
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await expect(readme).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
// After a reload forgets the preview, a stored Open file tab is still the slot a narrow-screen file open replaces.
|
||||
const launcher = panel.getByRole("tab", { name: "Open file", exact: true })
|
||||
await panel.getByRole("button", { name: "Open file", exact: true }).click()
|
||||
await expect(launcher).toHaveAttribute("aria-selected", "true")
|
||||
await expect(readme).toHaveCount(0)
|
||||
await page.reload()
|
||||
await expect(launcher).toHaveAttribute("aria-selected", "true")
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await tabs.getByRole("tab", { name: "Files", exact: true }).click()
|
||||
const files = page.locator('[data-slot="session-mobile-files"]')
|
||||
await files.getByRole("button", { name: "README.md", exact: true }).click()
|
||||
await expect(files.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await expect(readme).toHaveAttribute("aria-selected", "true")
|
||||
await expect(launcher).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("a narrow-screen palette pick keeps the view and dock, and the side region shows the file when wide", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page, {
|
||||
fileList: (path) => (path ? [] : [fileNode(fixture.directory, "README.md")]),
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
findFiles: ({ query }) => ("README.md".includes(query) ? [fileNode(fixture.directory, "README.md")] : []),
|
||||
pty: {},
|
||||
})
|
||||
await openStress(page, { lastProject: { local: fixture.directory } })
|
||||
await page.goto(sessionHref(fixture.targetID))
|
||||
|
||||
const tabs = page.getByRole("tablist", { name: "Session view", exact: true })
|
||||
const terminal = page.locator("#terminal-panel")
|
||||
await tabs.getByRole("tab", { name: "Terminal", exact: true }).click()
|
||||
await expect(terminal.locator("textarea")).toBeEditable()
|
||||
await page.keyboard.press("ControlOrMeta+p")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("textbox").fill("README")
|
||||
await dialog.getByRole("option", { name: "/ README.md", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(tabs.getByRole("tab", { selected: true })).toHaveText("Terminal")
|
||||
await expect(terminal).toHaveAttribute("data-opened", "true")
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await expect(terminal).toHaveAttribute("data-opened", "true")
|
||||
const panel = page.locator("#review-panel")
|
||||
await expect(panel.getByRole("tab", { name: "README.md", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test.describe("touch", () => {
|
||||
|
||||
@@ -90,7 +90,6 @@ test("settings opened from a remote session sweep every server and keep the remo
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveCount(0)
|
||||
await view.listed(REMOTE_SERVER, directoryB)
|
||||
await expect.poll(() => view.replies).toEqual([reply(sessionA.id, "permission-pending-a")])
|
||||
|
||||
@@ -113,7 +112,6 @@ test("settings opened from a remote session sweep every server and keep the remo
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
|
||||
await page.keyboard.press("Control+]")
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("Escape")
|
||||
await expectPath(page, sessionHref(sessionB.id, REMOTE_SERVER))
|
||||
await expectSessionTitle(page, sessionB.title)
|
||||
|
||||
@@ -220,16 +220,13 @@ for (const scenario of scenarios) {
|
||||
const top = sessionTop + (group?.getBoundingClientRect().height ?? 0)
|
||||
const rect = element.getBoundingClientRect()
|
||||
const trigger = element.querySelector("button")!
|
||||
// The header stuck directly below the session title sits where the title's fade starts.
|
||||
const hits = (target: Element, box: DOMRect) =>
|
||||
[box.top + 1, box.top + box.height / 2].every((y) =>
|
||||
target.contains(document.elementFromPoint(box.left + box.width / 2, y)),
|
||||
)
|
||||
return {
|
||||
gap: Math.abs(rect.top - top - (toolTitle?.getBoundingClientRect().height ?? 0)),
|
||||
groupGap: group ? Math.abs(group.getBoundingClientRect().top - sessionTop) : 0,
|
||||
titleGap: toolTitle ? Math.abs(toolTitle.getBoundingClientRect().top - top) : 0,
|
||||
clickable: hits(trigger, rect) && (!group || hits(group, group.getBoundingClientRect())),
|
||||
clickable: trigger.contains(
|
||||
document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2),
|
||||
),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { SERVER, project, provider, session, workspaceKey } from "../utils/app"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { workspaceKey } from "../utils/app"
|
||||
import { fileDiff, fileNode, openSession } from "../utils/workspace"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -533,29 +532,6 @@ test("restores review state and the side-panel tab per session", async ({ page }
|
||||
await expect(review).toHaveAttribute("aria-selected", "true")
|
||||
})
|
||||
|
||||
test("shows and restores last turn changes from the session diff", async ({ page }) => {
|
||||
const sessionID = "ses_reviewturn"
|
||||
await openSession(page, { name: "ReviewTurn", vcsDiff: [fileDiff("src/alpha.ts")] })
|
||||
await page.route(`**/api/session/${sessionID}/diff**`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ data: [fileDiff("src/delta.ts")] }),
|
||||
}),
|
||||
)
|
||||
const panel = page.locator("#review-panel")
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await page.getByRole("button", { name: "Git changes" }).click()
|
||||
await page.getByRole("option", { name: "Last turn changes" }).click()
|
||||
await expect(page.getByRole("button", { name: "Last turn changes" })).toBeVisible()
|
||||
await expect(panel.locator('[data-slot="session-review-v2-file-name"]')).toHaveText("delta.ts")
|
||||
|
||||
await page.reload()
|
||||
await expectSessionTitle(page, "ReviewTurn")
|
||||
await expect(page.getByRole("button", { name: "Last turn changes" })).toBeVisible()
|
||||
await expect(panel.locator('[data-slot="session-review-v2-file-name"]')).toHaveText("delta.ts")
|
||||
})
|
||||
|
||||
test("keeps the review state a session stored before extensions", async ({ page }) => {
|
||||
const directory = "C:/OpenCode/ReviewLegacy"
|
||||
await openSession(page, {
|
||||
@@ -604,65 +580,6 @@ test("keeps the review state a session stored before extensions", async ({ page
|
||||
await expect(trigger("gamma.ts")).toBeVisible()
|
||||
})
|
||||
|
||||
test("desktop review waits for the session's stored mode before it loads changes", async ({ page }) => {
|
||||
const directory = "C:/OpenCode/ReviewDesktop"
|
||||
const id = "ses_review_desktop"
|
||||
const title = "Desktop review"
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project({ id: "proj_review_desktop", directory }),
|
||||
provider: provider(),
|
||||
sessions: [session({ id, directory, title })],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
vcs: { current: "feature", default: "dev" },
|
||||
vcsDiff: ({ mode }) => (mode === "branch" ? [fileDiff("beta.ts")] : [fileDiff("gamma.ts")]),
|
||||
})
|
||||
const working: string[] = []
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url())
|
||||
if (url.pathname.endsWith("/vcs/diff") && url.searchParams.get("mode") === "working") working.push(request.url())
|
||||
})
|
||||
const desktop = (hold?: string) =>
|
||||
`/e2e/utils/settings-wsl.html?${new URLSearchParams({
|
||||
server: SERVER,
|
||||
mode: "stopped",
|
||||
storage: "async",
|
||||
path: `/server/${base64Encode("sidecar")}/session/${id}`,
|
||||
...(hold && { hold }),
|
||||
})}`
|
||||
const mode = (name: string) => page.getByRole("button", { name, exact: true })
|
||||
const file = page.locator('[data-slot="session-review-v2-file-name"]')
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
|
||||
await page.goto(desktop())
|
||||
await expectSessionTitle(page, title)
|
||||
await toggle.click()
|
||||
await mode("Git changes").click()
|
||||
await page.getByRole("option", { name: "Branch changes" }).click()
|
||||
await expect(file).toHaveText("beta.ts")
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
Object.entries(localStorage).some(
|
||||
([key, value]) => key.includes("extension.review.session") && value.includes('"branch"'),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
// The reopened window's review mounts before its session store has loaded.
|
||||
working.length = 0
|
||||
await page.goto(desktop("extension.review.session"))
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(page.locator("#review-panel").getByText("Loading changes…", { exact: true })).toBeVisible()
|
||||
await expect(mode("Git changes")).toHaveCount(0)
|
||||
await page.getByRole("button", { name: "Load held storage" }).click()
|
||||
await expect(mode("Branch changes")).toBeVisible()
|
||||
await expect(file).toHaveText("beta.ts")
|
||||
expect(working).toEqual([])
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`review toggle stays at the header edge in ${direction}`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1000, height: 900 })
|
||||
|
||||
@@ -25,7 +25,7 @@ for (const custom of [false, true]) {
|
||||
await expect(tooltip.locator('[data-slot="keybind-v2-label"]')).toHaveText(
|
||||
custom ? ["F8"] : mac ? ["⇧", "⌘", "Y"] : ["Ctrl", "Shift", "Y"],
|
||||
)
|
||||
for (const id of [fixture.sourceID, fixture.targetID, fixture.sourceID]) {
|
||||
for (const id of [fixture.targetID, fixture.sourceID]) {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(id)}"]`).click()
|
||||
const messages = id === fixture.sourceID ? fixture.expected.sourceMessageIDs : fixture.expected.targetMessageIDs
|
||||
await expect(
|
||||
|
||||
@@ -43,27 +43,6 @@ for (const mode of ["failed", "stopped", "ready"] as const) {
|
||||
})
|
||||
}
|
||||
|
||||
test("adding a WSL server while the WSL extension is down shows that WSL is unavailable", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: "/repo",
|
||||
project: project({ id: "proj_wsl_unavailable", directory: "/repo", name: "WSL project" }),
|
||||
provider: NO_PROVIDER,
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready" })}`)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(1)
|
||||
await page.getByRole("checkbox", { name: "WSL extension" }).uncheck()
|
||||
|
||||
await settings.getByRole("button", { name: "Add server", exact: true }).press("Enter")
|
||||
await page.getByRole("menuitem", { name: "Add WSL server", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("heading", { name: "WSL unavailable", exact: true })).toBeVisible()
|
||||
await expect(dialog.getByText("OpenCode could not verify WSL on this machine.", { exact: true })).toBeVisible()
|
||||
await expect(dialog.getByText("WSL is unavailable", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("an open session's terminal follows its WSL server to the endpoint it restarts on", async ({ page }) => {
|
||||
const restarted = "http://127.0.0.1:4098"
|
||||
const directory = "/home/ubuntu/project"
|
||||
@@ -152,50 +131,6 @@ test("WSL session and draft tabs outlive the extension going away until the serv
|
||||
await expect(tabs).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("an SSH host that asks for sign-in again opens the dialog once per selected tab", async ({ page }) => {
|
||||
const directory = "/home/box/project"
|
||||
const box = session({ id: "ses_ssh_offer", directory, title: "SSH offer session" })
|
||||
const config = {
|
||||
directory,
|
||||
project: project({ id: "proj_ssh_offer", directory }),
|
||||
provider: provider(),
|
||||
sessions: [box],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
}
|
||||
await mockServers(page, { [SERVER]: { ...config, sessions: [] }, [REMOTE_SERVER]: config })
|
||||
const path = `/server/${base64Encode("ssh:box")}/session/${box.id}`
|
||||
await page.goto(
|
||||
`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server: SERVER, mode: "ready", ssh: REMOTE_SERVER, path })}`,
|
||||
)
|
||||
await expectSessionTitle(page, box.title)
|
||||
const connects = page.getByLabel("SSH connects")
|
||||
const signIn = page.getByRole("button", { name: "Require SSH sign-in" })
|
||||
const authenticate = page.getByRole("button", { name: "Authenticate", exact: true })
|
||||
|
||||
// The first request on the tab opens sign-in by itself; the fixture's host connects without a prompt.
|
||||
await signIn.click()
|
||||
await expect(connects).toHaveText("1")
|
||||
await expectSessionTitle(page, box.title)
|
||||
// A later request on the same tab only offers the button.
|
||||
await signIn.click()
|
||||
await expect(authenticate).toBeVisible()
|
||||
await expect(connects).toHaveText("1")
|
||||
await authenticate.click()
|
||||
await expect(connects).toHaveText("2")
|
||||
await expectSessionTitle(page, box.title)
|
||||
|
||||
// Another tab selected and this one selected again is a new selection, even while the host was ready.
|
||||
const tabs = page.locator("a[data-titlebar-tab-link]")
|
||||
await page.getByRole("button", { name: "New session", exact: true }).click()
|
||||
await expect(tabs).toHaveCount(2)
|
||||
await expect(tabs.nth(1)).toHaveAttribute("href", /^\/new-session\?draftId=/)
|
||||
await expect(page.getByRole("heading", { name: box.title })).toHaveCount(0)
|
||||
await tabs.nth(0).click()
|
||||
await expectSessionTitle(page, box.title)
|
||||
await signIn.click()
|
||||
await expect(connects).toHaveText("3")
|
||||
})
|
||||
|
||||
test("an open session moves to the controller a new SSH sign-in creates", async ({ page }) => {
|
||||
const directory = "/home/box/project"
|
||||
const model = { id: "box-model", name: "Box Model" }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { ConfigEntry, OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise"
|
||||
import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, session } from "../utils/app"
|
||||
import { mockRemoteServer, mockWorkspace, openSettings, type WorkspaceInput } from "../utils/workspace"
|
||||
import { mockRemoteServer, openSettings, type WorkspaceInput } from "../utils/workspace"
|
||||
|
||||
const directory = "C:/Projects/settings-demo"
|
||||
const projectID = "proj_settings_demo"
|
||||
@@ -65,20 +65,6 @@ test("settings has its own route and returns through app history", async ({ page
|
||||
await expect(settings).toBeHidden()
|
||||
})
|
||||
|
||||
test("Ctrl+, opens settings as soon as the app loads", async ({ page }) => {
|
||||
await mockWorkspace(page, {
|
||||
name: "Settings demo",
|
||||
directory,
|
||||
provider: NO_PROVIDER,
|
||||
sessions: [],
|
||||
seed: { tabs: [] },
|
||||
})
|
||||
await page.goto("/")
|
||||
await page.keyboard.press("Control+,")
|
||||
await expect(page.getByTestId("settings-screen")).toBeVisible()
|
||||
await expect(page).toHaveURL("/settings")
|
||||
})
|
||||
|
||||
test("a settings page survives refresh", async ({ page }) => {
|
||||
const { settings } = await open(page)
|
||||
const appearance = settings.getByRole("tab", { name: "Appearance", exact: true })
|
||||
|
||||
@@ -10,10 +10,9 @@ import { PlatformProvider, type Platform } from "../../src/runtime/platform/plat
|
||||
import { ServerConnection } from "../../src/runtime/server/registry"
|
||||
import { useExtensionServers } from "../../src/runtime/extension/servers"
|
||||
|
||||
// A desktop window. `wsl` is the Ubuntu server's endpoint (default `server`); updating OpenCode restarts it on `restart`
|
||||
// (default `wsl`). `ssh` adds a saved SSH server `box`, ready on that endpoint with password `ssh-1`; each connect
|
||||
// brings up a new remote server with the next password (`ssh-2`, ...). `storage=async` stores in localStorage behind
|
||||
// asynchronous reads like the desktop app's; reads of keys containing `hold` wait for "Load held storage".
|
||||
// `wsl` is the Ubuntu server's endpoint (default `server`); updating OpenCode restarts it on `restart` (default `wsl`).
|
||||
// `ssh` adds a saved SSH server `box`, ready on that endpoint with password `ssh-1`; each connect brings up a new
|
||||
// remote server with the next password (`ssh-2`, ...).
|
||||
export function mount(input: {
|
||||
server: string
|
||||
mode: "failed" | "stopped" | "ready"
|
||||
@@ -21,8 +20,6 @@ export function mount(input: {
|
||||
restart?: string | null
|
||||
path?: string | null
|
||||
ssh?: string | null
|
||||
storage?: string | null
|
||||
hold?: string | null
|
||||
}) {
|
||||
const root = document.getElementById("root")
|
||||
if (!root) throw new Error("Missing fixture root")
|
||||
@@ -30,28 +27,14 @@ export function mount(input: {
|
||||
history.set({ value: input.path ?? "/settings", replace: true, scroll: false })
|
||||
const endpoint = { url: input.wsl ?? input.server }
|
||||
const ready = () => ({ kind: "ready" as const, url: endpoint.url, password: null })
|
||||
const held = Promise.withResolvers<void>()
|
||||
const storage = (name?: string) => {
|
||||
const item = (key: string) => (name ? `${name}:${key}` : key)
|
||||
return {
|
||||
getItem: async (key: string) => {
|
||||
if (input.hold && key.includes(input.hold)) await held.promise
|
||||
return localStorage.getItem(item(key))
|
||||
},
|
||||
setItem: async (key: string, value: string) => localStorage.setItem(item(key), value),
|
||||
removeItem: async (key: string) => localStorage.removeItem(item(key)),
|
||||
}
|
||||
}
|
||||
render(() => {
|
||||
const [store, setStore] = createStore<{
|
||||
calls: string[]
|
||||
connects: number
|
||||
available: boolean
|
||||
state: WslServersState
|
||||
ssh: SshState
|
||||
}>({
|
||||
calls: [],
|
||||
connects: 0,
|
||||
available: true,
|
||||
ssh: {
|
||||
revision: 0,
|
||||
@@ -112,6 +95,7 @@ export function mount(input: {
|
||||
revision: state.revision + 1,
|
||||
servers: state.servers.map((server) => ({ ...server, ...item })),
|
||||
}))
|
||||
const connects = { count: 1 }
|
||||
const methods: Record<string, Record<string, (input: { id?: string; name?: string }) => unknown>> = {
|
||||
wsl: {
|
||||
// Like main: stops the distro's server, updates OpenCode, then starts the server again on a new endpoint.
|
||||
@@ -142,8 +126,8 @@ export function mount(input: {
|
||||
ssh: {
|
||||
// Like main: a connect opens a new tunnel to a remote server with a new password.
|
||||
start() {
|
||||
setStore("connects", (count) => count + 1)
|
||||
setSsh({ stage: "ready", http: { url: input.ssh ?? "", password: `ssh-${store.connects + 1}` } })
|
||||
connects.count += 1
|
||||
setSsh({ stage: "ready", http: { url: input.ssh ?? "", password: `ssh-${connects.count}` } })
|
||||
return store.ssh.revision
|
||||
},
|
||||
},
|
||||
@@ -191,7 +175,6 @@ export function mount(input: {
|
||||
notify: async () => undefined,
|
||||
restart: unused,
|
||||
extensions: bridge,
|
||||
...(input.storage === "async" ? { storage } : {}),
|
||||
}
|
||||
function Interface() {
|
||||
const extensions = useExtensionServers()
|
||||
@@ -238,22 +221,6 @@ export function mount(input: {
|
||||
>
|
||||
Drop SSH tunnel
|
||||
</button>
|
||||
{/* A background reconnect that needs a password, as main reports it to every window. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSsh({ stage: "authentication" })
|
||||
publish("ssh")
|
||||
}}
|
||||
>
|
||||
Require SSH sign-in
|
||||
</button>
|
||||
<output aria-label="SSH connects">{store.connects}</output>
|
||||
</Show>
|
||||
<Show when={input.hold}>
|
||||
<button type="button" onClick={() => held.resolve()}>
|
||||
Load held storage
|
||||
</button>
|
||||
</Show>
|
||||
<Interface />
|
||||
</AppBaseProviders>
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
restart: query.get("restart"),
|
||||
path: query.get("path"),
|
||||
ssh: query.get("ssh"),
|
||||
storage: query.get("storage"),
|
||||
hold: query.get("hold"),
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -448,8 +448,8 @@ function openComment(
|
||||
})
|
||||
})
|
||||
}
|
||||
// The extension that owns the comment's origin reveals it (the review diff or a file tab), keeping the conversation.
|
||||
links.open({ href: item.path, origin: item.commentOrigin, exact: true, background: true, session })
|
||||
// The extension that owns the comment's origin reveals it (the review diff or a file tab).
|
||||
links.open({ href: item.path, origin: item.commentOrigin, exact: true, session })
|
||||
if (item.commentOrigin === "review") return queueFocus()
|
||||
void Promise.resolve(files.load(item.path)).finally(() => queueFocus())
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { Command } from "@opencode/gui-extensions/sdk"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { useSettings } from "@/settings/model"
|
||||
@@ -10,34 +9,27 @@ export function ExtensionCommands() {
|
||||
const host = useExtensionHost()
|
||||
const command = useCommand()
|
||||
migrateKeybinds(useSettings())
|
||||
// Extensions activate in whatever order their entries load; list them in definition order, then registration order.
|
||||
const rank = createMemo(() => new Map(host.definitions().map((definition, index) => [definition.id, index])))
|
||||
const order = (extension: string) => rank().get(extension) ?? rank().size
|
||||
command.register("extensions", () =>
|
||||
host
|
||||
.items(Command)
|
||||
.toSorted((a, b) => order(a.extension) - order(b.extension))
|
||||
.map((item) => ({
|
||||
id: `${item.extension}.${item.value.id}`,
|
||||
title: item.value.title,
|
||||
description: item.value.description,
|
||||
category: item.value.group,
|
||||
section: item.value.section,
|
||||
keybind: item.value.bind,
|
||||
slash: item.value.slash?.name,
|
||||
slashArguments: item.value.slash?.arguments,
|
||||
slashAfter: item.value.slash?.after,
|
||||
suggested: item.value.suggested,
|
||||
featured: item.value.featured,
|
||||
disabled: item.value.enabled === false,
|
||||
hidden: item.value.hidden,
|
||||
editable: item.value.editable,
|
||||
when: item.value.scope
|
||||
? (event: KeyboardEvent) =>
|
||||
event.target instanceof Element && !!event.target.closest(item.value.scope as string)
|
||||
: undefined,
|
||||
onSelect: (_source: unknown, input?: string) => item.value.run(input),
|
||||
})),
|
||||
host.items(Command).map((item) => ({
|
||||
id: `${item.extension}.${item.value.id}`,
|
||||
title: item.value.title,
|
||||
description: item.value.description,
|
||||
category: item.value.group,
|
||||
section: item.value.section,
|
||||
keybind: item.value.bind,
|
||||
slash: item.value.slash?.name,
|
||||
slashArguments: item.value.slash?.arguments,
|
||||
suggested: item.value.suggested,
|
||||
featured: item.value.featured,
|
||||
disabled: item.value.enabled === false,
|
||||
hidden: item.value.hidden,
|
||||
editable: item.value.editable,
|
||||
when: item.value.scope
|
||||
? (event: KeyboardEvent) =>
|
||||
event.target instanceof Element && !!event.target.closest(item.value.scope as string)
|
||||
: undefined,
|
||||
onSelect: (_source: unknown, input?: string) => item.value.run(input),
|
||||
})),
|
||||
)
|
||||
return null
|
||||
}
|
||||
@@ -137,12 +137,9 @@ export function createRegion(input: { region: Panel["region"]; view: SessionView
|
||||
]
|
||||
})
|
||||
|
||||
// Narrow screens never select a launcher: a stored one falls back like a missing tab.
|
||||
const desktop = createMediaQuery("(min-width: 768px)")
|
||||
const active = createMemo(() => {
|
||||
const value = input.tabs().active()
|
||||
if (value && strip().some((entry) => entry.key === value && (desktop() || entry.tab.kind !== "launcher")))
|
||||
return value
|
||||
if (value && strip().some((entry) => entry.key === value)) return value
|
||||
return strip()
|
||||
.filter((entry) => entry.tab.fallback !== undefined)
|
||||
.reduce<RegionEntry | undefined>(
|
||||
@@ -160,13 +157,10 @@ export function createRegion(input: { region: Panel["region"]; view: SessionView
|
||||
}),
|
||||
)
|
||||
|
||||
// Hidden tabs keep their place in the strip for selection, focus, and close, but draw no trigger.
|
||||
const drawn = createMemo(() => strip().filter((entry) => !entry.tab.hidden))
|
||||
|
||||
return {
|
||||
entries,
|
||||
/** Drawn strip order by key. Renders iterate keys so a provider's fresh tab objects never remount a trigger. */
|
||||
keys: createMemo(() => drawn().map((entry) => entry.key), [], { equals: same }),
|
||||
/** Strip order by key. Renders iterate keys so a provider's fresh tab objects never remount a trigger. */
|
||||
keys: createMemo(() => strip().map((entry) => entry.key), [], { equals: same }),
|
||||
entry: (key: string) => byKey().get(key),
|
||||
active,
|
||||
selected: createMemo(() => {
|
||||
@@ -177,7 +171,7 @@ export function createRegion(input: { region: Panel["region"]; view: SessionView
|
||||
/** An extension's tab ids in the stored strip. */
|
||||
openFor: (extension: string) =>
|
||||
stored().flatMap((key) => (key.startsWith(`${extension}:`) ? [key.slice(extension.length + 1)] : [])),
|
||||
lead: () => !!drawn().find((entry) => entry.tab.kind !== "pinned")?.tab.first,
|
||||
lead: () => !!strip().find((entry) => entry.tab.kind !== "pinned")?.tab.first,
|
||||
select(key: string) {
|
||||
input.tabs().setActive(key)
|
||||
},
|
||||
@@ -225,7 +219,7 @@ export function RegionContent(props: {
|
||||
<div
|
||||
id={member()?.tab.dom?.panel}
|
||||
role="tabpanel"
|
||||
aria-labelledby={active() && !member()?.tab.hidden ? member()?.tab.dom?.tab : undefined}
|
||||
aria-labelledby={active() ? member()?.tab.dom?.tab : undefined}
|
||||
data-slot="tabs-content"
|
||||
class="h-full min-h-0 overflow-hidden"
|
||||
classList={{ hidden: !active() }}
|
||||
@@ -257,7 +251,7 @@ export function RegionContent(props: {
|
||||
<div
|
||||
id={entry()?.tab.dom?.panel}
|
||||
role="tabpanel"
|
||||
aria-labelledby={entry()?.tab.hidden ? undefined : entry()?.tab.dom?.tab}
|
||||
aria-labelledby={entry()?.tab.dom?.tab}
|
||||
tabIndex={entry()?.tab.tabbable ? 0 : undefined}
|
||||
data-slot="tabs-content"
|
||||
class="flex flex-col h-full overflow-hidden contain-strict"
|
||||
|
||||
@@ -81,9 +81,7 @@ export function createExtensionServices() {
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
// The session screen's breakpoint, negated, so no fractional width is both narrow and desktop.
|
||||
const desktop = createMediaQuery("(min-width: 768px)")
|
||||
const narrow = () => !desktop()
|
||||
const narrow = createMediaQuery("(max-width: 767px)")
|
||||
const [attached, setAttached] = createSignal<Attached>()
|
||||
const removed = new Set<(value: { server: string; directory: string }) => void>()
|
||||
const memory = new Map<string, readonly [Store<object>, (mutation: (draft: object) => void) => void]>()
|
||||
@@ -288,11 +286,12 @@ export function createExtensionAttachment(services: ExtensionServices) {
|
||||
const host = useExtensionHost()
|
||||
const command = useCommand()
|
||||
const location = useLocation()
|
||||
const desktop = createMediaQuery("(min-width: 768px)")
|
||||
const narrow = () => !desktop()
|
||||
const narrow = createMediaQuery("(max-width: 767px)")
|
||||
const views = new Map<string, SessionView>()
|
||||
const [mounted, setMounted] = createStore({ revision: 0 })
|
||||
const refs = new Map<string, SessionRef>()
|
||||
// The panel whose toggle opened a session's side region, by session key.
|
||||
const openedFor = new Map<string, string>()
|
||||
|
||||
const connection = (id: string) => global.servers.list().find((item) => ServerConnection.key(item) === id)
|
||||
|
||||
@@ -372,15 +371,11 @@ export function createExtensionAttachment(services: ExtensionServices) {
|
||||
return Array.from(refs.values())
|
||||
})
|
||||
|
||||
const routed = createMemo(() => {
|
||||
const value = route()
|
||||
return value.type === "session" ? `${value.server}\n${value.sessionId}` : undefined
|
||||
})
|
||||
const current = createMemo(() => {
|
||||
const key = routed()
|
||||
if (!key) return
|
||||
const value = route()
|
||||
if (value.type !== "session") return
|
||||
void mounted.revision
|
||||
return views.get(key)
|
||||
return views.get(`${value.server}\n${value.sessionId}`)
|
||||
})
|
||||
|
||||
const scope = (id: string) => {
|
||||
@@ -403,19 +398,13 @@ export function createExtensionAttachment(services: ExtensionServices) {
|
||||
const sideOpened = (session: SessionRef) => !!tabs.pane(shellTab(session), "side")
|
||||
const dockOpened = (session: SessionRef) => !!tabs.pane(shellTab(session), "dock")
|
||||
const setDock = (session: SessionRef, opened: boolean) => tabs.setPane(shellTab(session), "dock", opened)
|
||||
// A token per session whose side region is open, new each time the region opens.
|
||||
const sideVisits = createMemo<ReadonlyMap<string, object>>(
|
||||
(previous) =>
|
||||
new Map(
|
||||
sessions().flatMap((session) =>
|
||||
sideOpened(session) ? [[session.key, previous.get(session.key) ?? {}] as const] : [],
|
||||
),
|
||||
),
|
||||
new Map(),
|
||||
)
|
||||
// The panel whose toggle opened a side region, by the region's token. However the region closes, it reopens with
|
||||
// a new token, so a region reopened any other way belongs to the user.
|
||||
const openedFor = new WeakMap<object, string>()
|
||||
// However the side region closes, it forgets its opener, so a region reopened any other way belongs to the user.
|
||||
createEffect(() => {
|
||||
const open = new Set(sessions().flatMap((session) => (sideOpened(session) ? [session.key] : [])))
|
||||
Array.from(openedFor.keys()).forEach((key) => {
|
||||
if (!open.has(key)) openedFor.delete(key)
|
||||
})
|
||||
})
|
||||
|
||||
// Keys are `${extension}:${tab id}`; the extension's panel decides the region.
|
||||
const provider = (key: string) => {
|
||||
@@ -428,18 +417,19 @@ export function createExtensionAttachment(services: ExtensionServices) {
|
||||
return view?.key === session.key ? view : undefined
|
||||
}
|
||||
|
||||
// Counts routing visits: each change of the routed session, including to none (e.g. Home), starts the next one.
|
||||
const visit = createMemo(on(routed, (_key, _previous, count: number = 0) => count + 1))
|
||||
// The narrow-screen view belongs to the routed, mounted session for one visit, and reads as the conversation once
|
||||
// another visit starts. A view selected for a session that is not routed (e.g. a file link that opens Files on
|
||||
// another session) belongs to the next visit, which the navigation that follows starts. The dock's view follows
|
||||
// the dock's own per-session state instead.
|
||||
const [mobile, setMobile] = createStore({ session: undefined as string | undefined, view: "session", visit: 0 })
|
||||
const mobileView = createMemo(() =>
|
||||
mobile.session === current()?.key && mobile.visit === visit() ? mobile.view : "session",
|
||||
// The narrow-screen view belongs to the routed, mounted session: it resets to the conversation when that session
|
||||
// changes or unmounts (e.g. on Home). The dock's view follows the dock's own per-session state instead.
|
||||
const [mobile, setMobile] = createStore({ session: undefined as string | undefined, view: "session" })
|
||||
createEffect(
|
||||
on(
|
||||
() => current()?.key,
|
||||
(key) => {
|
||||
if (key !== mobile.session) setMobile({ session: undefined, view: "session" })
|
||||
},
|
||||
),
|
||||
)
|
||||
const selectMobile = (session: SessionRef, view: string) =>
|
||||
setMobile({ session: session.key, view, visit: session.key === routed() ? visit() : visit() + 1 })
|
||||
const mobileView = createMemo(() => (mobile.session === current()?.key ? mobile.view : "session"))
|
||||
const selectMobile = (session: SessionRef, view: string) => setMobile({ session: session.key, view })
|
||||
|
||||
// The side tabs a mounted session lists right now, plus `adding` as if it were stored; unmounted sessions have none.
|
||||
const listed = (session: SessionRef, value: string, adding?: string) => {
|
||||
@@ -459,17 +449,20 @@ export function createExtensionAttachment(services: ExtensionServices) {
|
||||
)
|
||||
}
|
||||
|
||||
const open = (key: string, session: SessionRef, options?: Parameters<Layout["open"]>[2]) => {
|
||||
const open = (
|
||||
key: string,
|
||||
session: SessionRef,
|
||||
options?: { readonly preview?: boolean; readonly focus?: boolean; readonly select?: boolean },
|
||||
) => {
|
||||
const item = provider(key)
|
||||
if (item?.value.region === "dock") return setDock(session, true)
|
||||
const value = stateKey(session)
|
||||
if (!value) return
|
||||
// focus: false adds the tab quietly: no selection, no region change, no preview replacement.
|
||||
if (options?.focus === false && !options.preview) return layout.panel.append(value, key)
|
||||
// A select keeps the narrow-screen view and dock, as a background open does, and opens the side region too.
|
||||
if (options?.select)
|
||||
return batch(() => {
|
||||
tabs.setPane(shellTab(session), "side", true)
|
||||
if (!narrow()) tabs.setPane(shellTab(session), "side", true)
|
||||
layout.panel.append(value, key)
|
||||
layout.panel.focus(value, key)
|
||||
})
|
||||
@@ -478,14 +471,13 @@ export function createExtensionAttachment(services: ExtensionServices) {
|
||||
const launchers = new Set(known.flatMap((entry) => (entry.tab.kind === "launcher" ? [entry.key] : [])))
|
||||
const first = known.some((entry) => entry.key === key && entry.tab.first)
|
||||
batch(() => {
|
||||
if (narrow() && !options?.background) {
|
||||
if (narrow()) {
|
||||
setDock(session, false)
|
||||
if (item?.value.mobile) selectMobile(session, `${item.extension}:${item.value.id}`)
|
||||
// A tab its panel does not list, or a launcher, stays unstored: the open only selects the panel's view.
|
||||
if (mountedView(session) && !known.some((entry) => entry.key === key && entry.tab.kind !== "launcher")) return
|
||||
// A tab its panel does not list on narrow screens stays unstored: the open only selects the panel's view.
|
||||
if (mountedView(session) && !known.some((entry) => entry.key === key)) return
|
||||
}
|
||||
// A background open keeps the narrow-screen view, but its tab still shows once the window is wide.
|
||||
if (!narrow() || options?.background) tabs.setPane(shellTab(session), "side", true)
|
||||
if (!narrow()) tabs.setPane(shellTab(session), "side", true)
|
||||
// Pinned tabs are listed without being stored; opening one only selects it.
|
||||
if (known.some((entry) => entry.key === key && entry.tab.kind === "pinned")) return layout.panel.focus(value, key)
|
||||
if (options?.preview) return layout.panel.preview(value, key, launchers)
|
||||
@@ -577,21 +569,19 @@ export function createExtensionAttachment(services: ExtensionServices) {
|
||||
if (provider(key)?.value.region === "dock") return setDock(session, !dockOpened(session))
|
||||
const value = stateKey(session)
|
||||
if (!value) return
|
||||
const region = sideVisits().get(session.key)
|
||||
if (state(key, session) === "visible") {
|
||||
batch(() => {
|
||||
close(key, session)
|
||||
// Closing the last panel the region was opened for also closes the region.
|
||||
if (region && openedFor.get(region) === key && layout.panel.state(value).all.length === 0)
|
||||
if (openedFor.get(session.key) === key && layout.panel.state(value).all.length === 0)
|
||||
tabs.setPane(shellTab(session), "side", false)
|
||||
})
|
||||
return
|
||||
}
|
||||
// A panel opened into an open region makes the region the user's.
|
||||
if (region) openedFor.delete(region)
|
||||
const opening = !sideOpened(session)
|
||||
if (!opening) openedFor.delete(session.key)
|
||||
open(key, session)
|
||||
const opened = sideVisits().get(session.key)
|
||||
if (!region && opened) openedFor.set(opened, key)
|
||||
if (opening && sideOpened(session)) openedFor.set(session.key, key)
|
||||
},
|
||||
state,
|
||||
stored(extension, session) {
|
||||
|
||||
@@ -5,9 +5,6 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import type { SurfaceProps, Surfaces } from "@opencode/gui-extensions/sdk"
|
||||
import type { Bridge } from "@opencode/gui-extensions/sdk/bridge"
|
||||
|
||||
const geometry =
|
||||
/^(inset|left|right|top|bottom|translate|transform|scale|rotate|margin|padding|flex|grid|gap|row-gap|column-gap)|(^|-)(width|height)$/
|
||||
|
||||
type Input = {
|
||||
readonly bridge: Bridge | undefined
|
||||
readonly zoom: () => number
|
||||
@@ -167,14 +164,6 @@ function SurfaceView(props: SurfaceProps & { input: Input; bridge: Bridge }) {
|
||||
// animation frame keeps the native view in step with a pane drag.
|
||||
createResizeObserver(() => element, measure)
|
||||
createEventListener(window, "resize", () => schedule(300))
|
||||
// A layout transition elsewhere, such as the chat column's width while the side pane opens, can
|
||||
// move the box without resizing it. Track it from when it actually runs, and settle on its end.
|
||||
createEventListener(document, "transitionrun", (event) => {
|
||||
if (geometry.test(event.propertyName)) schedule(300)
|
||||
})
|
||||
createEventListener(document, ["transitionend", "transitioncancel"], (event) => {
|
||||
if (geometry.test(event.propertyName)) schedule()
|
||||
})
|
||||
// Floating content portals directly into <body>; keep measuring briefly so
|
||||
// the positioner has settled before the overlap check runs.
|
||||
const portals = new MutationObserver(() => schedule(300))
|
||||
|
||||
@@ -30,17 +30,12 @@ import "./session-identity-header.css"
|
||||
|
||||
export function SessionTitleHeader(props: ParentProps) {
|
||||
return (
|
||||
<>
|
||||
{/* The bottom inset reserves room for the fade without covering rows stuck below the bar. */}
|
||||
<div data-session-title class="pointer-events-none sticky top-0 z-30 w-full pb-4">
|
||||
<div class="pointer-events-auto bg-v2-background-bg-base pe-3 ps-2.5">{props.children}</div>
|
||||
</div>
|
||||
{/* Stuck timeline headers stack above this fade, so it only fades content scrolling under the bar. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none sticky top-12 z-[5] -mt-4 h-4 w-full bg-[linear-gradient(to_bottom,var(--v2-background-bg-base),transparent)]"
|
||||
/>
|
||||
</>
|
||||
<div
|
||||
data-session-title
|
||||
class="sticky top-0 z-30 w-full bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)] pb-4 pe-3 ps-2.5"
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -89,8 +89,6 @@ export interface CommandOption {
|
||||
keybind?: KeybindConfig
|
||||
slash?: string
|
||||
slashArguments?: boolean
|
||||
/** Listed right after the option whose slash name this is, when one is registered. */
|
||||
slashAfter?: string
|
||||
suggested?: boolean
|
||||
/** Listed when the command palette opens without a query. Host commands are listed by id instead. */
|
||||
featured?: boolean
|
||||
@@ -148,22 +146,6 @@ export function activeCommandRegistrations(registrations: CommandRegistration[])
|
||||
})
|
||||
}
|
||||
|
||||
// Each option with `slashAfter` follows the first option with that slash name, so a command from another
|
||||
// registration can sit inside that registration's slash list.
|
||||
function placeAfterSlash(options: CommandOption[]) {
|
||||
const anchors = new Map<string, CommandOption>()
|
||||
options.forEach((option) => {
|
||||
if (option.slash && !option.slashAfter && !anchors.has(option.slash)) anchors.set(option.slash, option)
|
||||
})
|
||||
const moved = options.filter((option) => option.slashAfter && anchors.has(option.slashAfter))
|
||||
if (moved.length === 0) return options
|
||||
return options.flatMap((option) => {
|
||||
if (moved.includes(option)) return []
|
||||
if (!option.slash || anchors.get(option.slash) !== option) return [option]
|
||||
return [option, ...moved.filter((item) => item.slashAfter === option.slash)]
|
||||
})
|
||||
}
|
||||
|
||||
export function parseKeybind(config: string): Keybind[] {
|
||||
if (!config || config === "none") return []
|
||||
|
||||
@@ -331,7 +313,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
|
||||
}
|
||||
}
|
||||
|
||||
return placeAfterSlash(all)
|
||||
return all
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
|
||||
@@ -64,7 +64,7 @@ export function createCommandPaletteFileOpener(onOpenFile?: (path: string) => vo
|
||||
const extensions = useExtensionAttachment()
|
||||
|
||||
return (path: string) => {
|
||||
links.open({ href: path, exact: true, background: true, session: extensions.current() })
|
||||
links.open({ href: path, exact: true, session: extensions.current() })
|
||||
onOpenFile?.(path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.6.0",
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
"@clack/core": "1.0.0-alpha.1",
|
||||
"@clack/prompts": "1.0.0-alpha.1",
|
||||
"@effect/platform-node": "catalog:",
|
||||
|
||||
@@ -6,18 +6,13 @@ import {
|
||||
type AgentNotificationMethod,
|
||||
type AgentRequestHandlersByMethod,
|
||||
type AgentRequestMethod,
|
||||
type JsonRpcId,
|
||||
type Stream,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient } from "@opencode/client/promise"
|
||||
import { Cause, Deferred, Effect, Ref, type Scope } from "effect"
|
||||
import { Cause, Deferred, Effect, type Scope } from "effect"
|
||||
import { ACPCatalog } from "./catalog"
|
||||
import { ACPConnection } from "./connection"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
import { ACPService } from "./service"
|
||||
import { ACPSessions } from "./sessions"
|
||||
import { ACPTurn } from "./turn"
|
||||
|
||||
// Untraced so request spans parent to the caller's span instead of a setup span that has already ended.
|
||||
export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stream: Stream) {
|
||||
@@ -33,20 +28,13 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
const handler = Effect.fn(name)(
|
||||
(ctx: AgentHandlerContext<Params>) =>
|
||||
Deferred.await(ready).pipe(Effect.flatMap((service) => call(service, ctx))),
|
||||
Effect.catchTags({
|
||||
ACPCatalogLoadError: (error) => ACPPromise.classify(error.cause),
|
||||
ACPCatalogNotReadyError: (error) => Effect.die(error),
|
||||
}),
|
||||
// Catalog failures arrive typed and are classified like promise rejections.
|
||||
Effect.catch(ACPError.classify),
|
||||
Effect.mapError((error) => (error instanceof RequestError ? error : ACPError.toRequestError(error))),
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logError("ACP request failed", cause)),
|
||||
Effect.catchDefect((defect) => Effect.fail(ACPError.toRequestError(ACPError.fromUnknown(defect)))),
|
||||
)
|
||||
return (ctx: AgentHandlerContext<Params> & { readonly requestId?: JsonRpcId }) => {
|
||||
if (ctx.requestId === undefined) return run(handler(ctx))
|
||||
return run(
|
||||
handler(ctx).pipe(Effect.provideService(ACPConnection.Responded, acp.connection.responded(ctx.requestId))),
|
||||
)
|
||||
}
|
||||
return (ctx: AgentHandlerContext<Params>) => run(handler(ctx))
|
||||
}
|
||||
const app = agent({ name: "opencode" })
|
||||
const request = <Method extends AgentRequestMethod>(
|
||||
@@ -88,7 +76,7 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
)
|
||||
request(
|
||||
"session/close",
|
||||
handle((service, ctx) => service.closeSession(ctx.params)),
|
||||
handle((service, ctx) => ACPError.promise(() => service.closeSession(ctx.params))),
|
||||
)
|
||||
request(
|
||||
"session/fork",
|
||||
@@ -106,19 +94,15 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
// `stopReason: "cancelled"`.
|
||||
request(
|
||||
"session/prompt",
|
||||
handle((service, ctx) => service.prompt(ctx.params, ctx.signal)),
|
||||
handle((service, ctx) => ACPError.promise(() => service.prompt(ctx.params, ctx.signal))),
|
||||
)
|
||||
notification(
|
||||
"session/cancel",
|
||||
handle((service, ctx) => service.cancel(ctx.params)),
|
||||
handle((service, ctx) => ACPError.promise(() => service.cancel(ctx.params))),
|
||||
)
|
||||
const acp = ACPConnection.make(app, stream)
|
||||
const connection = acp.connection
|
||||
const sessions = yield* ACPSessions.make({ client, connection, catalog })
|
||||
const capabilities = yield* Ref.make({ childSessionUpdates: false, formElicitation: false })
|
||||
const turn = yield* ACPTurn.make({ client, connection, sessions, catalog, capabilities })
|
||||
yield* Deferred.succeed(ready, ACPService.make({ client, connection, catalog, sessions, capabilities, turn }))
|
||||
return acp.agent
|
||||
const connection = app.connect(stream)
|
||||
yield* Deferred.succeed(ready, yield* ACPService.make({ client, connection, catalog, run }))
|
||||
return connection
|
||||
})
|
||||
|
||||
const spanName = (method: string) => `cli.acp.${method.replaceAll("/", ".")}`
|
||||
|
||||
@@ -3,18 +3,12 @@ import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Context, Deferred, Effect, Exit, Schedule, Schema, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||
import type { ConfigOptionProvider } from "./config-option"
|
||||
|
||||
// ACP runs these itself; they take precedence over server commands with the same name.
|
||||
export const builtinCommands = new Map([
|
||||
["compact", { description: "Compact the session", start: "compaction" as const }],
|
||||
])
|
||||
|
||||
export type Catalog = {
|
||||
readonly providers: ConfigOptionProvider[]
|
||||
readonly models: ModelInfo[]
|
||||
readonly defaultModel: ModelRef
|
||||
readonly modes: Array<{ id: string; name: string; description?: string }>
|
||||
readonly defaultModeID: string
|
||||
/** Server commands, without those shadowed by a built-in. */
|
||||
readonly commands: CommandInfo[]
|
||||
}
|
||||
|
||||
@@ -182,7 +176,7 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
|
||||
},
|
||||
modes: agents.map((agent) => ({ id: agent.id, name: agent.name, description: agent.description })),
|
||||
defaultModeID: defaultAgent.id,
|
||||
commands: commandResult.data.filter((command) => !builtinCommands.has(command.name)),
|
||||
commands: commandResult.data,
|
||||
} satisfies Catalog
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import type { ModelRef } from "@opencode/client/promise"
|
||||
import { builtinCommands, type Catalog } from "./catalog"
|
||||
|
||||
export const DEFAULT_VARIANT_VALUE = "default"
|
||||
|
||||
@@ -27,34 +25,6 @@ export type ModelSelection = {
|
||||
variant?: string
|
||||
}
|
||||
|
||||
/** A session's model and mode. Unset fields follow the catalog defaults. */
|
||||
export type Selection = {
|
||||
readonly model?: ModelRef
|
||||
readonly modeID?: string
|
||||
}
|
||||
|
||||
export function currentModel(catalog: Catalog, selection: Selection) {
|
||||
return selection.model ?? catalog.defaultModel
|
||||
}
|
||||
|
||||
export function configOptions(catalog: Catalog, selection: Selection) {
|
||||
const model = currentModel(catalog, selection)
|
||||
return buildConfigOptions({
|
||||
providers: catalog.providers,
|
||||
currentModel: { providerID: model.providerID, modelID: model.id },
|
||||
currentVariant: model.variant,
|
||||
modes: catalog.modes,
|
||||
currentModeId: selection.modeID ?? catalog.defaultModeID,
|
||||
})
|
||||
}
|
||||
|
||||
export function availableCommands(catalog: Catalog) {
|
||||
return [
|
||||
...catalog.commands.map((command) => ({ name: command.name, description: command.description ?? "" })),
|
||||
...Array.from(builtinCommands, ([name, command]) => ({ name, description: command.description })),
|
||||
]
|
||||
}
|
||||
|
||||
export function buildConfigOptions(input: {
|
||||
providers: readonly ConfigOptionProvider[]
|
||||
currentModel: ModelSelection["model"]
|
||||
|
||||
@@ -1,86 +1,52 @@
|
||||
import {
|
||||
methods,
|
||||
type AgentApp,
|
||||
type AnyMessage,
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
type JsonRpcId,
|
||||
type AgentConnection,
|
||||
type RequestError,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SendRequestOptions,
|
||||
type SessionNotification,
|
||||
type Stream,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { Context, Deferred, Effect } from "effect"
|
||||
import type { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
import { Context, type Effect } from "effect"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
/**
|
||||
* Completes once the response to the request being handled is written, so messages sent afterwards follow it.
|
||||
* Interrupts when the request fails. Outside a request it completes immediately.
|
||||
*/
|
||||
export const Responded = Context.Reference<Effect.Effect<void>>("@opencode/cli/acp/Connection/Responded", {
|
||||
defaultValue: () => Effect.void,
|
||||
})
|
||||
type Failure = ACPError.Error | RequestError
|
||||
|
||||
export interface Interface {
|
||||
readonly sessionUpdate: (params: SessionNotification) => Effect.Effect<void, ACPError.Error | RequestError>
|
||||
/** Interruption cancels the client's request. */
|
||||
readonly requestPermission: (
|
||||
params: RequestPermissionRequest,
|
||||
) => Effect.Effect<RequestPermissionResponse, ACPError.Error | RequestError>
|
||||
readonly extNotification: (
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Effect.Effect<void, ACPError.Error | RequestError>
|
||||
/** Interruption cancels the client's request. */
|
||||
readonly createElicitation: (
|
||||
params: CreateElicitationRequest,
|
||||
) => Effect.Effect<CreateElicitationResponse, ACPError.Error | RequestError>
|
||||
/** Tracks an incoming request from now on and returns its `Responded`. */
|
||||
readonly responded: (requestId: JsonRpcId) => Effect.Effect<void>
|
||||
readonly sessionUpdate: (params: SessionNotification) => Effect.Effect<void, Failure>
|
||||
/** Interrupting the request cancels it on the client. */
|
||||
readonly requestPermission: (params: RequestPermissionRequest) => Effect.Effect<RequestPermissionResponse, Failure>
|
||||
readonly extNotification: (method: string, params: Record<string, unknown>) => Effect.Effect<void, Failure>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Connection") {}
|
||||
|
||||
export function make(app: AgentApp, stream: Stream) {
|
||||
// Settled as each response is written to the stream, which serializes every outgoing message.
|
||||
const responses = new Map<JsonRpcId, Deferred.Deferred<void>>()
|
||||
const writer = stream.writable.getWriter()
|
||||
const agent = app.connect({
|
||||
readable: stream.readable,
|
||||
writable: new WritableStream<AnyMessage>({
|
||||
write: async (message) => {
|
||||
await writer.write(message)
|
||||
if ("method" in message) return
|
||||
const responded = responses.get(message.id)
|
||||
if (!responded) return
|
||||
responses.delete(message.id)
|
||||
Deferred.doneUnsafe(responded, "result" in message ? Effect.void : Effect.interrupt)
|
||||
},
|
||||
close: () => writer.close(),
|
||||
abort: (reason) => writer.abort(reason),
|
||||
}),
|
||||
export function service(connection: AgentConnection) {
|
||||
return Service.of({
|
||||
sessionUpdate: (params) => ACPError.promise(() => connection.client.notify(methods.client.session.update, params)),
|
||||
requestPermission: (params) =>
|
||||
ACPError.promise((signal) =>
|
||||
connection.client.request(methods.client.session.requestPermission, params, { cancellationSignal: signal }),
|
||||
),
|
||||
extNotification: (method, params) => ACPError.promise(() => connection.client.notify(method, params)),
|
||||
})
|
||||
}
|
||||
|
||||
export type Connection = {
|
||||
readonly signal?: AbortSignal
|
||||
sessionUpdate(params: SessionNotification): Promise<void>
|
||||
requestPermission(params: RequestPermissionRequest, options?: SendRequestOptions): Promise<RequestPermissionResponse>
|
||||
extNotification?(method: string, params: Record<string, unknown>): Promise<void>
|
||||
}
|
||||
|
||||
/** Promise view for the turn and permission code until they run as effects. */
|
||||
export function make(connection: AgentConnection): Connection {
|
||||
return {
|
||||
agent,
|
||||
connection: Service.of({
|
||||
sessionUpdate: (params) => ACPPromise.promise(() => agent.client.notify(methods.client.session.update, params)),
|
||||
requestPermission: (params) =>
|
||||
ACPPromise.promise((signal) =>
|
||||
agent.client.request(methods.client.session.requestPermission, params, { cancellationSignal: signal }),
|
||||
),
|
||||
extNotification: (method, params) => ACPPromise.promise(() => agent.client.notify(method, params)),
|
||||
createElicitation: (params) =>
|
||||
ACPPromise.promise((signal) =>
|
||||
agent.client.request(methods.client.elicitation.create, params, { cancellationSignal: signal }),
|
||||
),
|
||||
responded: (requestId) => {
|
||||
const responded = Deferred.makeUnsafe<void>()
|
||||
responses.set(requestId, responded)
|
||||
return Deferred.await(responded)
|
||||
},
|
||||
}),
|
||||
signal: connection.signal,
|
||||
sessionUpdate: (params) => connection.client.notify(methods.client.session.update, params),
|
||||
requestPermission: (params, options) =>
|
||||
connection.client.request(methods.client.session.requestPermission, params, options),
|
||||
extNotification: (method, params) => connection.client.notify(method, params),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -171,11 +171,7 @@ function resourceLinkToPart(link: ResourceLink): PromptPart {
|
||||
mime: link.mimeType ?? "text/plain",
|
||||
}
|
||||
}
|
||||
return linkReference(link.name, link.uri)
|
||||
}
|
||||
|
||||
export function linkReference(name: string | undefined, uri: string): PromptPart {
|
||||
return { type: "text", text: name ? `[${name}](${uri})` : uri }
|
||||
return { type: "text", text: link.uri }
|
||||
}
|
||||
|
||||
function filenameFromUri(uri: string | undefined): string | undefined {
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { isAbsolute, join, resolve } from "node:path"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import type { OpenCodeClient, PermissionRule, SessionInfo, SessionMetadata } from "@opencode/client/promise"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
|
||||
const key = "opencode.acp.additionalDirectories"
|
||||
const decodeStored = Schema.decodeUnknownOption(Schema.Array(Schema.String))
|
||||
|
||||
/**
|
||||
* Normalizes additional workspace roots without following symlinks, keeping the spelling tools will see, and
|
||||
* drops duplicates and roots that resolve to cwd. Glob characters are rejected because permission resources
|
||||
* would treat them as wildcards. The ACP SDK has already dropped entries that are not strings and replaced a
|
||||
* value that is not an array with `[]`.
|
||||
*/
|
||||
export const parse = Effect.fnUntraced(function* (cwd: string, directories: readonly string[] = []) {
|
||||
const invalid = directories.find((directory) => !isAbsolute(directory) || /[*?]/.test(directory))
|
||||
if (invalid !== undefined) return yield* new ACPError.InvalidAdditionalDirectoryError({ directory: invalid })
|
||||
const root = FSUtil.resolve(cwd)
|
||||
return [...new Set(directories.map((directory) => resolve(FSUtil.windowsPath(directory))))].filter(
|
||||
(directory) => FSUtil.resolve(directory) !== root,
|
||||
)
|
||||
})
|
||||
|
||||
/** Session create fields that grant these directories. */
|
||||
export function grant(directories: readonly string[]) {
|
||||
if (directories.length === 0) return {}
|
||||
return { permissions: rules(directories), metadata: { [key]: [...directories] } }
|
||||
}
|
||||
|
||||
/** The additional directories ACP last activated for the session, in request order. */
|
||||
export function list(session: Pick<SessionInfo, "metadata">) {
|
||||
return [...Option.getOrElse(decodeStored(session.metadata?.[key]), () => [])]
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the rules granted for the previously activated directories with grants for these directories.
|
||||
* Grants persist on the server session, so they also apply when it is used from other clients, and child
|
||||
* sessions copy them when they are created; a child created earlier keeps roots its parent later dropped.
|
||||
* Session rules are evaluated after agent and config rules, so a grant overrides config `external_directory`
|
||||
* rules inside the root, while read, edit, and shell rules still apply.
|
||||
*/
|
||||
export const activate = Effect.fnUntraced(function* (
|
||||
client: OpenCodeClient,
|
||||
session: SessionInfo,
|
||||
directories: readonly string[],
|
||||
) {
|
||||
const previous = list(session)
|
||||
const owned = rules(previous)
|
||||
const current = session.permissions ?? []
|
||||
// ACP rules lead so the session's other rules keep precedence for the same paths.
|
||||
const permissions = [
|
||||
...rules(directories),
|
||||
...current.filter((rule) => !owned.some((item) => isDeepStrictEqual(item, rule))),
|
||||
]
|
||||
const metadata: SessionMetadata = {
|
||||
...Object.fromEntries(Object.entries(session.metadata ?? {}).filter(([name]) => name !== key)),
|
||||
...(directories.length > 0 ? { [key]: [...directories] } : {}),
|
||||
}
|
||||
const permissionsChanged = !isDeepStrictEqual(permissions, current)
|
||||
const metadataChanged = !isDeepStrictEqual(previous, directories)
|
||||
if (!permissionsChanged && !metadataChanged) return
|
||||
yield* ACPPromise.promise(() =>
|
||||
client.session.update({
|
||||
sessionID: session.id,
|
||||
...(permissionsChanged ? { permissions } : {}),
|
||||
...(metadataChanged ? { metadata } : {}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// Tools compare the written path without following symlinks, so both spellings of a root are granted.
|
||||
function rules(directories: readonly string[]): PermissionRule[] {
|
||||
return [...new Set(directories.flatMap((directory) => [directory, FSUtil.resolve(directory)]))].map((directory) => ({
|
||||
action: "external_directory",
|
||||
resource: join(directory, "*"),
|
||||
effect: "allow",
|
||||
}))
|
||||
}
|
||||
|
||||
export * as ACPDirectories from "./directories"
|
||||
@@ -1,238 +0,0 @@
|
||||
import type {
|
||||
CreateElicitationResponse,
|
||||
ElicitationPropertySchema,
|
||||
ElicitationSchema,
|
||||
EnumOption,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { isFormAlreadySettledError, isFormNotFoundError, type OpenCodeClient } from "@opencode/client/promise"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
import { Cause, Effect, Option, Schema } from "effect"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import type { ACPService } from "./service"
|
||||
|
||||
/** A form as the event stream carries it, with an unbranded ID. */
|
||||
export type AskedForm = Omit<Form.Info, "id"> & { readonly id: string }
|
||||
type InputField = Exclude<Form.Field, Form.ExternalField>
|
||||
type SelectField = Form.StringField | Form.MultiselectField
|
||||
|
||||
// Form mode must not collect secrets, so only forms from flows known not to ask for credentials are elicited.
|
||||
const ElicitedKind = Schema.Struct({ kind: Schema.Literals(["question", "websearch.provider"]) })
|
||||
const Credential = /password|passphrase|secret|token|api[_-]?key|credential|private[_-]?key/i
|
||||
const ToolSource = Schema.Struct({ tool: Schema.Struct({ id: Schema.String }) })
|
||||
|
||||
type Input = {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly form: AskedForm
|
||||
readonly requestedSchema: ElicitationSchema
|
||||
readonly clientSessionID: string
|
||||
readonly child?: { readonly id: string; readonly title?: string }
|
||||
/** Whether the asking tool call reached the client as a `session/update` tool call. */
|
||||
readonly toolCallSent: boolean
|
||||
/** Completes once the form is answered or cancelled elsewhere. */
|
||||
readonly settled: Effect.Effect<void>
|
||||
}
|
||||
|
||||
type Outcome = Form.Answer | "cancel" | "settled"
|
||||
|
||||
/**
|
||||
* Asks the client, then resolves the form on the server. Once `cancelled` completes, the client's request is
|
||||
* cancelled or never sent, and the form is cancelled. Once `settled` completes, the client's request is cancelled and
|
||||
* the server is left alone. Resolving on the server is uninterruptible.
|
||||
*/
|
||||
export const reply = Effect.fn("cli.acp.elicitation.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
|
||||
restore(
|
||||
cancelled.pipe(
|
||||
Effect.as("cancel" as const),
|
||||
Effect.raceFirst(input.settled.pipe(Effect.as("settled" as const))),
|
||||
Effect.raceFirst(ask(input)),
|
||||
),
|
||||
).pipe(
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP elicitation failed", cause)),
|
||||
Effect.catchCause(() => Effect.succeed("cancel" as const)),
|
||||
Effect.flatMap((outcome) => respond(input, outcome)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
/** Cancels a form, interrupting its session when the server can't cancel it. */
|
||||
export function cancel(client: OpenCodeClient, form: AskedForm) {
|
||||
return settle(() => client.session.form.cancel({ sessionID: form.sessionID, formID: form.id })).pipe(
|
||||
Effect.catch(() =>
|
||||
Effect.tryPromise(() => client.session.interrupt({ sessionID: form.sessionID })).pipe(Effect.ignore),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The form-mode schema for a form, or undefined when the form is cancelled instead: the client lacks form
|
||||
* elicitation, the form is not from an allowed flow or has a field that looks like a credential, or ACP can't
|
||||
* represent it faithfully. Unrepresentable forms have `external` fields, `when` conditions, a hidden required field
|
||||
* without a default, a default outside a field's options, or a free-text answer alongside options that must also
|
||||
* satisfy `required` or item bounds across both inputs. Hidden fields are not asked and answer with their default.
|
||||
*/
|
||||
export function requestedSchema(form: AskedForm, capabilities: ACPService.Capabilities): ElicitationSchema | undefined {
|
||||
if (!capabilities.formElicitation) return undefined
|
||||
if (Option.isNone(Schema.decodeUnknownOption(ElicitedKind)(form.metadata))) return undefined
|
||||
if (form.fields.some((field) => Credential.test(field.key) || Credential.test(field.title ?? ""))) return undefined
|
||||
const fields = form.fields.filter((field): field is InputField => field.type !== "external")
|
||||
if (fields.length !== form.fields.length || fields.some((field) => field.when?.length)) return undefined
|
||||
if (fields.some((field) => field.hidden && field.required && field.default === undefined)) return undefined
|
||||
const keys = new Set(fields.map((field) => field.key))
|
||||
const visible = fields.filter((field) => !field.hidden)
|
||||
if (!visible.every((field) => representable(field, keys))) return undefined
|
||||
return {
|
||||
type: "object",
|
||||
properties: Object.fromEntries(visible.flatMap(properties)),
|
||||
required: visible.filter((field) => field.required).map((field) => field.key),
|
||||
}
|
||||
}
|
||||
|
||||
/** The answer for an accepted response, or undefined when the user declined, cancelled, or sent invalid content. */
|
||||
export function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answer | undefined {
|
||||
if (response.action !== "accept") return undefined
|
||||
const content = Schema.decodeUnknownOption(Form.Answer)(response.content ?? {})
|
||||
if (Option.isNone(content)) return undefined
|
||||
return Object.fromEntries(
|
||||
form.fields.flatMap((field) => {
|
||||
const value = fieldAnswer(field, content.value)
|
||||
return value === undefined ? [] : [[field.key, value]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
const source = input.toolCallSent ? Schema.decodeUnknownOption(ToolSource)(input.form.metadata) : Option.none()
|
||||
const toolCallID = Option.getOrUndefined(Option.map(source, (metadata) => metadata.tool.id))
|
||||
const response = yield* input.connection.createElicitation({
|
||||
mode: "form",
|
||||
sessionId: input.clientSessionID,
|
||||
...(toolCallID ? { toolCallId: input.child ? `${input.child.id}:${toolCallID}` : toolCallID } : {}),
|
||||
message: input.child?.title ? `${input.child.title}: ${input.form.title}` : input.form.title,
|
||||
requestedSchema: input.requestedSchema,
|
||||
})
|
||||
return answer(input.form, response) ?? "cancel"
|
||||
})
|
||||
|
||||
function respond(input: Input, outcome: Outcome) {
|
||||
if (outcome === "settled") return Effect.void
|
||||
if (outcome === "cancel") return cancel(input.client, input.form)
|
||||
return settle(() =>
|
||||
input.client.session.form.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: outcome }),
|
||||
).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("ACP form reply failed", cause).pipe(Effect.andThen(cancel(input.client, input.form))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// A form already answered or cancelled elsewhere needs nothing more.
|
||||
function settle(evaluate: () => Promise<void>) {
|
||||
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(
|
||||
Effect.catch((cause) =>
|
||||
isFormAlreadySettledError(cause) || isFormNotFoundError(cause) ? Effect.void : Effect.fail(cause),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function representable(field: InputField, keys: ReadonlySet<string>) {
|
||||
if (field.type !== "string" && field.type !== "multiselect") return true
|
||||
if (!hasOptions(field)) return true
|
||||
const values = new Set(field.options?.map((option) => option.value))
|
||||
const defaults =
|
||||
field.default === undefined ? [] : typeof field.default === "string" ? [field.default] : field.default
|
||||
if (defaults.some((value) => !values.has(value))) return false
|
||||
if (!field.custom) return true
|
||||
if (field.required || keys.has(customKey(field))) return false
|
||||
return field.type === "string" || (field.minItems === undefined && field.maxItems === undefined)
|
||||
}
|
||||
|
||||
function properties(field: InputField): Array<[string, ElicitationPropertySchema]> {
|
||||
const base = { title: field.title, description: field.description }
|
||||
switch (field.type) {
|
||||
case "string": {
|
||||
if (!hasOptions(field)) return [[field.key, { type: "string", ...base, ...text(field), default: field.default }]]
|
||||
const select: ElicitationPropertySchema = {
|
||||
type: "string",
|
||||
...base,
|
||||
oneOf: options(field),
|
||||
default: field.default,
|
||||
}
|
||||
return field.custom ? [[field.key, select], other(field, "Type your own answer")] : [[field.key, select]]
|
||||
}
|
||||
case "multiselect": {
|
||||
const select: ElicitationPropertySchema = {
|
||||
type: "array",
|
||||
...base,
|
||||
items: { anyOf: options(field) },
|
||||
minItems: field.required ? Math.max(field.minItems ?? 0, 1) : field.minItems,
|
||||
maxItems: field.maxItems,
|
||||
default: field.default,
|
||||
}
|
||||
return field.custom ? [[field.key, select], other(field, "Add your own answer")] : [[field.key, select]]
|
||||
}
|
||||
case "number":
|
||||
case "integer":
|
||||
return [
|
||||
[
|
||||
field.key,
|
||||
{ type: field.type, ...base, minimum: field.minimum, maximum: field.maximum, default: field.default },
|
||||
],
|
||||
]
|
||||
case "boolean":
|
||||
return [[field.key, { type: "boolean", ...base, default: field.default }]]
|
||||
}
|
||||
}
|
||||
|
||||
// A free-text answer next to a field's options is a separate optional property that wins over the selection.
|
||||
function other(field: SelectField, description: string): [string, ElicitationPropertySchema] {
|
||||
return [
|
||||
customKey(field),
|
||||
{
|
||||
type: "string",
|
||||
title: `${field.title ?? field.key} (other)`,
|
||||
description,
|
||||
...(field.type === "string" ? text(field) : {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// Core rejects an empty string for a required field, so the client is told it needs at least one character.
|
||||
function text(field: Form.StringField) {
|
||||
return {
|
||||
format: field.format,
|
||||
minLength: field.required ? Math.max(field.minLength ?? 0, 1) : field.minLength,
|
||||
maxLength: field.maxLength,
|
||||
pattern: field.pattern,
|
||||
}
|
||||
}
|
||||
|
||||
function options(field: SelectField): EnumOption[] {
|
||||
return (field.options ?? []).map((option) => ({
|
||||
const: option.value,
|
||||
title: option.label,
|
||||
description: option.description,
|
||||
}))
|
||||
}
|
||||
|
||||
function fieldAnswer(field: Form.Field, content: Form.Answer) {
|
||||
if (field.type !== "external" && field.hidden) return field.default
|
||||
const value = content[field.key]
|
||||
if ((field.type !== "string" && field.type !== "multiselect") || !field.custom || !hasOptions(field)) return value
|
||||
const custom = content[customKey(field)]
|
||||
if (typeof custom !== "string" || custom.trim() === "") return value
|
||||
if (field.type === "string") return custom
|
||||
return Array.isArray(value) ? [...value, custom] : [custom]
|
||||
}
|
||||
|
||||
function hasOptions(field: SelectField) {
|
||||
return field.type === "multiselect" || field.options !== undefined
|
||||
}
|
||||
|
||||
function customKey(field: SelectField) {
|
||||
return `${field.key}_custom`
|
||||
}
|
||||
|
||||
export * as ACPElicitation from "./elicitation"
|
||||
@@ -1,5 +1,7 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { Schema } from "effect"
|
||||
import { ClientError } from "@opencode/client/promise"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { ACPCatalog } from "./catalog"
|
||||
|
||||
export class SessionNotFoundError extends Schema.TaggedError<SessionNotFoundError>()("ACPSessionNotFoundError", {
|
||||
sessionId: Schema.String,
|
||||
@@ -28,22 +30,12 @@ export class InvalidModeError extends Schema.TaggedError<InvalidModeError>()("AC
|
||||
mode: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class InvalidAdditionalDirectoryError extends Schema.TaggedError<InvalidAdditionalDirectoryError>()(
|
||||
"ACPInvalidAdditionalDirectoryError",
|
||||
{ directory: Schema.String },
|
||||
) {}
|
||||
|
||||
export class AuthRequiredError extends Schema.TaggedError<AuthRequiredError>()("ACPAuthRequiredError", {}) {}
|
||||
|
||||
export class UnknownAuthMethodError extends Schema.TaggedError<UnknownAuthMethodError>()("ACPUnknownAuthMethodError", {
|
||||
methodId: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>()("ACPInvalidRequestError", {
|
||||
message: Schema.String,
|
||||
field: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
export class ServiceFailureError extends Schema.TaggedError<ServiceFailureError>()("ACPServiceFailureError", {
|
||||
safeMessage: Schema.String,
|
||||
service: Schema.optional(Schema.String),
|
||||
@@ -62,10 +54,8 @@ const Errors = Schema.Union([
|
||||
InvalidModelError,
|
||||
InvalidEffortError,
|
||||
InvalidModeError,
|
||||
InvalidAdditionalDirectoryError,
|
||||
AuthRequiredError,
|
||||
UnknownAuthMethodError,
|
||||
InvalidRequestError,
|
||||
ServiceFailureError,
|
||||
ServerUnavailableError,
|
||||
])
|
||||
@@ -94,17 +84,10 @@ export function toRequestError(error: Error): RequestError {
|
||||
return RequestError.invalidParams({ effort: error.effort }, `effort not found: ${error.effort}`)
|
||||
case "ACPInvalidModeError":
|
||||
return RequestError.invalidParams({ mode: error.mode }, `mode not found: ${error.mode}`)
|
||||
case "ACPInvalidAdditionalDirectoryError":
|
||||
return RequestError.invalidParams(
|
||||
{ additionalDirectory: error.directory },
|
||||
`additional directory must be an absolute path without glob characters: ${error.directory}`,
|
||||
)
|
||||
case "ACPAuthRequiredError":
|
||||
return RequestError.authRequired({}, "provider authentication required")
|
||||
case "ACPUnknownAuthMethodError":
|
||||
return RequestError.invalidParams({ methodId: error.methodId }, `unknown auth method: ${error.methodId}`)
|
||||
case "ACPInvalidRequestError":
|
||||
return RequestError.invalidParams(error.field ? { field: error.field } : {}, error.message)
|
||||
case "ACPServiceFailureError":
|
||||
return RequestError.internalError(
|
||||
{
|
||||
@@ -120,6 +103,18 @@ export function toRequestError(error: Error): RequestError {
|
||||
return exhaustive
|
||||
}
|
||||
|
||||
/** Runs a promise, keeping ACP failures typed. Any other rejection is a defect. */
|
||||
export const promise = <A>(evaluate: (signal: AbortSignal) => Promise<A>) =>
|
||||
Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(Effect.catch(classify))
|
||||
|
||||
export function classify(cause: unknown): Effect.Effect<never, Error | RequestError> {
|
||||
// A catalog load failure is classified by the client error that caused it.
|
||||
if (cause instanceof ACPCatalog.LoadError) return classify(cause.cause)
|
||||
if (cause instanceof RequestError || is(cause)) return Effect.fail(cause)
|
||||
if (cause instanceof ClientError && cause.reason === "Transport") return Effect.fail(new ServerUnavailableError())
|
||||
return Effect.die(cause)
|
||||
}
|
||||
|
||||
export function fromUnknown(error: unknown, service?: string) {
|
||||
const errorName = error instanceof Error ? error.name : undefined
|
||||
return new ServiceFailureError({ safeMessage: "Internal service failure", service, errorName })
|
||||
|
||||
@@ -0,0 +1,698 @@
|
||||
import type { PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
OpenCodeClient,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionStructuredError,
|
||||
TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { partsToContentChunks, type ReplayPart } from "./content"
|
||||
import { ACPError } from "./error"
|
||||
import { replyPermission } from "./permission"
|
||||
import {
|
||||
completedToolUpdate,
|
||||
errorToolUpdate,
|
||||
pendingToolCall,
|
||||
runningToolUpdate,
|
||||
type ToolContent,
|
||||
type ToolInput,
|
||||
} from "./tool"
|
||||
|
||||
type Connection = Pick<ACPConnection.Connection, "sessionUpdate" | "requestPermission">
|
||||
|
||||
export type TurnControl = {
|
||||
cancelled: boolean
|
||||
// Aborted whenever the turn is cancelled or closed, so it also cancels the turn's outbound client requests.
|
||||
readonly admission: AbortController
|
||||
}
|
||||
|
||||
type ToolState = {
|
||||
readonly name: string
|
||||
input: ToolInput
|
||||
metadata: Record<string, unknown>
|
||||
content: ToolContent
|
||||
}
|
||||
|
||||
export type TurnStart =
|
||||
| { readonly type: "input"; readonly id: string }
|
||||
| { readonly type: "skill"; readonly id: string }
|
||||
| { readonly type: "compaction"; readonly id: string }
|
||||
|
||||
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
|
||||
export const ChildSessionUpdateMethod = "opencode/session/child_update"
|
||||
const RetryMeta = "opencode/retry"
|
||||
const CompactionMeta = "opencode/compaction"
|
||||
|
||||
type RetryStatus = {
|
||||
readonly attempt: number
|
||||
readonly nextRetryAt: string
|
||||
readonly error: SessionStructuredError
|
||||
}
|
||||
|
||||
type CompactionMarker = {
|
||||
readonly status: "started" | "completed" | "failed"
|
||||
readonly messageId: string
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error?: SessionStructuredError
|
||||
}
|
||||
|
||||
type CompactionEvent = Extract<
|
||||
EventSubscribeOutput,
|
||||
{ readonly type: "session.compaction.started" | "session.compaction.ended" | "session.compaction.failed" }
|
||||
>
|
||||
|
||||
type ChildSessionUpdateBase = {
|
||||
readonly rootSessionId: string
|
||||
readonly childSessionId: string
|
||||
readonly parentSessionId: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
}
|
||||
|
||||
type ChildSessionEvent =
|
||||
| { readonly type: "update"; readonly update: SessionUpdate }
|
||||
| {
|
||||
readonly type: "status"
|
||||
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
|
||||
readonly error?: { readonly type: string; readonly message: string }
|
||||
}
|
||||
|
||||
export type ChildSessionUpdate = ChildSessionUpdateBase & ChildSessionEvent
|
||||
|
||||
type ChildSession = {
|
||||
readonly id: string
|
||||
readonly parentID: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
}
|
||||
|
||||
function emptyToolState(): ToolState {
|
||||
return { name: "tool", input: {}, metadata: {}, content: [] }
|
||||
}
|
||||
|
||||
export async function streamTurn(input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: Connection
|
||||
readonly sessionID: string
|
||||
readonly cwd: string
|
||||
readonly start: TurnStart
|
||||
readonly action?: boolean
|
||||
readonly submit: (signal: AbortSignal) => Promise<unknown>
|
||||
readonly control: TurnControl
|
||||
readonly childSessionUpdate?: (update: ChildSessionUpdate) => Promise<void>
|
||||
readonly connectionSignal?: AbortSignal
|
||||
readonly sessionSignal?: AbortSignal
|
||||
}): Promise<{ readonly response: PromptResponse; readonly contextTokens: number | undefined }> {
|
||||
const streamController = new AbortController()
|
||||
const connectionAbort = () => streamController.abort()
|
||||
input.connectionSignal?.addEventListener("abort", connectionAbort, { once: true })
|
||||
const stream = input.client.event.subscribe({ signal: streamController.signal })[Symbol.asyncIterator]()
|
||||
const connected = await stream.next()
|
||||
if (connected.done) throw new Error("event stream disconnected before prompt admission")
|
||||
|
||||
const control = input.control
|
||||
let started = false
|
||||
let finish: SessionMessageAssistant["finish"]
|
||||
let executionError: { readonly type: string; readonly message: string } | undefined
|
||||
let stepError: SessionStructuredError | undefined
|
||||
let usage: { readonly turn: TokenUsageInfo; readonly last: TokenUsageInfo } | undefined
|
||||
const tools = new Map<string, ToolState>()
|
||||
const retries = new Map<string, RetryStatus>()
|
||||
const compactions = new Map<string, string>()
|
||||
const children = new Map<string, ChildSession>()
|
||||
const openChildren = new Set<string>()
|
||||
let handedOff = false
|
||||
|
||||
const recordStep = (tokens: TokenUsageInfo) => {
|
||||
const turn = usage?.turn
|
||||
usage = {
|
||||
turn: turn
|
||||
? {
|
||||
input: turn.input + tokens.input,
|
||||
output: turn.output + tokens.output,
|
||||
reasoning: turn.reasoning + tokens.reasoning,
|
||||
cache: { read: turn.cache.read + tokens.cache.read, write: turn.cache.write + tokens.cache.write },
|
||||
}
|
||||
: tokens,
|
||||
last: tokens,
|
||||
}
|
||||
}
|
||||
|
||||
const notifyChild = async (child: ChildSession, value: ChildSessionEvent) => {
|
||||
if (!input.childSessionUpdate) return
|
||||
await input
|
||||
.childSessionUpdate({
|
||||
rootSessionId: input.sessionID,
|
||||
childSessionId: child.id,
|
||||
parentSessionId: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
...value,
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
const updateSession = async (value: SessionUpdate, child: ChildSession | undefined, mode: "turn" | "background") => {
|
||||
const projected = child ? projectChildUpdate(value, child) : value
|
||||
if (mode === "turn" && (!child || !input.childSessionUpdate)) {
|
||||
await input.connection.sessionUpdate({ sessionId: input.sessionID, update: projected })
|
||||
}
|
||||
if (child) await notifyChild(child, { type: "update", update: projected })
|
||||
}
|
||||
|
||||
const consume = async (mode: "turn" | "background") => {
|
||||
while (!streamController.signal.aborted) {
|
||||
const next = await stream.next()
|
||||
if (next.done) throw new Error("event stream disconnected during prompt execution")
|
||||
const event = next.value
|
||||
if (event.type === "session.created") {
|
||||
const parentID = event.data.parentID
|
||||
if (!parentID) continue
|
||||
const parent = parentID === input.sessionID ? undefined : children.get(parentID)
|
||||
if ((mode === "turn" && parentID === input.sessionID) || parent) {
|
||||
const child = {
|
||||
id: event.data.sessionID,
|
||||
parentID,
|
||||
depth: parent ? parent.depth + 1 : 1,
|
||||
title: event.data.title,
|
||||
}
|
||||
children.set(child.id, child)
|
||||
openChildren.add(child.id)
|
||||
await notifyChild(child, { type: "status", status: "created" })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const eventSessionID = sessionIDFromEvent(event)
|
||||
const child = eventSessionID ? children.get(eventSessionID) : undefined
|
||||
const send = (update: SessionUpdate) => updateSession(update, child, mode)
|
||||
if (mode === "background" && !child) continue
|
||||
|
||||
if (event.type === "permission.asked" && (event.data.sessionID === input.sessionID || child)) {
|
||||
const tool = event.data.source?.id ? tools.get(toolKey(event.data.sessionID, event.data.source.id)) : undefined
|
||||
await replyPermission({
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
event,
|
||||
sessionID: event.data.sessionID,
|
||||
clientSessionID: input.sessionID,
|
||||
cwd: input.cwd,
|
||||
tool,
|
||||
signal: control.admission.signal,
|
||||
...(child ? { toolCallPrefix: child.id, titlePrefix: child.title } : {}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) {
|
||||
await input.client.session.form
|
||||
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
|
||||
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
|
||||
continue
|
||||
}
|
||||
if (!eventSessionID || (eventSessionID !== input.sessionID && !child)) continue
|
||||
if (matchesStart(event, input.start)) {
|
||||
started = true
|
||||
continue
|
||||
}
|
||||
if (!started) continue
|
||||
|
||||
if (event.type === "session.execution.started") {
|
||||
if (child) {
|
||||
await notifyChild(child, { type: "status", status: "running" })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === "session.step.started") {
|
||||
if (!child) stepError = undefined
|
||||
if (retries.delete(eventSessionID))
|
||||
await send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: null } })
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.retry.scheduled") {
|
||||
const retry = {
|
||||
attempt: event.data.attempt,
|
||||
nextRetryAt: new Date(event.data.at).toISOString(),
|
||||
error: event.data.error,
|
||||
}
|
||||
retries.set(eventSessionID, retry)
|
||||
await send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: retry } })
|
||||
continue
|
||||
}
|
||||
if (
|
||||
event.type === "session.compaction.started" ||
|
||||
event.type === "session.compaction.ended" ||
|
||||
event.type === "session.compaction.failed"
|
||||
) {
|
||||
const marker = compactionMarker(event, compactions)
|
||||
if (marker) await send(compactionUpdate(marker))
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.text.delta") {
|
||||
await send({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: event.data.assistantMessageID,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.reasoning.delta") {
|
||||
await send({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.input.started") {
|
||||
tools.set(toolKey(event.data.sessionID, event.data.id), {
|
||||
name: event.data.name,
|
||||
input: {},
|
||||
metadata: {},
|
||||
content: [],
|
||||
})
|
||||
await send({
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: event.data.id,
|
||||
toolName: event.data.name,
|
||||
state: { input: {} },
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.called") {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
current.input = event.data.input
|
||||
tools.set(key, current)
|
||||
await send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: current.name,
|
||||
state: { input: current.input },
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.progress") {
|
||||
const current = tools.get(toolKey(event.data.sessionID, event.data.id))
|
||||
if (!current) continue
|
||||
current.metadata = event.data.metadata
|
||||
await send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: current.name,
|
||||
state: { input: current.input },
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.success") {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
tools.delete(key)
|
||||
await send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: current.name,
|
||||
input: current.input,
|
||||
metadata: event.data.metadata,
|
||||
content: event.data.content,
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.tool.failed") {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const current = tools.get(key) ?? emptyToolState()
|
||||
tools.delete(key)
|
||||
await send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: current.name,
|
||||
input: current.input,
|
||||
metadata: event.data.metadata ?? current.metadata,
|
||||
content: event.data.content ?? current.content,
|
||||
error: event.data.error.message,
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.step.ended") {
|
||||
if (!child) {
|
||||
finish = event.data.finish
|
||||
recordStep(event.data.tokens)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.step.failed") {
|
||||
if (!child) {
|
||||
stepError = event.data.error
|
||||
if (event.data.tokens) recordStep(event.data.tokens)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.execution.succeeded") {
|
||||
if (!child) return "succeeded" as const
|
||||
openChildren.delete(child.id)
|
||||
await notifyChild(child, { type: "status", status: "completed" })
|
||||
if (mode === "background" && openChildren.size === 0) return "succeeded" as const
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.execution.interrupted") {
|
||||
if (!child) return "interrupted" as const
|
||||
openChildren.delete(child.id)
|
||||
await notifyChild(child, { type: "status", status: "interrupted" })
|
||||
if (mode === "background" && openChildren.size === 0) return "interrupted" as const
|
||||
continue
|
||||
}
|
||||
if (event.type === "session.execution.failed") {
|
||||
if (child) {
|
||||
openChildren.delete(child.id)
|
||||
await notifyChild(child, { type: "status", status: "failed", error: event.data.error })
|
||||
if (mode === "background" && openChildren.size === 0) return "failed" as const
|
||||
continue
|
||||
}
|
||||
executionError = event.data.error
|
||||
return "failed" as const
|
||||
}
|
||||
}
|
||||
return "interrupted" as const
|
||||
}
|
||||
|
||||
const completed = consume("turn")
|
||||
const closeStream = async () => {
|
||||
streamController.abort()
|
||||
input.connectionSignal?.removeEventListener("abort", connectionAbort)
|
||||
input.sessionSignal?.removeEventListener("abort", connectionAbort)
|
||||
await stream.return?.(undefined).catch(() => {})
|
||||
}
|
||||
try {
|
||||
await input.submit(control.admission.signal).catch((error) => {
|
||||
if (!control.cancelled) throw error
|
||||
})
|
||||
if (input.action) {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
return {
|
||||
response: response(undefined, undefined, "succeeded", control.cancelled, undefined),
|
||||
contextTokens: undefined,
|
||||
}
|
||||
}
|
||||
if (control.cancelled) {
|
||||
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
if (!started) {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
return { response: response(undefined, undefined, "interrupted", true, undefined), contextTokens: undefined }
|
||||
}
|
||||
}
|
||||
const terminal = await completed
|
||||
if (input.childSessionUpdate && openChildren.size > 0 && !input.sessionSignal?.aborted) {
|
||||
handedOff = true
|
||||
input.sessionSignal?.addEventListener("abort", connectionAbort, { once: true })
|
||||
void consume("background")
|
||||
.catch(() => {})
|
||||
.finally(closeStream)
|
||||
}
|
||||
return {
|
||||
response: response(
|
||||
usage?.turn,
|
||||
stepError ?? executionError,
|
||||
terminal,
|
||||
control.cancelled,
|
||||
finish,
|
||||
retries.get(input.sessionID),
|
||||
),
|
||||
contextTokens: usage ? TokenUsage.total(usage.last) : undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
streamController.abort()
|
||||
await completed.catch(() => {})
|
||||
throw error
|
||||
} finally {
|
||||
if (!handedOff) await closeStream()
|
||||
}
|
||||
}
|
||||
|
||||
function sessionIDFromEvent(event: EventSubscribeOutput) {
|
||||
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
|
||||
if (event.type === "form.created") return event.data.form.sessionID
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toolKey(sessionID: string, id: string) {
|
||||
return `${sessionID}:${id}`
|
||||
}
|
||||
|
||||
// Message IDs follow core's compaction message projection, so live markers match replayed ones.
|
||||
function compactionMarker(event: CompactionEvent, compactions: Map<string, string>): CompactionMarker | undefined {
|
||||
const sessionID = event.data.sessionID
|
||||
if (event.type === "session.compaction.started") {
|
||||
const messageId = event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id))
|
||||
compactions.set(sessionID, messageId)
|
||||
return { status: "started", messageId, reason: event.data.reason }
|
||||
}
|
||||
const tracked = compactions.get(sessionID)
|
||||
compactions.delete(sessionID)
|
||||
if (event.type === "session.compaction.ended")
|
||||
return tracked ? { status: "completed", messageId: tracked, reason: event.data.reason } : undefined
|
||||
// Automatic compaction can fail before it starts, for example when there is nothing to compact yet.
|
||||
return {
|
||||
status: "failed",
|
||||
messageId: tracked ?? event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id)),
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
}
|
||||
}
|
||||
|
||||
function compactionUpdate(marker: CompactionMarker): SessionUpdate {
|
||||
return { sessionUpdate: "session_info_update", _meta: { [CompactionMeta]: marker } }
|
||||
}
|
||||
|
||||
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||
const projected = { ...update }
|
||||
projected._meta = {
|
||||
...projected._meta,
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
|
||||
projected.toolCallId = `${child.id}:${projected.toolCallId}`
|
||||
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
export async function replayMessages(
|
||||
connection: Pick<Connection, "sessionUpdate">,
|
||||
sessionID: string,
|
||||
cwd: string,
|
||||
messages: readonly SessionMessageInfo[],
|
||||
) {
|
||||
for (const message of messages) await replayMessage(connection, sessionID, cwd, message).catch(() => {})
|
||||
}
|
||||
|
||||
async function replayMessage(
|
||||
connection: Pick<Connection, "sessionUpdate">,
|
||||
sessionID: string,
|
||||
cwd: string,
|
||||
message: SessionMessageInfo,
|
||||
) {
|
||||
if (message.type === "user") {
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: message.id,
|
||||
content: { type: "text", text: message.text },
|
||||
},
|
||||
})
|
||||
const files: ReplayPart[] = (message.files ?? []).map((file) => ({
|
||||
type: "file",
|
||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
filename: file.name,
|
||||
mime: file.mime,
|
||||
}))
|
||||
for (const chunk of partsToContentChunks(files)) {
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: { sessionUpdate: "user_message_chunk", messageId: message.id, ...chunk },
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
// A running compaction has no live turn on this connection to settle it, so replay only settled ones.
|
||||
if (message.type === "compaction" && message.status !== "running") {
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: compactionUpdate({
|
||||
status: message.status,
|
||||
messageId: message.id,
|
||||
reason: message.reason,
|
||||
...(message.status === "failed" ? { error: message.error } : {}),
|
||||
}),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (message.type !== "assistant") return
|
||||
// Live reasoning ordinals count only reasoning parts, not the mixed content array.
|
||||
let reasoningOrdinal = 0
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: message.id,
|
||||
content: { type: "text", text: part.text },
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: `${message.id}:reasoning:${reasoningOrdinal++}`,
|
||||
content: { type: "text", text: part.text },
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
state: { input: part.state.status === "streaming" ? {} : part.state.input },
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
switch (part.state.status) {
|
||||
case "completed":
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
break
|
||||
case "running":
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
state: { input: part.state.input },
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
break
|
||||
case "error":
|
||||
await connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
error: part.state.error.message,
|
||||
cwd,
|
||||
}),
|
||||
},
|
||||
})
|
||||
break
|
||||
case "streaming":
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
|
||||
if (start.type === "input") return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
|
||||
if (start.type === "compaction") return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
|
||||
return event.type === "session.skill.activated" && event.id === start.id.replace(/^msg_/, "evt_")
|
||||
}
|
||||
|
||||
function response(
|
||||
tokens: TokenUsageInfo | undefined,
|
||||
error: { readonly type: string; readonly message: string } | undefined,
|
||||
terminal: "succeeded" | "failed" | "interrupted",
|
||||
cancelled: boolean,
|
||||
finish: SessionMessageAssistant["finish"],
|
||||
retry?: RetryStatus,
|
||||
): PromptResponse {
|
||||
if (error?.type === "provider.auth") throw new ACPError.AuthRequiredError()
|
||||
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
|
||||
throw new ACPError.ServiceFailureError({
|
||||
safeMessage: error.message || "OpenCode prompt failed",
|
||||
service: "session",
|
||||
errorName: error.type,
|
||||
})
|
||||
}
|
||||
const usage = tokens
|
||||
? {
|
||||
inputTokens: tokens.input,
|
||||
outputTokens: tokens.output,
|
||||
totalTokens: TokenUsage.total(tokens),
|
||||
...(tokens.reasoning > 0 ? { thoughtTokens: tokens.reasoning } : {}),
|
||||
...(tokens.cache.read > 0 ? { cachedReadTokens: tokens.cache.read } : {}),
|
||||
...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
|
||||
}
|
||||
: undefined
|
||||
const stopReason = resolveStopReason({ terminal, cancelled, finish, error: error?.type })
|
||||
// Only an interrupt during backoff leaves a retry pending. Interruption clears the projected retry, so report it here.
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
|
||||
}
|
||||
|
||||
function resolveStopReason(input: {
|
||||
readonly terminal: "succeeded" | "failed" | "interrupted"
|
||||
readonly cancelled: boolean
|
||||
readonly finish: SessionMessageAssistant["finish"]
|
||||
readonly error?: string
|
||||
}): PromptResponse["stopReason"] {
|
||||
if (input.cancelled || input.terminal === "interrupted" || input.error === "aborted") return "cancelled"
|
||||
if (input.finish === "length") return "max_tokens"
|
||||
if (input.finish === "content-filter" || input.error === "provider.content-filter") return "refusal"
|
||||
return "end_turn"
|
||||
}
|
||||
|
||||
export * as ACPEvent from "./event"
|
||||
@@ -1,62 +1,53 @@
|
||||
import type { PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
|
||||
import type { EventSubscribeOutput, OpenCodeClient, PermissionReplyInput } from "@opencode/client/promise"
|
||||
import type { EventSubscribeOutput, OpenCodeClient } from "@opencode/client/promise"
|
||||
import { Patch } from "@opencode/util/patch"
|
||||
import { Cause, Effect } from "effect"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { ACPPromise } from "./promise"
|
||||
import { absolutePath, filePath, patchHunks, pendingToolCall, stringValue, toLocations, type ToolInput } from "./tool"
|
||||
import {
|
||||
absolutePath,
|
||||
filePath,
|
||||
patchHunks,
|
||||
pendingToolCall,
|
||||
stringValue,
|
||||
toLocations,
|
||||
type ToolInput,
|
||||
} from "./tool"
|
||||
|
||||
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
|
||||
type Connection = Pick<ACPConnection.Connection, "requestPermission">
|
||||
type Tool = { readonly name: string; readonly input: ToolInput }
|
||||
|
||||
type Input = {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly event: PermissionEvent
|
||||
readonly sessionID: string
|
||||
readonly clientSessionID: string
|
||||
readonly cwd: string
|
||||
readonly tool?: Tool
|
||||
readonly toolCallPrefix?: string
|
||||
readonly titlePrefix?: string
|
||||
}
|
||||
|
||||
const options: PermissionOption[] = [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
]
|
||||
|
||||
/**
|
||||
* Asks the client, then replies to the server. Once `cancelled` completes, the client's request is cancelled or never
|
||||
* sent, and the server gets `reject`. The server reply is uninterruptible, so a server that is alive but stuck can
|
||||
* hold a cancel past `CancelDrainTimeout`; a dead server fails fast.
|
||||
*/
|
||||
export const reply = Effect.fn("cli.acp.permission.reply")(function* (input: Input, cancelled: Effect.Effect<void>) {
|
||||
yield* Effect.uninterruptibleMask((restore) =>
|
||||
// The race starts racers in order and stops once one is done, so an earlier cancel never starts the ask.
|
||||
restore(cancelled.pipe(Effect.as("reject" as const), Effect.raceFirst(ask(input)))).pipe(
|
||||
Effect.tapCauseIf(Cause.hasDies, (cause) => Effect.logWarning("ACP permission ask failed", cause)),
|
||||
Effect.catchCause(() => Effect.succeed("reject" as const)),
|
||||
Effect.flatMap((decision) => respond(input, decision)),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
export async function replyPermission(input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: Connection
|
||||
readonly event: PermissionEvent
|
||||
readonly sessionID: string
|
||||
readonly clientSessionID?: string
|
||||
readonly cwd: string
|
||||
readonly tool?: Tool
|
||||
readonly toolCallPrefix?: string
|
||||
readonly titlePrefix?: string
|
||||
readonly signal?: AbortSignal
|
||||
}) {
|
||||
const toolName = input.tool?.name ?? input.event.data.action
|
||||
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
|
||||
const previews = yield* permissionPreviews(toolName, toolInput, input.cwd)
|
||||
const previews = await permissionPreviews(toolName, toolInput, input.cwd)
|
||||
const toolCallID = input.event.data.source?.id ?? input.event.data.id
|
||||
const result = yield* input.connection.requestPermission({
|
||||
sessionId: input.clientSessionID,
|
||||
const title = permissionTitle(toolName, toolInput, previews)
|
||||
const request = {
|
||||
sessionId: input.clientSessionID ?? input.sessionID,
|
||||
toolCall: {
|
||||
...pendingToolCall({
|
||||
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
|
||||
toolName,
|
||||
state: {
|
||||
input: toolInput,
|
||||
title: prefixedTitle(input.titlePrefix, permissionTitle(toolName, toolInput, previews)),
|
||||
title: prefixedTitle(input.titlePrefix, title),
|
||||
},
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
@@ -64,15 +55,18 @@ const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
...(previews.length > 0 ? { content: previews } : {}),
|
||||
},
|
||||
options,
|
||||
}
|
||||
// An already-cancelled turn skips the round-trip; the SDK would still send the request and then cancel it.
|
||||
const result = input.signal?.aborted
|
||||
? undefined
|
||||
: await input.connection.requestPermission(request, { cancellationSignal: input.signal }).catch(() => undefined)
|
||||
const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
|
||||
const reply = selected === "once" || selected === "always" ? selected : "reject"
|
||||
await input.client.permission.reply({
|
||||
sessionID: input.sessionID,
|
||||
requestID: input.event.data.id,
|
||||
decision: reply,
|
||||
})
|
||||
const selected = result.outcome.outcome === "selected" ? result.outcome.optionId : undefined
|
||||
return selected === "once" || selected === "always" ? selected : "reject"
|
||||
})
|
||||
|
||||
function respond(input: Input, decision: PermissionReplyInput["decision"]) {
|
||||
return ACPPromise.promise(() =>
|
||||
input.client.permission.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision }),
|
||||
)
|
||||
}
|
||||
|
||||
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||
@@ -81,50 +75,45 @@ function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||
return `${prefix}: ${title}`
|
||||
}
|
||||
|
||||
const permissionPreviews = Effect.fnUntraced(function* (toolName: string, input: ToolInput, cwd: string) {
|
||||
async function permissionPreviews(toolName: string, input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
if (tool === "patch" || tool === "apply_patch") return yield* patchPreviews(input, cwd)
|
||||
if (tool === "patch" || tool === "apply_patch") return patchPreviews(input, cwd)
|
||||
const file = filePath(input)
|
||||
if (!file) return []
|
||||
const path = absolutePath(file, cwd)
|
||||
const oldText = await readText(path)
|
||||
if (tool === "write") {
|
||||
const content = stringValue(input.content)
|
||||
if (content === undefined) return []
|
||||
const oldText = yield* readText(path)
|
||||
return [diff(path, oldText, content)]
|
||||
return content === undefined ? [] : [{ type: "diff", path, oldText, newText: content }]
|
||||
}
|
||||
if (tool !== "edit") return []
|
||||
const oldString = stringValue(input.oldString)
|
||||
const newString = stringValue(input.newString)
|
||||
if (oldString === undefined || newString === undefined) return []
|
||||
const oldText = yield* readText(path)
|
||||
const newText =
|
||||
input.replaceAll === true ? oldText.replaceAll(oldString, newString) : oldText.replace(oldString, newString)
|
||||
return [diff(path, oldText, newText)]
|
||||
})
|
||||
|
||||
// Patch.derive throws when a hunk does not match the current file; the patch then gets no previews.
|
||||
function patchPreviews(input: ToolInput, cwd: string) {
|
||||
return Effect.forEach(
|
||||
patchHunks(input),
|
||||
(hunk) =>
|
||||
Effect.gen(function* () {
|
||||
const path = absolutePath(hunk.path, cwd)
|
||||
if (hunk.type === "add") {
|
||||
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||
return diff(path, "", newText)
|
||||
}
|
||||
const oldText = yield* readText(path)
|
||||
if (hunk.type === "delete") return diff(path, oldText, "")
|
||||
const derived = yield* Effect.try(() => Patch.derive(hunk.path, hunk.chunks, oldText))
|
||||
return diff(hunk.movePath ? absolutePath(hunk.movePath, cwd) : path, oldText, derived.content)
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.orElseSucceed((): ToolCallContent[] => []))
|
||||
return [{ type: "diff", path, oldText, newText }]
|
||||
}
|
||||
|
||||
function diff(path: string, oldText: string, newText: string): ToolCallContent {
|
||||
return { type: "diff", path, oldText, newText }
|
||||
function patchPreviews(input: ToolInput, cwd: string): Promise<ToolCallContent[]> {
|
||||
// Patch.derive throws when a hunk does not match the current file.
|
||||
return Promise.all(
|
||||
patchHunks(input).map(async (hunk): Promise<ToolCallContent> => {
|
||||
const path = absolutePath(hunk.path, cwd)
|
||||
if (hunk.type === "add") {
|
||||
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||
return { type: "diff", path, oldText: "", newText }
|
||||
}
|
||||
const oldText = await readText(path)
|
||||
if (hunk.type === "delete") return { type: "diff", path, oldText, newText: "" }
|
||||
return {
|
||||
type: "diff",
|
||||
path: hunk.movePath ? absolutePath(hunk.movePath, cwd) : path,
|
||||
oldText,
|
||||
newText: Patch.derive(hunk.path, hunk.chunks, oldText).content,
|
||||
}
|
||||
}),
|
||||
).catch(() => [])
|
||||
}
|
||||
|
||||
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<ToolCallContent>) {
|
||||
@@ -161,9 +150,10 @@ function permissionLocations(
|
||||
return resources.filter((resource) => resource !== "*").map((path) => ({ path: absolutePath(path, cwd) }))
|
||||
}
|
||||
|
||||
// A missing file previews as empty.
|
||||
function readText(path: string) {
|
||||
return Effect.tryPromise(() => Bun.file(path).text()).pipe(Effect.orElseSucceed(() => ""))
|
||||
return Bun.file(path)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
}
|
||||
|
||||
export * as ACPPermission from "./permission"
|
||||
@@ -1,21 +0,0 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { ClientError, isInvalidRequestError, isSessionNotFoundError } from "@opencode/client/promise"
|
||||
import { Effect } from "effect"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
/** Runs a promise, keeping ACP failures typed. Any other rejection is a defect. */
|
||||
export const promise = <A>(evaluate: (signal: AbortSignal) => Promise<A>) =>
|
||||
Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(Effect.catch(classify))
|
||||
|
||||
export function classify(cause: unknown): Effect.Effect<never, ACPError.Error | RequestError> {
|
||||
if (cause instanceof RequestError || ACPError.is(cause)) return Effect.fail(cause)
|
||||
if (cause instanceof ClientError && cause.reason === "Transport")
|
||||
return Effect.fail(new ACPError.ServerUnavailableError())
|
||||
if (isSessionNotFoundError(cause))
|
||||
return Effect.fail(new ACPError.SessionNotFoundError({ sessionId: cause.sessionID }))
|
||||
if (isInvalidRequestError(cause))
|
||||
return Effect.fail(new ACPError.InvalidRequestError({ message: cause.message, field: cause.field }))
|
||||
return Effect.die(cause)
|
||||
}
|
||||
|
||||
export * as ACPPromise from "./promise"
|
||||
+253
-127
@@ -1,14 +1,9 @@
|
||||
import {
|
||||
isInvalidRequestError,
|
||||
isSessionNotFoundError,
|
||||
type ModelRef,
|
||||
type OpenCodeClient,
|
||||
type SessionMessageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { isSessionNotFoundError, type CommandInfo, type ModelRef, type OpenCodeClient } from "@opencode/client/promise"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Effect, Option, Ref, Result, Stream } from "effect"
|
||||
import { Effect, Option, Ref, Stream, type Scope } from "effect"
|
||||
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
|
||||
import type {
|
||||
AgentConnection,
|
||||
AuthenticateRequest,
|
||||
AuthenticateResponse,
|
||||
AuthMethod,
|
||||
@@ -38,22 +33,35 @@ import type {
|
||||
SetSessionModeResponse,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { ACPCatalog, Catalog } from "./catalog"
|
||||
import { configOptions, currentModel, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { ACPDirectories } from "./directories"
|
||||
import { DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
|
||||
import { ACPConnection } from "./connection"
|
||||
import { promptContentToParts } from "./content"
|
||||
import {
|
||||
ChildSessionUpdateMethod,
|
||||
ChildSessionUpdatesCapability,
|
||||
replayMessages,
|
||||
streamTurn,
|
||||
type ChildSessionUpdate,
|
||||
type TurnControl,
|
||||
type TurnStart,
|
||||
} from "./event"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
import type { ACPSessions, Attached } from "./sessions"
|
||||
import { ACPTranslate } from "./translate"
|
||||
import type { ACPTurn } from "./turn"
|
||||
import { ACPSessions, builtinCommands, type Attached } from "./sessions"
|
||||
|
||||
export const AuthMethodID = "opencode-login"
|
||||
|
||||
export type Failure = ACPError.Error | RequestError | ACPCatalog.Error
|
||||
type PreparedPrompt = {
|
||||
readonly start: TurnStart
|
||||
readonly text: string
|
||||
readonly files: Array<{ readonly uri: string; readonly name?: string }>
|
||||
readonly synthetic: ReadonlyArray<string>
|
||||
readonly slash?: { readonly name: string; readonly args: string }
|
||||
readonly command?: CommandInfo
|
||||
}
|
||||
|
||||
/** What the client advertised in `initialize`. */
|
||||
export type Capabilities = { readonly childSessionUpdates: boolean; readonly formElicitation: boolean }
|
||||
export type Failure = ACPError.Error | RequestError | ACPCatalog.Error
|
||||
|
||||
export interface Interface {
|
||||
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse>
|
||||
@@ -63,26 +71,32 @@ export interface Interface {
|
||||
readonly listSessions: (input: ListSessionsRequest) => Effect.Effect<ListSessionsResponse, Failure>
|
||||
readonly deleteSession: (input: DeleteSessionRequest) => Effect.Effect<DeleteSessionResponse, Failure>
|
||||
readonly resumeSession: (input: ResumeSessionRequest) => Effect.Effect<ResumeSessionResponse, Failure>
|
||||
readonly closeSession: (input: CloseSessionRequest) => Effect.Effect<CloseSessionResponse, Failure>
|
||||
readonly forkSession: (input: ForkSessionRequest) => Effect.Effect<ForkSessionResponse, Failure>
|
||||
readonly setSessionConfigOption: (
|
||||
input: SetSessionConfigOptionRequest,
|
||||
) => Effect.Effect<SetSessionConfigOptionResponse, Failure>
|
||||
readonly setSessionMode: (input: SetSessionModeRequest) => Effect.Effect<SetSessionModeResponse, Failure>
|
||||
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, Failure>
|
||||
readonly cancel: (input: CancelNotification) => Effect.Effect<void>
|
||||
closeSession(input: CloseSessionRequest): Promise<CloseSessionResponse>
|
||||
prompt(input: PromptRequest, signal?: AbortSignal): Promise<PromptResponse>
|
||||
cancel(input: CancelNotification): Promise<void>
|
||||
}
|
||||
|
||||
export function make(input: {
|
||||
export const make = Effect.fnUntraced(function* (input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly connection: AgentConnection
|
||||
readonly catalog: ACPCatalog.Interface
|
||||
readonly sessions: ACPSessions.Interface
|
||||
readonly capabilities: Ref.Ref<Capabilities>
|
||||
readonly turn: ACPTurn.Interface
|
||||
}): Interface {
|
||||
const currentOptions = Effect.fnUntraced(function* (attached: Attached) {
|
||||
return configOptions(yield* input.catalog.get(attached.cwd), yield* Ref.get(attached.selection))
|
||||
readonly run: <A, E>(effect: Effect.Effect<A, E, Scope.Scope>) => Promise<A>
|
||||
}) {
|
||||
const connection = ACPConnection.service(input.connection)
|
||||
// The turn still runs on promises.
|
||||
const turnConnection = ACPConnection.make(input.connection)
|
||||
const sessions = yield* ACPSessions.make({ client: input.client, connection, catalog: input.catalog })
|
||||
const capabilities = yield* Ref.make({ childSessionUpdates: false })
|
||||
const active = new Map<string, { readonly control: TurnControl; readonly turn: Promise<PromptResponse> }>()
|
||||
|
||||
const configOptions = Effect.fnUntraced(function* (attached: Attached) {
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
return ACPSessions.configOptions(catalog, yield* Ref.get(attached.selection))
|
||||
})
|
||||
|
||||
// A selection the catalog has not seen may be new on the server, so reload once before rejecting it.
|
||||
@@ -94,67 +108,81 @@ export function make(input: {
|
||||
}
|
||||
|
||||
const selectOption = Effect.fnUntraced(function* (attached: Attached, configId: string, value: string) {
|
||||
if (configId === "mode") return yield* selectMode(attached, value)
|
||||
if (configId !== "model" && configId !== "effort") return yield* new ACPError.InvalidConfigOptionError({ configId })
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const current = currentModel(catalog, yield* Ref.get(attached.selection))
|
||||
switch (configId) {
|
||||
case "model":
|
||||
return yield* selectModel(attached, yield* requireModel(catalog, value, current))
|
||||
case "effort":
|
||||
return yield* selectModel(attached, yield* requireEffort(catalog, value, current))
|
||||
case "mode":
|
||||
return yield* selectMode(attached, value)
|
||||
default:
|
||||
return yield* new ACPError.InvalidConfigOptionError({ configId })
|
||||
}
|
||||
})
|
||||
|
||||
// Both selectors update the selection before switching on the server, so the echoed event diffs to no change.
|
||||
const selectModel = Effect.fnUntraced(function* (attached: Attached, model: ModelRef) {
|
||||
const current = ACPSessions.currentModel(catalog, yield* Ref.get(attached.selection))
|
||||
const model =
|
||||
configId === "model"
|
||||
? yield* requireModel(catalog, value, current)
|
||||
: yield* requireEffort(catalog, value, current)
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, model }))
|
||||
yield* ACPPromise.promise(() => input.client.session.switchModel({ sessionID: attached.id, model }))
|
||||
yield* ACPError.promise(() => input.client.session.switchModel({ sessionID: attached.id, model }))
|
||||
})
|
||||
|
||||
const selectMode = Effect.fnUntraced(function* (attached: Attached, modeID: string) {
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
if (!catalog.modes.some((mode) => mode.id === modeID)) return yield* new ACPError.InvalidModeError({ mode: modeID })
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, modeID }))
|
||||
yield* ACPPromise.promise(() => input.client.session.switchAgent({ sessionID: attached.id, agent: modeID }))
|
||||
yield* ACPError.promise(() => input.client.session.switchAgent({ sessionID: attached.id, agent: modeID }))
|
||||
})
|
||||
|
||||
const getSession = Effect.fnUntraced(function* (sessionID: string, cwd: string) {
|
||||
const session = yield* ACPPromise.promise(() => input.client.session.get({ sessionID }))
|
||||
const session = yield* ACPError.promise(() => input.client.session.get({ sessionID }).catch(notFound(sessionID)))
|
||||
if (FSUtil.resolve(cwd) !== FSUtil.resolve(session.location.directory))
|
||||
return yield* new ACPError.SessionDirectoryMismatchError({ sessionId: sessionID, cwd })
|
||||
return session
|
||||
})
|
||||
|
||||
const replay = (attached: Attached) =>
|
||||
Stream.paginate(undefined, (cursor: string | undefined) =>
|
||||
ACPPromise.promise(() =>
|
||||
cursor
|
||||
? input.client.message.list({ sessionID: attached.id, limit: 200, cursor })
|
||||
Stream.paginate(Option.none<string>(), (cursor) =>
|
||||
ACPError.promise(() =>
|
||||
Option.isSome(cursor)
|
||||
? input.client.message.list({ sessionID: attached.id, limit: 200, cursor: cursor.value })
|
||||
: input.client.message.list({ sessionID: attached.id, limit: 200, order: "asc" }),
|
||||
).pipe(Effect.map((page) => [page.data, Option.fromNullishOr(page.cursor.next)] as const)),
|
||||
).pipe(Stream.runForEach((message) => replayMessage(attached, message)))
|
||||
).pipe(
|
||||
Effect.map(
|
||||
(page) => [page.data, Option.fromNullishOr(page.cursor.next).pipe(Option.map(Option.some))] as const,
|
||||
),
|
||||
),
|
||||
).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.flatMap((messages) =>
|
||||
ACPError.promise(() => replayMessages(turnConnection, attached.id, attached.cwd, messages)),
|
||||
),
|
||||
)
|
||||
|
||||
// A message that fails to translate keeps the updates before the failure and does not stop the replay.
|
||||
const replayMessage = Effect.fnUntraced(function* (attached: Attached, message: SessionMessageInfo) {
|
||||
const updates = ACPTranslate.replayMessage(message, attached.cwd)
|
||||
while (true) {
|
||||
const next = yield* Effect.result(Effect.try(() => updates.next()))
|
||||
if (Result.isFailure(next))
|
||||
return yield* Effect.logWarning("ACP replay skipped the rest of a message", message.id, next.failure.cause)
|
||||
if (next.success.done) return
|
||||
yield* input.connection.sessionUpdate({ sessionId: attached.id, update: next.success.value })
|
||||
}
|
||||
const sendUsageUpdate = Effect.fnUntraced(function* (attached: Attached, used: number | undefined) {
|
||||
if (!used) return
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const current = ACPSessions.currentModel(catalog, yield* Ref.get(attached.selection))
|
||||
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
|
||||
if (!model?.limit.context) return
|
||||
const info = yield* ACPError.promise(() => input.client.session.get({ sessionID: attached.id }))
|
||||
yield* connection.sessionUpdate({
|
||||
sessionId: attached.id,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used,
|
||||
size: model.limit.context,
|
||||
cost: { amount: info.cost, currency: "USD" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const cancelTurn = (sessionID: string) => {
|
||||
const turn = active.get(sessionID)
|
||||
if (turn) {
|
||||
turn.control.cancelled = true
|
||||
turn.control.admission.abort()
|
||||
}
|
||||
return input.client.session.interrupt({ sessionID })
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: Effect.fnUntraced(function* (params) {
|
||||
const elicitation = params.clientCapabilities?.elicitation
|
||||
yield* Ref.set(input.capabilities, {
|
||||
childSessionUpdates: params.clientCapabilities?._meta?.[ACPTranslate.ChildSessionUpdatesCapability] === true,
|
||||
formElicitation: elicitation?.form !== undefined && elicitation.form !== null,
|
||||
yield* Ref.set(capabilities, {
|
||||
childSessionUpdates: params.clientCapabilities?._meta?.[ChildSessionUpdatesCapability] === true,
|
||||
})
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
@@ -172,8 +200,8 @@ export function make(input: {
|
||||
loadSession: true,
|
||||
mcpCapabilities: { http: true, sse: false },
|
||||
promptCapabilities: { embeddedContext: true, image: true },
|
||||
sessionCapabilities: { additionalDirectories: {}, close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
||||
_meta: { [ACPTranslate.ChildSessionUpdatesCapability]: true },
|
||||
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
|
||||
_meta: { [ChildSessionUpdatesCapability]: true },
|
||||
},
|
||||
authMethods: [authMethod],
|
||||
agentInfo: { name: "OpenCode", version: OPENCODE_VERSION },
|
||||
@@ -185,32 +213,23 @@ export function make(input: {
|
||||
return {}
|
||||
}),
|
||||
newSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
// Load before creating so a catalog failure leaves no session behind. Agent and model stay unset
|
||||
// so the server resolves its defaults after plugins activate.
|
||||
yield* input.catalog.get(params.cwd)
|
||||
const created = yield* ACPPromise.promise(() =>
|
||||
input.client.session.create({
|
||||
location: { directory: params.cwd },
|
||||
...ACPDirectories.grant(directories),
|
||||
}),
|
||||
const created = yield* ACPError.promise(() =>
|
||||
input.client.session.create({ location: { directory: params.cwd } }),
|
||||
)
|
||||
const attached = yield* input.sessions.attach(created, params.cwd, params.mcpServers)
|
||||
return { sessionId: attached.id, configOptions: yield* currentOptions(attached) }
|
||||
const attached = yield* sessions.attach(created, params.cwd, params.mcpServers)
|
||||
return { sessionId: attached.id, configOptions: yield* configOptions(attached) }
|
||||
}),
|
||||
loadSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
const session = yield* getSession(params.sessionId, params.cwd)
|
||||
yield* ACPDirectories.activate(input.client, session, directories)
|
||||
const attached = yield* input.sessions.attach(session, session.location.directory, params.mcpServers)
|
||||
return yield* replay(attached).pipe(
|
||||
Effect.andThen(currentOptions(attached)),
|
||||
Effect.map((configOptions) => ({ configOptions })),
|
||||
Effect.onError(() => input.sessions.detach(attached.id)),
|
||||
)
|
||||
const attached = yield* sessions.attach(session, session.location.directory, params.mcpServers)
|
||||
yield* replay(attached)
|
||||
return { configOptions: yield* configOptions(attached) }
|
||||
}),
|
||||
listSessions: Effect.fnUntraced(function* (params) {
|
||||
const page = yield* ACPPromise.promise(() =>
|
||||
const page = yield* ACPError.promise(() =>
|
||||
input.client.session.list({
|
||||
...(params.cwd ? { directory: params.cwd } : {}),
|
||||
order: "desc",
|
||||
@@ -219,70 +238,169 @@ export function make(input: {
|
||||
}),
|
||||
)
|
||||
return {
|
||||
sessions: page.data.map((session) => {
|
||||
const additionalDirectories = ACPDirectories.list(session)
|
||||
return {
|
||||
sessionId: session.id,
|
||||
cwd: session.location.directory,
|
||||
...(additionalDirectories.length > 0 ? { additionalDirectories } : {}),
|
||||
title: withTimestampedFallback(session),
|
||||
updatedAt: new Date(session.time.updated).toISOString(),
|
||||
}
|
||||
}),
|
||||
sessions: page.data.map((session) => ({
|
||||
sessionId: session.id,
|
||||
cwd: session.location.directory,
|
||||
title: withTimestampedFallback(session),
|
||||
updatedAt: new Date(session.time.updated).toISOString(),
|
||||
})),
|
||||
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
|
||||
}
|
||||
}),
|
||||
deleteSession: Effect.fnUntraced(function* (params) {
|
||||
// A malformed ID fails the server's path decode, and the session ID is the only path param.
|
||||
yield* ACPPromise.promise(() =>
|
||||
input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
|
||||
if (isSessionNotFoundError(error) || (isInvalidRequestError(error) && error.kind === "Params")) return
|
||||
throw error
|
||||
}),
|
||||
)
|
||||
yield* input.sessions.detach(params.sessionId)
|
||||
yield* ACPError.promise(() =>
|
||||
input.client.session.remove({ sessionID: params.sessionId }).catch(notFound(params.sessionId)),
|
||||
).pipe(Effect.catchTag("ACPSessionNotFoundError", () => Effect.void))
|
||||
yield* sessions.detach(params.sessionId)
|
||||
return {}
|
||||
}),
|
||||
resumeSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
const session = yield* getSession(params.sessionId, params.cwd)
|
||||
yield* ACPDirectories.activate(input.client, session, directories)
|
||||
const attached = yield* input.sessions.attach(session, session.location.directory, params.mcpServers ?? [])
|
||||
return { configOptions: yield* currentOptions(attached) }
|
||||
}),
|
||||
closeSession: Effect.fnUntraced(function* (params) {
|
||||
yield* input.turn.close(params.sessionId)
|
||||
yield* input.sessions.detach(params.sessionId)
|
||||
return {}
|
||||
const attached = yield* sessions.attach(session, session.location.directory, params.mcpServers ?? [])
|
||||
return { configOptions: yield* configOptions(attached) }
|
||||
}),
|
||||
forkSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
const forked = yield* ACPPromise.promise(() => input.client.session.fork({ sessionID: params.sessionId }))
|
||||
// Forks copy the source session's rules, so the request list replaces any inherited grants.
|
||||
yield* ACPDirectories.activate(input.client, forked, directories)
|
||||
const attached = yield* input.sessions.attach(forked, forked.location.directory, params.mcpServers ?? [])
|
||||
return yield* currentOptions(attached).pipe(
|
||||
Effect.map((configOptions) => ({ sessionId: attached.id, configOptions })),
|
||||
Effect.onError(() => input.sessions.detach(attached.id)),
|
||||
)
|
||||
const forked = yield* ACPError.promise(() => input.client.session.fork({ sessionID: params.sessionId }))
|
||||
const attached = yield* sessions.attach(forked, forked.location.directory, params.mcpServers ?? [])
|
||||
yield* replay(attached)
|
||||
return { sessionId: attached.id, configOptions: yield* configOptions(attached) }
|
||||
}),
|
||||
setSessionConfigOption: Effect.fnUntraced(function* (params) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
const attached = yield* sessions.require(params.sessionId)
|
||||
const value = params.value
|
||||
if (typeof value !== "string") return yield* new ACPError.InvalidConfigOptionError({ configId: params.configId })
|
||||
yield* withReload(attached, selectOption(attached, params.configId, value))
|
||||
return { configOptions: yield* currentOptions(attached) }
|
||||
return { configOptions: yield* configOptions(attached) }
|
||||
}),
|
||||
setSessionMode: Effect.fnUntraced(function* (params) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
const attached = yield* sessions.require(params.sessionId)
|
||||
yield* withReload(attached, selectMode(attached, params.modeId))
|
||||
return {}
|
||||
}),
|
||||
prompt: input.turn.prompt,
|
||||
cancel: input.turn.cancel,
|
||||
closeSession: async (params) => {
|
||||
const turn = active.get(params.sessionId)
|
||||
await cancelTurn(params.sessionId).catch((error) => {
|
||||
if (!isSessionNotFoundError(error)) throw error
|
||||
})
|
||||
await turn?.turn.catch(() => {})
|
||||
await input.run(sessions.detach(params.sessionId))
|
||||
return {}
|
||||
},
|
||||
prompt: async (params, signal) => {
|
||||
// Read everything first so the active check and registration below stay synchronous.
|
||||
const resolved = await input.run(
|
||||
Effect.gen(function* () {
|
||||
const attached = yield* sessions.require(params.sessionId)
|
||||
return {
|
||||
attached,
|
||||
catalog: yield* input.catalog.get(attached.cwd),
|
||||
childSessionUpdates: (yield* Ref.get(capabilities)).childSessionUpdates,
|
||||
}
|
||||
}),
|
||||
)
|
||||
const state = resolved.attached
|
||||
if (active.has(state.id)) {
|
||||
throw new ACPError.ServiceFailureError({
|
||||
safeMessage: `Session already has an active ACP prompt: ${state.id}`,
|
||||
service: "session",
|
||||
})
|
||||
}
|
||||
const messageID = SessionMessage.ID.create()
|
||||
const prepared = preparePrompt(resolved.catalog, params.prompt, messageID)
|
||||
const control: TurnControl = { cancelled: false, admission: new AbortController() }
|
||||
const extNotification = turnConnection.extNotification
|
||||
const childSessionUpdate =
|
||||
resolved.childSessionUpdates && extNotification
|
||||
? (update: ChildSessionUpdate) => extNotification(ChildSessionUpdateMethod, update).then(() => {})
|
||||
: undefined
|
||||
// A `$/cancel_request` for this prompt behaves like `session/cancel` for its turn.
|
||||
const cancel = () => void cancelTurn(state.id).catch(() => {})
|
||||
const turn = streamTurn({
|
||||
client: input.client,
|
||||
connection: turnConnection,
|
||||
sessionID: state.id,
|
||||
cwd: state.cwd,
|
||||
start: prepared.start,
|
||||
action: prepared.command !== undefined,
|
||||
control,
|
||||
connectionSignal: turnConnection.signal,
|
||||
sessionSignal: state.signal,
|
||||
submit: (signal) => submitPrompt(input.client, state, prepared, signal),
|
||||
...(childSessionUpdate ? { childSessionUpdate } : {}),
|
||||
})
|
||||
.then(async (result) => {
|
||||
await input.run(sendUsageUpdate(state, result.contextTokens)).catch(() => {})
|
||||
return result.response
|
||||
})
|
||||
.finally(() => {
|
||||
signal?.removeEventListener("abort", cancel)
|
||||
if (active.get(state.id)?.control === control) active.delete(state.id)
|
||||
})
|
||||
active.set(state.id, { control, turn })
|
||||
signal?.addEventListener("abort", cancel, { once: true })
|
||||
// The cancel may already be buffered behind the awaits above.
|
||||
if (signal?.aborted) cancel()
|
||||
return turn
|
||||
},
|
||||
cancel: async (params) => {
|
||||
await cancelTurn(params.sessionId).catch(() => {})
|
||||
},
|
||||
} satisfies Interface
|
||||
})
|
||||
|
||||
function notFound(sessionID: string) {
|
||||
return (error: unknown): never => {
|
||||
throw isSessionNotFoundError(error) ? new ACPError.SessionNotFoundError({ sessionId: sessionID }) : error
|
||||
}
|
||||
}
|
||||
|
||||
function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messageID: string): PreparedPrompt {
|
||||
const parts = promptContentToParts(prompt)
|
||||
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
|
||||
const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
|
||||
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
|
||||
const slash = detectSlashCommand(text)
|
||||
const command =
|
||||
slash && !builtinCommands.has(slash.name) ? catalog.commands.find((item) => item.name === slash.name) : undefined
|
||||
const start = turnStart(messageID, slash)
|
||||
return { start, text, files, synthetic, slash, command }
|
||||
}
|
||||
|
||||
async function submitPrompt(client: OpenCodeClient, session: Attached, prompt: PreparedPrompt, signal: AbortSignal) {
|
||||
if (prompt.synthetic.length > 0) {
|
||||
await client.session.synthetic({
|
||||
sessionID: session.id,
|
||||
text: prompt.synthetic.join("\n\n"),
|
||||
description: "ACP embedded context",
|
||||
delivery: "steer",
|
||||
resume: false,
|
||||
})
|
||||
}
|
||||
if (prompt.start.type === "compaction") return client.session.compact({ sessionID: session.id, id: prompt.start.id })
|
||||
if (prompt.command) {
|
||||
return client.session.command(
|
||||
{
|
||||
sessionID: session.id,
|
||||
name: prompt.command.name,
|
||||
text: prompt.slash?.args ?? "",
|
||||
files: prompt.files,
|
||||
delivery: "steer",
|
||||
},
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
return client.session.prompt(
|
||||
{ sessionID: session.id, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
|
||||
{ signal },
|
||||
)
|
||||
}
|
||||
|
||||
function turnStart(messageID: string, slash: PreparedPrompt["slash"]): TurnStart {
|
||||
if (slash && builtinCommands.get(slash.name)?.start === "compaction") return { type: "compaction", id: messageID }
|
||||
return { type: "input", id: messageID }
|
||||
}
|
||||
|
||||
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: string, current: ModelRef) {
|
||||
const selected = parseModelSelection(modelID, catalog.providers)
|
||||
const model = catalog.models.find(
|
||||
@@ -308,4 +426,12 @@ const requireEffort = Effect.fnUntraced(function* (catalog: Catalog, effort: str
|
||||
return { ...current, variant: effort } satisfies ModelRef
|
||||
})
|
||||
|
||||
function detectSlashCommand(text: string): { readonly name: string; readonly args: string } | undefined {
|
||||
const value = text.trim()
|
||||
if (!value.startsWith("/")) return undefined
|
||||
const [name, ...rest] = value.slice(1).split(/\s+/)
|
||||
if (!name) return undefined
|
||||
return { name, args: rest.join(" ").trim() }
|
||||
}
|
||||
|
||||
export * as ACPService from "./service"
|
||||
@@ -1,24 +1,35 @@
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import type { McpServer, RequestError } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient, OpenCodeEvent, SessionInfo } from "@opencode/client/promise"
|
||||
import { Context, Deferred, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"
|
||||
import type { ModelRef, OpenCodeClient, SessionInfo } from "@opencode/client/promise"
|
||||
import { Cause, Context, Effect, Exit, Ref, Scope, Stream } from "effect"
|
||||
import type { ACPCatalog, Catalog } from "./catalog"
|
||||
import { availableCommands, configOptions, type Selection } from "./config-option"
|
||||
import { ACPConnection } from "./connection"
|
||||
import { buildConfigOptions } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
|
||||
// ACP runs these itself; they take precedence over server commands with the same name.
|
||||
export const builtinCommands = new Map([
|
||||
["compact", { description: "Compact the session", start: "compaction" as const }],
|
||||
])
|
||||
|
||||
/** Unset fields follow the server defaults. */
|
||||
export type Selection = {
|
||||
readonly model?: ModelRef
|
||||
readonly modeID?: string
|
||||
}
|
||||
|
||||
export type Attached = {
|
||||
readonly id: string
|
||||
readonly cwd: string
|
||||
readonly selection: Ref.Ref<Selection>
|
||||
/** Aborted when the session detaches, for the promise-based turn. */
|
||||
readonly signal: AbortSignal
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Attaches a session in its own scope, closing any previous attachment of the same ID. Once the attaching request
|
||||
* has responded, the scope follows the cwd's catalog and pushes config option and command updates while it is
|
||||
* open. A failed attach leaves the session detached.
|
||||
* Attaches a session in its own scope, closing any previous attachment of the same ID. The scope follows the
|
||||
* cwd's catalog and pushes config option and command updates while it is open.
|
||||
*/
|
||||
readonly attach: (
|
||||
session: SessionInfo,
|
||||
@@ -28,61 +39,37 @@ export interface Interface {
|
||||
/** Closes the session scope. No-op when the session is not attached. */
|
||||
readonly detach: (sessionID: string) => Effect.Effect<void>
|
||||
readonly require: (sessionID: string) => Effect.Effect<Attached, ACPError.SessionNotFoundError>
|
||||
/** Forks work into this attachment's scope, so it ends on detach or re-attach. Fails once the attachment is gone. */
|
||||
readonly fork: (attached: Attached, effect: Effect.Effect<void>) => Effect.Effect<void, ACPError.SessionNotFoundError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Sessions") {}
|
||||
|
||||
type Entry = {
|
||||
readonly attached: Attached
|
||||
readonly scope: Scope.Closeable
|
||||
/** Selection changes from other clients, applied by the session's fold. */
|
||||
readonly selected: Queue.Queue<Selection>
|
||||
}
|
||||
|
||||
type SelectedEvent = Extract<OpenCodeEvent, { type: "session.model.selected" | "session.agent.selected" }>
|
||||
|
||||
export const make = Effect.fnUntraced(function* (input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly catalog: ACPCatalog.Interface
|
||||
}) {
|
||||
const scope = yield* Effect.scope
|
||||
const sessions = new Map<string, Entry>()
|
||||
const sessions = new Map<string, { readonly attached: Attached; readonly scope: Scope.Closeable }>()
|
||||
// Kept across re-attachment so resuming with the same servers does not add them again.
|
||||
const registeredMcp = new Map<string, Set<string>>()
|
||||
const connected = yield* Deferred.make<void>()
|
||||
|
||||
// Subscribe before any attach so a switch right after `sessions.set` reaches the session.
|
||||
yield* Stream.fromAsyncIterable(input.client.event.subscribe(), (cause) => cause).pipe(
|
||||
Stream.tap((event) => (event.type === "server.connected" ? Deferred.succeed(connected, undefined) : Effect.void)),
|
||||
Stream.filter(
|
||||
(event): event is SelectedEvent =>
|
||||
event.type === "session.model.selected" || event.type === "session.agent.selected",
|
||||
),
|
||||
Stream.runForEach((event) => {
|
||||
const entry = sessions.get(event.data.sessionID)
|
||||
if (!entry) return Effect.void
|
||||
return Queue.offer(
|
||||
entry.selected,
|
||||
event.type === "session.model.selected" ? { model: event.data.model } : { modeID: event.data.agent },
|
||||
)
|
||||
}),
|
||||
Effect.ignore,
|
||||
Effect.ensuring(Deferred.succeed(connected, undefined)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
const sendCommands = (sessionID: string, catalog: Catalog) =>
|
||||
input.connection.sessionUpdate({
|
||||
sessionId: sessionID,
|
||||
update: { sessionUpdate: "available_commands_update", availableCommands: availableCommands(catalog) },
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
...catalog.commands
|
||||
.filter((command) => !builtinCommands.has(command.name))
|
||||
.map((command) => ({ name: command.name, description: command.description ?? "" })),
|
||||
...Array.from(builtinCommands, ([name, command]) => ({ name, description: command.description })),
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const changed = Effect.fnUntraced(function* (attached: Attached, previous: Catalog, next: Catalog, patch: Selection) {
|
||||
const selection = yield* Ref.getAndUpdate(attached.selection, (current) => ({ ...current, ...patch }))
|
||||
const options = configOptions(next, { ...selection, ...patch })
|
||||
const changed = Effect.fnUntraced(function* (attached: Attached, previous: Catalog, next: Catalog) {
|
||||
const selection = yield* Ref.get(attached.selection)
|
||||
const options = configOptions(next, selection)
|
||||
if (!isDeepStrictEqual(options, configOptions(previous, selection))) {
|
||||
yield* input.connection.sessionUpdate({
|
||||
sessionId: attached.id,
|
||||
@@ -104,83 +91,82 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const key = `${server.name}:${stableStringify(config)}`
|
||||
if (registered.has(key)) return Effect.void
|
||||
registered.add(key)
|
||||
return ACPPromise.promise(() =>
|
||||
return ACPError.promise(() =>
|
||||
input.client.mcp.add({ server: server.name, location: { directory: attached.cwd }, config }),
|
||||
).pipe(
|
||||
Effect.onError(() => Effect.sync(() => registered.delete(key))),
|
||||
Effect.uninterruptible,
|
||||
// An interrupted add still completes on the server, so only a failed one is forgotten.
|
||||
Effect.tapCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.sync(() => registered.delete(key)),
|
||||
),
|
||||
)
|
||||
}),
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
const remove = (sessionID: string, entry: Entry) =>
|
||||
Effect.suspend(() => {
|
||||
if (sessions.get(sessionID) === entry) {
|
||||
sessions.delete(sessionID)
|
||||
registeredMcp.delete(sessionID)
|
||||
}
|
||||
return Scope.close(entry.scope, Exit.void)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
attach: Effect.fn("cli.acp.sessions.attach")(function* (session, cwd, mcpServers) {
|
||||
yield* Deferred.await(connected)
|
||||
const current = yield* input.catalog.get(cwd)
|
||||
const entry: Entry = {
|
||||
attached: {
|
||||
id: session.id,
|
||||
cwd,
|
||||
selection: yield* Ref.make<Selection>({ model: session.model, modeID: session.agent }),
|
||||
},
|
||||
scope: Scope.forkUnsafe(scope),
|
||||
selected: yield* Queue.unbounded<Selection>(),
|
||||
const abort = new AbortController()
|
||||
const attached: Attached = {
|
||||
id: session.id,
|
||||
cwd,
|
||||
selection: yield* Ref.make<Selection>({ model: session.model, modeID: session.agent }),
|
||||
signal: abort.signal,
|
||||
}
|
||||
// Swap synchronously so concurrent attaches of one ID cannot both keep a scope.
|
||||
const sessionScope = Scope.forkUnsafe(scope)
|
||||
const replaced = sessions.get(session.id)
|
||||
sessions.set(session.id, entry)
|
||||
sessions.set(session.id, { attached, scope: sessionScope })
|
||||
if (replaced) yield* Scope.close(replaced.scope, Exit.void)
|
||||
yield* registerMcp(entry.attached, mcpServers).pipe(Effect.onError(() => remove(session.id, entry)))
|
||||
const responded = yield* ACPConnection.Responded
|
||||
// Updates wait for the response that hands the client this session. `changes` emits the latest catalog
|
||||
// first, so a reload since `current` is still pushed. One fold applies catalog and selection changes so
|
||||
// pushes leave the client on the latest pair.
|
||||
yield* Effect.gen(function* () {
|
||||
yield* responded
|
||||
yield* sendCommands(session.id, current)
|
||||
yield* Stream.merge(
|
||||
input.catalog.changes(cwd).pipe(Stream.map((catalog) => ({ catalog, patch: {} }))),
|
||||
Stream.fromQueue(entry.selected).pipe(Stream.map((patch) => ({ catalog: undefined, patch }))),
|
||||
).pipe(
|
||||
Stream.runFoldEffect(
|
||||
() => current,
|
||||
(previous, step) => {
|
||||
const next = step.catalog ?? previous
|
||||
return changed(entry.attached, previous, next, step.patch).pipe(Effect.ignore, Effect.as(next))
|
||||
},
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.ignore, Effect.forkIn(entry.scope))
|
||||
return entry.attached
|
||||
yield* Scope.addFinalizer(
|
||||
sessionScope,
|
||||
Effect.sync(() => abort.abort()),
|
||||
)
|
||||
yield* input.catalog.changes(cwd).pipe(
|
||||
Stream.runFoldEffect(
|
||||
() => current,
|
||||
(previous, next) =>
|
||||
next === previous
|
||||
? Effect.succeed(previous)
|
||||
: changed(attached, previous, next).pipe(Effect.ignore, Effect.as(next)),
|
||||
),
|
||||
Effect.ignore,
|
||||
Effect.forkIn(sessionScope),
|
||||
)
|
||||
yield* registerMcp(attached, mcpServers)
|
||||
yield* sendCommands(attached.id, yield* input.catalog.get(cwd))
|
||||
return attached
|
||||
}),
|
||||
detach: Effect.fn("cli.acp.sessions.detach")(function* (sessionID) {
|
||||
const entry = sessions.get(sessionID)
|
||||
if (entry) yield* remove(sessionID, entry)
|
||||
sessions.delete(sessionID)
|
||||
registeredMcp.delete(sessionID)
|
||||
if (entry) yield* Scope.close(entry.scope, Exit.void)
|
||||
}),
|
||||
require: Effect.fn("cli.acp.sessions.require")(function* (sessionID) {
|
||||
const entry = sessions.get(sessionID)
|
||||
if (!entry) return yield* new ACPError.SessionNotFoundError({ sessionId: sessionID })
|
||||
return entry.attached
|
||||
}),
|
||||
fork: Effect.fn("cli.acp.sessions.fork")(function* (attached, effect) {
|
||||
const entry = sessions.get(attached.id)
|
||||
if (entry?.attached !== attached) return yield* new ACPError.SessionNotFoundError({ sessionId: attached.id })
|
||||
yield* Effect.forkIn(effect, entry.scope, { startImmediately: true })
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
export function currentModel(catalog: Catalog, selection: Selection) {
|
||||
return selection.model ?? catalog.defaultModel
|
||||
}
|
||||
|
||||
export function configOptions(catalog: Catalog, selection: Selection) {
|
||||
const model = currentModel(catalog, selection)
|
||||
return buildConfigOptions({
|
||||
providers: catalog.providers,
|
||||
currentModel: { providerID: model.providerID, modelID: model.id },
|
||||
currentVariant: model.variant,
|
||||
modes: catalog.modes,
|
||||
currentModeId: selection.modeID ?? catalog.defaultModeID,
|
||||
})
|
||||
}
|
||||
|
||||
function mcpConfig(server: McpServer) {
|
||||
if ("type" in server) {
|
||||
if (server.type === "acp") throw new Error("MCP-over-ACP is not supported")
|
||||
|
||||
@@ -1,633 +0,0 @@
|
||||
import type { PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionStructuredError,
|
||||
TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import { partsToContentChunks, type ReplayPart } from "./content"
|
||||
import { ACPError } from "./error"
|
||||
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate, type ToolInput } from "./tool"
|
||||
|
||||
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
|
||||
export const ChildSessionUpdateMethod = "opencode/session/child_update"
|
||||
const RetryMeta = "opencode/retry"
|
||||
const CompactionMeta = "opencode/compaction"
|
||||
|
||||
export type TurnStart = { readonly type: "input" | "compaction"; readonly id: string }
|
||||
|
||||
export type Terminal = "succeeded" | "failed" | "interrupted"
|
||||
|
||||
export type Context = {
|
||||
readonly sessionID: string
|
||||
readonly cwd: string
|
||||
readonly start: TurnStart
|
||||
readonly childUpdates: boolean
|
||||
/** A background consumer follows open children after the parent turn ends; it never writes `session/update`. */
|
||||
readonly mode: "turn" | "background"
|
||||
}
|
||||
|
||||
type Tool = {
|
||||
readonly sessionID: string
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly input: ToolInput
|
||||
readonly metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
type RetryStatus = {
|
||||
readonly attempt: number
|
||||
readonly nextRetryAt: string
|
||||
readonly error: SessionStructuredError
|
||||
}
|
||||
|
||||
export type ChildSession = {
|
||||
readonly id: string
|
||||
readonly parentID: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
}
|
||||
|
||||
type ChildSessionEvent =
|
||||
| { readonly type: "update"; readonly update: SessionUpdate }
|
||||
| {
|
||||
readonly type: "status"
|
||||
readonly status: "created" | "running" | "completed" | "failed" | "interrupted"
|
||||
readonly error?: { readonly type: string; readonly message: string }
|
||||
}
|
||||
|
||||
export type ChildSessionUpdate = {
|
||||
readonly rootSessionId: string
|
||||
readonly childSessionId: string
|
||||
readonly parentSessionId: string
|
||||
readonly depth: number
|
||||
readonly title?: string
|
||||
} & ChildSessionEvent
|
||||
|
||||
export type TurnState = {
|
||||
readonly started: boolean
|
||||
readonly tools: ReadonlyMap<string, Tool>
|
||||
readonly retries: ReadonlyMap<string, RetryStatus>
|
||||
readonly compactions: ReadonlyMap<string, string>
|
||||
readonly children: ReadonlyMap<string, ChildSession>
|
||||
readonly openChildren: ReadonlySet<string>
|
||||
/** Forms asked of the client that the server has not yet answered or cancelled. */
|
||||
readonly forms: ReadonlySet<string>
|
||||
readonly finish?: SessionMessageAssistant["finish"]
|
||||
readonly usage?: { readonly turn: TokenUsageInfo; readonly last: TokenUsageInfo }
|
||||
readonly stepError?: SessionStructuredError
|
||||
readonly executionError?: { readonly type: string; readonly message: string }
|
||||
}
|
||||
|
||||
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
|
||||
type FormEvent = Extract<EventSubscribeOutput, { type: "form.created" }>
|
||||
|
||||
export type Output =
|
||||
| { readonly _tag: "SessionUpdate"; readonly update: SessionUpdate }
|
||||
| { readonly _tag: "ChildUpdate"; readonly update: ChildSessionUpdate }
|
||||
| {
|
||||
readonly _tag: "PermissionAsk"
|
||||
readonly event: PermissionEvent
|
||||
readonly tool?: Tool
|
||||
readonly child?: ChildSession
|
||||
}
|
||||
| {
|
||||
readonly _tag: "FormAsk"
|
||||
readonly form: FormEvent["data"]["form"]
|
||||
readonly child?: ChildSession
|
||||
/** Whether the form's session sends its tool calls to the client as `session/update` tool calls. */
|
||||
readonly toolCallSent: boolean
|
||||
}
|
||||
| { readonly _tag: "FormSettled"; readonly formID: string }
|
||||
|
||||
export type Step = {
|
||||
readonly state: TurnState
|
||||
readonly outputs: ReadonlyArray<Output>
|
||||
readonly terminal?: Terminal
|
||||
}
|
||||
|
||||
type CompactionEvent = Extract<
|
||||
EventSubscribeOutput,
|
||||
{ readonly type: "session.compaction.started" | "session.compaction.ended" | "session.compaction.failed" }
|
||||
>
|
||||
|
||||
type CompactionMarker = {
|
||||
readonly status: "started" | "completed" | "failed"
|
||||
readonly messageId: string
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error?: SessionStructuredError
|
||||
}
|
||||
|
||||
export const initial: TurnState = {
|
||||
started: false,
|
||||
tools: new Map(),
|
||||
retries: new Map(),
|
||||
compactions: new Map(),
|
||||
children: new Map(),
|
||||
openChildren: new Set(),
|
||||
forms: new Set(),
|
||||
}
|
||||
|
||||
export function step(state: TurnState, event: EventSubscribeOutput, ctx: Context): Step {
|
||||
if (event.type === "session.created") {
|
||||
const parentID = event.data.parentID
|
||||
if (!parentID) return { state, outputs: [] }
|
||||
const parent = parentID === ctx.sessionID ? undefined : state.children.get(parentID)
|
||||
if (!parent && (ctx.mode === "background" || parentID !== ctx.sessionID)) return { state, outputs: [] }
|
||||
const child = { id: event.data.sessionID, parentID, depth: parent ? parent.depth + 1 : 1, title: event.data.title }
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
children: new Map(state.children).set(child.id, child),
|
||||
openChildren: new Set(state.openChildren).add(child.id),
|
||||
},
|
||||
outputs: childStatus(ctx, child, { type: "status", status: "created" }),
|
||||
}
|
||||
}
|
||||
|
||||
const eventSessionID = sessionIDFromEvent(event)
|
||||
const child = eventSessionID ? state.children.get(eventSessionID) : undefined
|
||||
if (ctx.mode === "background" && !child) return { state, outputs: [] }
|
||||
const send = (update: SessionUpdate) => route(ctx, child, update)
|
||||
|
||||
if (event.type === "permission.asked" && (event.data.sessionID === ctx.sessionID || child)) {
|
||||
const tool = event.data.source?.id
|
||||
? state.tools.get(toolKey(event.data.sessionID, event.data.source.id))
|
||||
: undefined
|
||||
return { state, outputs: [{ _tag: "PermissionAsk", event, tool, child }] }
|
||||
}
|
||||
if (event.type === "form.created" && (event.data.form.sessionID === ctx.sessionID || child)) {
|
||||
return {
|
||||
state: { ...state, forms: new Set(state.forms).add(event.data.form.id) },
|
||||
outputs: [
|
||||
{
|
||||
_tag: "FormAsk",
|
||||
form: event.data.form,
|
||||
child,
|
||||
toolCallSent: ctx.mode === "turn" && (!child || !ctx.childUpdates),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
if ((event.type === "form.replied" || event.type === "form.cancelled") && state.forms.has(event.data.id)) {
|
||||
const forms = new Set(state.forms)
|
||||
forms.delete(event.data.id)
|
||||
return { state: { ...state, forms }, outputs: [{ _tag: "FormSettled", formID: event.data.id }] }
|
||||
}
|
||||
if (!eventSessionID || (eventSessionID !== ctx.sessionID && !child)) return { state, outputs: [] }
|
||||
if (matchesStart(event, ctx.start)) return { state: { ...state, started: true }, outputs: [] }
|
||||
if (!state.started) return { state, outputs: [] }
|
||||
|
||||
switch (event.type) {
|
||||
case "session.execution.started":
|
||||
return { state, outputs: child ? childStatus(ctx, child, { type: "status", status: "running" }) : [] }
|
||||
case "session.step.started": {
|
||||
const next = child ? state : { ...state, stepError: undefined }
|
||||
if (!state.retries.has(eventSessionID)) return { state: next, outputs: [] }
|
||||
return {
|
||||
state: { ...next, retries: without(state.retries, eventSessionID) },
|
||||
outputs: send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: null } }),
|
||||
}
|
||||
}
|
||||
case "session.retry.scheduled": {
|
||||
const retry = {
|
||||
attempt: event.data.attempt,
|
||||
nextRetryAt: new Date(event.data.at).toISOString(),
|
||||
error: event.data.error,
|
||||
}
|
||||
return {
|
||||
state: { ...state, retries: new Map(state.retries).set(eventSessionID, retry) },
|
||||
outputs: send({ sessionUpdate: "session_info_update", _meta: { [RetryMeta]: retry } }),
|
||||
}
|
||||
}
|
||||
case "session.compaction.started":
|
||||
case "session.compaction.ended":
|
||||
case "session.compaction.failed": {
|
||||
const compacted = compactionMarker(event, state.compactions)
|
||||
return {
|
||||
state: { ...state, compactions: compacted.compactions },
|
||||
outputs: compacted.marker ? send(compactionUpdate(compacted.marker)) : [],
|
||||
}
|
||||
}
|
||||
case "session.text.delta":
|
||||
return {
|
||||
state,
|
||||
outputs: send({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
messageId: event.data.assistantMessageID,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
}),
|
||||
}
|
||||
case "session.reasoning.delta":
|
||||
return {
|
||||
state,
|
||||
outputs: send({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
|
||||
content: { type: "text", text: event.data.delta },
|
||||
}),
|
||||
}
|
||||
case "session.tool.input.started":
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
tools: new Map(state.tools).set(
|
||||
toolKey(event.data.sessionID, event.data.id),
|
||||
newTool(event.data.sessionID, event.data.id, event.data.name),
|
||||
),
|
||||
},
|
||||
outputs: send({
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: event.data.id,
|
||||
toolName: event.data.name,
|
||||
state: { input: {} },
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
case "session.tool.called": {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const tool = {
|
||||
...(state.tools.get(key) ?? newTool(event.data.sessionID, event.data.id)),
|
||||
input: event.data.input,
|
||||
}
|
||||
return {
|
||||
state: { ...state, tools: new Map(state.tools).set(key, tool) },
|
||||
outputs: send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: tool.name,
|
||||
state: { input: tool.input },
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
case "session.tool.progress": {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const current = state.tools.get(key)
|
||||
if (!current) return { state, outputs: [] }
|
||||
return {
|
||||
state: { ...state, tools: new Map(state.tools).set(key, { ...current, metadata: event.data.metadata }) },
|
||||
outputs: send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: current.name,
|
||||
state: { input: current.input },
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
case "session.tool.success": {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const tool = state.tools.get(key) ?? newTool(event.data.sessionID, event.data.id)
|
||||
return {
|
||||
state: { ...state, tools: without(state.tools, key) },
|
||||
outputs: send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: event.data.metadata,
|
||||
content: event.data.content,
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
case "session.tool.failed": {
|
||||
const key = toolKey(event.data.sessionID, event.data.id)
|
||||
const tool = state.tools.get(key) ?? newTool(event.data.sessionID, event.data.id)
|
||||
return {
|
||||
state: { ...state, tools: without(state.tools, key) },
|
||||
outputs: send({
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: event.data.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: event.data.metadata ?? tool.metadata,
|
||||
content: event.data.content ?? [],
|
||||
error: event.data.error.message,
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
case "session.step.ended":
|
||||
if (child) return { state, outputs: [] }
|
||||
return { state: { ...recordStep(state, event.data.tokens), finish: event.data.finish }, outputs: [] }
|
||||
case "session.step.failed": {
|
||||
if (child) return { state, outputs: [] }
|
||||
const recorded = event.data.tokens ? recordStep(state, event.data.tokens) : state
|
||||
return { state: { ...recorded, stepError: event.data.error }, outputs: [] }
|
||||
}
|
||||
case "session.execution.succeeded":
|
||||
if (!child) return { state, outputs: [], terminal: "succeeded" }
|
||||
return childEnded(state, ctx, child, { type: "status", status: "completed" }, "succeeded")
|
||||
case "session.execution.interrupted":
|
||||
if (!child) return { state, outputs: [], terminal: "interrupted" }
|
||||
return childEnded(state, ctx, child, { type: "status", status: "interrupted" }, "interrupted")
|
||||
case "session.execution.failed":
|
||||
if (!child) return { state: { ...state, executionError: event.data.error }, outputs: [], terminal: "failed" }
|
||||
return childEnded(state, ctx, child, { type: "status", status: "failed", error: event.data.error }, "failed")
|
||||
default:
|
||||
return { state, outputs: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/** The ACP failure a settled turn reports instead of a response, if any. */
|
||||
export function failure(state: TurnState) {
|
||||
const error = state.stepError ?? state.executionError
|
||||
if (error?.type === "provider.auth") return new ACPError.AuthRequiredError()
|
||||
if (error && error.type !== "aborted" && error.type !== "provider.content-filter") {
|
||||
return new ACPError.ServiceFailureError({
|
||||
safeMessage: error.message || "OpenCode prompt failed",
|
||||
service: "session",
|
||||
errorName: error.type,
|
||||
})
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function response(state: TurnState, sessionID: string, terminal: Terminal): PromptResponse {
|
||||
const tokens = state.usage?.turn
|
||||
const usage = tokens
|
||||
? {
|
||||
inputTokens: tokens.input,
|
||||
outputTokens: tokens.output,
|
||||
totalTokens: TokenUsage.total(tokens),
|
||||
...(tokens.reasoning > 0 ? { thoughtTokens: tokens.reasoning } : {}),
|
||||
...(tokens.cache.read > 0 ? { cachedReadTokens: tokens.cache.read } : {}),
|
||||
...(tokens.cache.write > 0 ? { cachedWriteTokens: tokens.cache.write } : {}),
|
||||
}
|
||||
: undefined
|
||||
const error = (state.stepError ?? state.executionError)?.type
|
||||
const stopReason = resolveStopReason({ terminal, finish: state.finish, error })
|
||||
// Only an interrupt during backoff leaves a retry pending. Interruption clears the projected retry, so report it here.
|
||||
const retry = state.retries.get(sessionID)
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
|
||||
}
|
||||
|
||||
/** Fails the tools a cancelled turn left open, for when the server's wind-down never reports them. */
|
||||
export function abandonTools(state: TurnState, ctx: Context): Step {
|
||||
return {
|
||||
state: { ...state, tools: new Map() },
|
||||
outputs: [...state.tools.values()].flatMap((tool) =>
|
||||
route(ctx, state.children.get(tool.sessionID), {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: tool.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: tool.metadata,
|
||||
content: [],
|
||||
error: "Cancelled",
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/** Lazy, so a message that fails to translate part way still replays the updates before the failure. */
|
||||
export function* replayMessage(message: SessionMessageInfo, cwd: string): Generator<SessionUpdate> {
|
||||
if (message.type === "user") {
|
||||
yield { sessionUpdate: "user_message_chunk", messageId: message.id, content: { type: "text", text: message.text } }
|
||||
const files: ReplayPart[] = (message.files ?? []).map((file) => ({
|
||||
type: "file",
|
||||
url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`,
|
||||
filename: file.name,
|
||||
mime: file.mime,
|
||||
}))
|
||||
for (const chunk of partsToContentChunks(files))
|
||||
yield { sessionUpdate: "user_message_chunk", messageId: message.id, ...chunk }
|
||||
return
|
||||
}
|
||||
// A running compaction has no live turn on this connection to settle it, so replay only settled ones.
|
||||
if (message.type === "compaction" && message.status !== "running") {
|
||||
yield compactionUpdate({
|
||||
status: message.status,
|
||||
messageId: message.id,
|
||||
reason: message.reason,
|
||||
...(message.status === "failed" ? { error: message.error } : {}),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (message.type !== "assistant") return
|
||||
// Live reasoning ordinals count only reasoning parts, not the mixed content array.
|
||||
let reasoningOrdinal = 0
|
||||
for (const part of message.content) {
|
||||
if (part.type === "text") {
|
||||
yield { sessionUpdate: "agent_message_chunk", messageId: message.id, content: { type: "text", text: part.text } }
|
||||
continue
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
yield {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
messageId: `${message.id}:reasoning:${reasoningOrdinal++}`,
|
||||
content: { type: "text", text: part.text },
|
||||
}
|
||||
continue
|
||||
}
|
||||
yield {
|
||||
sessionUpdate: "tool_call",
|
||||
...pendingToolCall({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
state: { input: part.state.status === "streaming" ? {} : part.state.input },
|
||||
cwd,
|
||||
}),
|
||||
}
|
||||
switch (part.state.status) {
|
||||
case "completed":
|
||||
yield {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...completedToolUpdate({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
cwd,
|
||||
}),
|
||||
}
|
||||
break
|
||||
case "running":
|
||||
yield {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...runningToolUpdate({ toolCallId: part.id, toolName: part.name, state: { input: part.state.input }, cwd }),
|
||||
}
|
||||
break
|
||||
case "error":
|
||||
yield {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: part.id,
|
||||
toolName: part.name,
|
||||
input: part.state.input,
|
||||
metadata: part.state.metadata,
|
||||
content: part.state.content,
|
||||
error: part.state.error.message,
|
||||
cwd,
|
||||
}),
|
||||
}
|
||||
break
|
||||
case "streaming":
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function newTool(sessionID: string, id: string, name = "tool"): Tool {
|
||||
return { sessionID, id, name, input: {}, metadata: {} }
|
||||
}
|
||||
|
||||
function route(ctx: Context, child: ChildSession | undefined, update: SessionUpdate): Output[] {
|
||||
if (!child) return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update }] : []
|
||||
const projected = projectChildUpdate(update, child)
|
||||
if (ctx.childUpdates) return childStatus(ctx, child, { type: "update", update: projected })
|
||||
return ctx.mode === "turn" ? [{ _tag: "SessionUpdate", update: projected }] : []
|
||||
}
|
||||
|
||||
function childStatus(ctx: Context, child: ChildSession, value: ChildSessionEvent): Output[] {
|
||||
if (!ctx.childUpdates) return []
|
||||
return [
|
||||
{
|
||||
_tag: "ChildUpdate",
|
||||
update: {
|
||||
rootSessionId: ctx.sessionID,
|
||||
childSessionId: child.id,
|
||||
parentSessionId: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
...value,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// A background consumer ends once its last open child settles.
|
||||
function childEnded(
|
||||
state: TurnState,
|
||||
ctx: Context,
|
||||
child: ChildSession,
|
||||
status: ChildSessionEvent,
|
||||
terminal: Terminal,
|
||||
): Step {
|
||||
const openChildren = new Set(state.openChildren)
|
||||
openChildren.delete(child.id)
|
||||
return {
|
||||
state: { ...state, openChildren },
|
||||
outputs: childStatus(ctx, child, status),
|
||||
...(ctx.mode === "background" && openChildren.size === 0 ? { terminal } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function recordStep(state: TurnState, tokens: TokenUsageInfo): TurnState {
|
||||
const turn = state.usage?.turn
|
||||
return {
|
||||
...state,
|
||||
usage: {
|
||||
turn: turn
|
||||
? {
|
||||
input: turn.input + tokens.input,
|
||||
output: turn.output + tokens.output,
|
||||
reasoning: turn.reasoning + tokens.reasoning,
|
||||
cache: { read: turn.cache.read + tokens.cache.read, write: turn.cache.write + tokens.cache.write },
|
||||
}
|
||||
: tokens,
|
||||
last: tokens,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function without<K, V>(map: ReadonlyMap<K, V>, key: K) {
|
||||
const next = new Map(map)
|
||||
next.delete(key)
|
||||
return next
|
||||
}
|
||||
|
||||
function sessionIDFromEvent(event: EventSubscribeOutput) {
|
||||
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
|
||||
if (event.type === "form.created") return event.data.form.sessionID
|
||||
return undefined
|
||||
}
|
||||
|
||||
function toolKey(sessionID: string, id: string) {
|
||||
return `${sessionID}:${id}`
|
||||
}
|
||||
|
||||
// Message IDs follow core's compaction message projection, so live markers match replayed ones.
|
||||
function compactionMarker(event: CompactionEvent, compactions: ReadonlyMap<string, string>) {
|
||||
const sessionID = event.data.sessionID
|
||||
if (event.type === "session.compaction.started") {
|
||||
const messageId = event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id))
|
||||
const marker: CompactionMarker = { status: "started", messageId, reason: event.data.reason }
|
||||
return { marker, compactions: new Map(compactions).set(sessionID, messageId) }
|
||||
}
|
||||
const tracked = compactions.get(sessionID)
|
||||
const remaining = without(compactions, sessionID)
|
||||
if (event.type === "session.compaction.ended") {
|
||||
const marker: CompactionMarker | undefined = tracked
|
||||
? { status: "completed", messageId: tracked, reason: event.data.reason }
|
||||
: undefined
|
||||
return { marker, compactions: remaining }
|
||||
}
|
||||
// Automatic compaction can fail before it starts, for example when there is nothing to compact yet.
|
||||
const marker: CompactionMarker = {
|
||||
status: "failed",
|
||||
messageId: tracked ?? event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id)),
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
}
|
||||
return { marker, compactions: remaining }
|
||||
}
|
||||
|
||||
function compactionUpdate(marker: CompactionMarker): SessionUpdate {
|
||||
return { sessionUpdate: "session_info_update", _meta: { [CompactionMeta]: marker } }
|
||||
}
|
||||
|
||||
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||
const projected = { ...update }
|
||||
projected._meta = {
|
||||
...projected._meta,
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
|
||||
projected.toolCallId = `${child.id}:${projected.toolCallId}`
|
||||
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
|
||||
}
|
||||
return projected
|
||||
}
|
||||
|
||||
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
|
||||
return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
|
||||
}
|
||||
|
||||
function resolveStopReason(input: {
|
||||
readonly terminal: Terminal
|
||||
readonly finish: SessionMessageAssistant["finish"]
|
||||
readonly error?: string
|
||||
}): PromptResponse["stopReason"] {
|
||||
if (input.terminal === "interrupted" || input.error === "aborted") return "cancelled"
|
||||
if (input.finish === "length") return "max_tokens"
|
||||
if (input.finish === "content-filter" || input.error === "provider.content-filter") return "refusal"
|
||||
return "end_turn"
|
||||
}
|
||||
|
||||
export * as ACPTranslate from "./translate"
|
||||
@@ -1,455 +0,0 @@
|
||||
import type { CancelNotification, PromptRequest, PromptResponse, RequestError } from "@agentclientprotocol/sdk"
|
||||
import {
|
||||
isSessionNotFoundError,
|
||||
type CommandInfo,
|
||||
type OpenCodeClient,
|
||||
type OpenCodeEvent,
|
||||
} from "@opencode/client/promise"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import {
|
||||
Cause,
|
||||
Context,
|
||||
Deferred,
|
||||
Duration,
|
||||
Effect,
|
||||
Exit,
|
||||
Fiber,
|
||||
FiberMap,
|
||||
Option,
|
||||
Queue,
|
||||
Ref,
|
||||
Scope,
|
||||
Stream,
|
||||
} from "effect"
|
||||
import { access, constants } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { builtinCommands, type ACPCatalog, type Catalog } from "./catalog"
|
||||
import { currentModel } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { linkReference, promptContentToParts, type PromptPart } from "./content"
|
||||
import { ACPElicitation } from "./elicitation"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPermission } from "./permission"
|
||||
import { ACPPromise } from "./promise"
|
||||
import type { ACPService } from "./service"
|
||||
import type { ACPSessions, Attached } from "./sessions"
|
||||
import { ACPTranslate } from "./translate"
|
||||
|
||||
type Failure = ACPError.Error | RequestError | ACPCatalog.Error
|
||||
|
||||
export interface Interface {
|
||||
/**
|
||||
* Runs the session's only turn. Cancelling it, including through the request's `$/cancel_request` signal,
|
||||
* interrupts the turn and still resolves with `stopReason: "cancelled"`.
|
||||
*/
|
||||
readonly prompt: (input: PromptRequest, signal: AbortSignal) => Effect.Effect<PromptResponse, Failure>
|
||||
/** Interrupts the session's active turn and waits for it to settle. No-op when the session is idle. */
|
||||
readonly cancel: (input: CancelNotification) => Effect.Effect<void>
|
||||
/** Like `cancel`, but an idle session is still interrupted, since server work can outlive its turn. */
|
||||
readonly close: (sessionID: string) => Effect.Effect<void, ACPError.Error | RequestError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/acp/Turn") {}
|
||||
|
||||
/**
|
||||
* How long a cancelled turn keeps forwarding the server's wind-down. Core acknowledges an interrupt before its
|
||||
* cleanup settles, and its shell tool waits 3 seconds before escalating to SIGKILL.
|
||||
*/
|
||||
export const CancelDrainTimeout = Context.Reference<Duration.Input>("@opencode/cli/acp/Turn/CancelDrainTimeout", {
|
||||
defaultValue: () => "5 seconds",
|
||||
})
|
||||
|
||||
type PreparedPrompt = {
|
||||
readonly start: ACPTranslate.TurnStart
|
||||
readonly text: string
|
||||
readonly files: Array<{ readonly uri: string; readonly name?: string }>
|
||||
readonly synthetic: ReadonlyArray<string>
|
||||
readonly slash?: { readonly name: string; readonly args: string }
|
||||
readonly command?: CommandInfo
|
||||
}
|
||||
|
||||
type PermissionAsk = Extract<ACPTranslate.Output, { readonly _tag: "PermissionAsk" }>
|
||||
|
||||
/** A turn's event feed. It moves to the session scope when the turn ends with children still running. */
|
||||
type Subscription = {
|
||||
readonly scope: Scope.Closeable
|
||||
readonly events: Queue.Dequeue<OpenCodeEvent, unknown>
|
||||
/** Runs permission and form asks one at a time in ask order, without holding back the rest of the stream. */
|
||||
readonly asks: Queue.Queue<Effect.Effect<void, ACPError.Error | RequestError>>
|
||||
/** Completed when the turn is cancelled; pending and later asks then resolve without the client. */
|
||||
readonly cancelled: Deferred.Deferred<void>
|
||||
/** Completed per asked form once the server reports it answered or cancelled. */
|
||||
readonly forms: Map<string, Deferred.Deferred<void>>
|
||||
}
|
||||
|
||||
export const make = Effect.fnUntraced(function* (input: {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly sessions: ACPSessions.Interface
|
||||
readonly catalog: ACPCatalog.Interface
|
||||
readonly capabilities: Ref.Ref<ACPService.Capabilities>
|
||||
}) {
|
||||
const scope = yield* Effect.scope
|
||||
const drainTimeout = yield* CancelDrainTimeout
|
||||
const turns = yield* FiberMap.make<string, PromptResponse, Failure>()
|
||||
|
||||
const subscribe = Effect.fnUntraced(function* () {
|
||||
// Parented, so it still closes when the session scope it is handed to is already gone.
|
||||
const subscriptionScope = yield* Scope.fork(scope)
|
||||
const subscription: Subscription = {
|
||||
scope: subscriptionScope,
|
||||
events: yield* Stream.fromAsyncIterable(input.client.event.subscribe(), (cause) => cause).pipe(
|
||||
Stream.toQueue({ capacity: "unbounded" }),
|
||||
Scope.provide(subscriptionScope),
|
||||
),
|
||||
asks: yield* Queue.unbounded<Effect.Effect<void, ACPError.Error | RequestError>>(),
|
||||
cancelled: yield* Deferred.make<void>(),
|
||||
forms: new Map(),
|
||||
}
|
||||
yield* Queue.take(subscription.asks).pipe(
|
||||
Effect.flatten,
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause) ? Effect.void : Effect.logWarning("ACP permission reply failed", cause),
|
||||
),
|
||||
Effect.forever,
|
||||
Effect.forkIn(subscriptionScope),
|
||||
)
|
||||
return subscription
|
||||
})
|
||||
|
||||
const take = (subscription: Subscription) =>
|
||||
Queue.take(subscription.events).pipe(
|
||||
Effect.catch((error) =>
|
||||
Cause.isDone(error) ? Effect.fail(new ACPError.ServerUnavailableError()) : ACPPromise.classify(error),
|
||||
),
|
||||
)
|
||||
|
||||
// A turn settles only after the asks it saw have been resolved.
|
||||
const asksSettled = Effect.fnUntraced(function* (subscription: Subscription) {
|
||||
const settled = yield* Deferred.make<void>()
|
||||
yield* Queue.offer(subscription.asks, Deferred.succeed(settled, undefined).pipe(Effect.asVoid))
|
||||
yield* Deferred.await(settled)
|
||||
})
|
||||
|
||||
const reply = (subscription: Subscription, ctx: ACPTranslate.Context, ask: PermissionAsk) =>
|
||||
ACPPermission.reply(
|
||||
{
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
event: ask.event,
|
||||
sessionID: ask.event.data.sessionID,
|
||||
clientSessionID: ctx.sessionID,
|
||||
cwd: ctx.cwd,
|
||||
tool: ask.tool,
|
||||
...(ask.child ? { toolCallPrefix: ask.child.id, titlePrefix: ask.child.title } : {}),
|
||||
},
|
||||
Deferred.await(subscription.cancelled),
|
||||
)
|
||||
|
||||
const interpret = (subscription: Subscription, ctx: ACPTranslate.Context, output: ACPTranslate.Output) => {
|
||||
switch (output._tag) {
|
||||
case "SessionUpdate":
|
||||
return input.connection.sessionUpdate({ sessionId: ctx.sessionID, update: output.update })
|
||||
case "ChildUpdate":
|
||||
return input.connection
|
||||
.extNotification(ACPTranslate.ChildSessionUpdateMethod, output.update)
|
||||
.pipe(Effect.ignoreCause)
|
||||
case "PermissionAsk":
|
||||
return Queue.offer(subscription.asks, reply(subscription, ctx, output)).pipe(Effect.asVoid)
|
||||
case "FormAsk":
|
||||
return Effect.gen(function* () {
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
const requestedSchema = ACPElicitation.requestedSchema(output.form, capabilities)
|
||||
if (!requestedSchema) return yield* ACPElicitation.cancel(input.client, output.form)
|
||||
const settled = yield* Deferred.make<void>()
|
||||
subscription.forms.set(output.form.id, settled)
|
||||
yield* Queue.offer(
|
||||
subscription.asks,
|
||||
ACPElicitation.reply(
|
||||
{
|
||||
client: input.client,
|
||||
connection: input.connection,
|
||||
form: output.form,
|
||||
requestedSchema,
|
||||
clientSessionID: ctx.sessionID,
|
||||
child: output.child,
|
||||
toolCallSent: output.toolCallSent,
|
||||
settled: Deferred.await(settled),
|
||||
},
|
||||
Deferred.await(subscription.cancelled),
|
||||
),
|
||||
)
|
||||
})
|
||||
case "FormSettled":
|
||||
return Effect.suspend(() => {
|
||||
const settled = subscription.forms.get(output.formID)
|
||||
subscription.forms.delete(output.formID)
|
||||
return settled ? Deferred.succeed(settled, undefined) : Effect.void
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const consume = Effect.fnUntraced(function* (
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
) {
|
||||
while (true) {
|
||||
const event = yield* take(subscription)
|
||||
const next = ACPTranslate.step(yield* Ref.get(state), event, ctx)
|
||||
yield* Ref.set(state, next.state)
|
||||
yield* Effect.forEach(next.outputs, (output) => interpret(subscription, ctx, output), { discard: true })
|
||||
if (next.terminal) {
|
||||
yield* asksSettled(subscription)
|
||||
return next.terminal
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const submit = Effect.fnUntraced(function* (attached: Attached, prompt: PreparedPrompt) {
|
||||
const sessionID = attached.id
|
||||
if (prompt.synthetic.length > 0) {
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.synthetic(
|
||||
{
|
||||
sessionID,
|
||||
text: prompt.synthetic.join("\n\n"),
|
||||
description: "ACP embedded context",
|
||||
delivery: "steer",
|
||||
resume: false,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
}
|
||||
if (prompt.start.type === "compaction") {
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.compact({ sessionID, id: prompt.start.id }, { signal }),
|
||||
)
|
||||
return
|
||||
}
|
||||
const command = prompt.command
|
||||
if (command) {
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.command(
|
||||
{ sessionID, name: command.name, text: prompt.slash?.args ?? "", files: prompt.files, delivery: "steer" },
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.prompt(
|
||||
{ sessionID, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const interruptServer = (sessionID: string) =>
|
||||
ACPPromise.promise(() => input.client.session.interrupt({ sessionID })).pipe(Effect.ignoreCause)
|
||||
|
||||
// Rejects pending asks, interrupts the server once, then forwards its wind-down until the terminal event or the
|
||||
// timeout. Tools still open at the timeout are reported failed so the client never shows them running.
|
||||
const windDown = Effect.fnUntraced(function* (
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
events: Fiber.Fiber<ACPTranslate.Terminal, Failure>,
|
||||
) {
|
||||
yield* Deferred.succeed(subscription.cancelled, undefined)
|
||||
yield* interruptServer(ctx.sessionID)
|
||||
if (!(yield* Ref.get(state)).started) return
|
||||
if (Option.isSome(yield* Fiber.await(events).pipe(Effect.timeoutOption(drainTimeout)))) return
|
||||
yield* Fiber.interrupt(events)
|
||||
const abandoned = ACPTranslate.abandonTools(yield* Ref.get(state), ctx)
|
||||
yield* Ref.set(state, abandoned.state)
|
||||
yield* Effect.forEach(abandoned.outputs, (output) => interpret(subscription, ctx, output), { discard: true }).pipe(
|
||||
Effect.ignore,
|
||||
)
|
||||
})
|
||||
|
||||
const execute = (
|
||||
attached: Attached,
|
||||
prompt: PreparedPrompt,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
) =>
|
||||
Effect.acquireUseRelease(
|
||||
subscribe(),
|
||||
(subscription) =>
|
||||
Effect.gen(function* () {
|
||||
// The feed opens with `server.connected`, so every event the submission causes comes after it.
|
||||
const connected = yield* take(subscription)
|
||||
if (connected.type !== "server.connected")
|
||||
return yield* Effect.die(new Error(`expected server.connected, got ${connected.type}`))
|
||||
const events = yield* consume(subscription, ctx, state).pipe(Effect.forkScoped)
|
||||
return yield* Effect.gen(function* () {
|
||||
yield* submit(attached, prompt)
|
||||
if (prompt.command) return "succeeded" as const
|
||||
return yield* Fiber.join(events)
|
||||
}).pipe(Effect.onInterrupt(() => windDown(subscription, ctx, state, events)))
|
||||
}).pipe(Effect.scoped),
|
||||
(subscription, exit) => handoff(attached, subscription, ctx, state, exit),
|
||||
)
|
||||
|
||||
const handoff = Effect.fnUntraced(function* (
|
||||
attached: Attached,
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, Failure>,
|
||||
) {
|
||||
const close = Scope.close(subscription.scope, Exit.void)
|
||||
if (Exit.isFailure(exit) && !Cause.hasInterruptsOnly(exit.cause)) return yield* close
|
||||
if ((yield* Ref.get(state)).openChildren.size === 0) return yield* close
|
||||
// Children that outlive a cancelled turn were not cancelled, so their asks still go to the client.
|
||||
const cancelled = yield* Deferred.make<void>()
|
||||
const background = consume({ ...subscription, cancelled }, { ...ctx, mode: "background" }, state).pipe(
|
||||
Effect.ignore,
|
||||
Effect.ensuring(close),
|
||||
Effect.withSpan("cli.acp.turn.background"),
|
||||
)
|
||||
yield* input.sessions.fork(attached, background).pipe(Effect.catchTag("ACPSessionNotFoundError", () => close))
|
||||
})
|
||||
|
||||
const settle = Effect.fnUntraced(function* (
|
||||
attached: Attached,
|
||||
state: Ref.Ref<ACPTranslate.TurnState>,
|
||||
exit: Exit.Exit<ACPTranslate.Terminal, Failure>,
|
||||
) {
|
||||
if (Exit.isFailure(exit) && !Cause.hasInterrupts(exit.cause)) return yield* Effect.failCause(exit.cause)
|
||||
const current = yield* Ref.get(state)
|
||||
const failure = ACPTranslate.failure(current)
|
||||
if (failure) return yield* failure
|
||||
yield* sendUsageUpdate(attached, current)
|
||||
return ACPTranslate.response(current, attached.id, Exit.isSuccess(exit) ? exit.value : "interrupted")
|
||||
})
|
||||
|
||||
const sendUsageUpdate = Effect.fn("cli.acp.turn.usage")(
|
||||
function* (attached: Attached, state: ACPTranslate.TurnState) {
|
||||
const used = state.usage ? TokenUsage.total(state.usage.last) : 0
|
||||
if (!used) return
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const current = currentModel(catalog, yield* Ref.get(attached.selection))
|
||||
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
|
||||
if (!model?.limit.context) return
|
||||
const info = yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.get({ sessionID: attached.id }, { signal }),
|
||||
)
|
||||
yield* input.connection.sessionUpdate({
|
||||
sessionId: attached.id,
|
||||
update: {
|
||||
sessionUpdate: "usage_update",
|
||||
used,
|
||||
size: model.limit.context,
|
||||
cost: { amount: info.cost, currency: "USD" },
|
||||
},
|
||||
})
|
||||
},
|
||||
(effect) => Effect.ignoreCause(effect),
|
||||
)
|
||||
|
||||
// Forked uninterruptible: interruption reaches only `execute`, so the fiber still settles with a response.
|
||||
const run = Effect.fn("cli.acp.turn.run")(function* (
|
||||
attached: Attached,
|
||||
prompt: PreparedPrompt,
|
||||
childUpdates: boolean,
|
||||
) {
|
||||
const state = yield* Ref.make(ACPTranslate.initial)
|
||||
const ctx: ACPTranslate.Context = {
|
||||
sessionID: attached.id,
|
||||
cwd: attached.cwd,
|
||||
start: prompt.start,
|
||||
childUpdates,
|
||||
mode: "turn",
|
||||
}
|
||||
const exit = yield* Effect.exit(Effect.interruptible(execute(attached, prompt, ctx, state)))
|
||||
return yield* settle(attached, state, exit)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
prompt: Effect.fn("cli.acp.turn.prompt")(function* (params, signal) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const childUpdates = (yield* Ref.get(input.capabilities)).childSessionUpdates
|
||||
const parts = yield* Effect.forEach(promptContentToParts(params.prompt), referenceUnreadableFile, {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
const prompt = preparePrompt(catalog, parts, SessionMessage.ID.create())
|
||||
// Check and register in one synchronous step.
|
||||
const turn = yield* Effect.withFiber((fiber) => {
|
||||
if (FiberMap.hasUnsafe(turns, attached.id)) {
|
||||
return Effect.fail(
|
||||
new ACPError.ServiceFailureError({
|
||||
safeMessage: `Session already has an active ACP prompt: ${attached.id}`,
|
||||
service: "session",
|
||||
}),
|
||||
)
|
||||
}
|
||||
const forked = Effect.runForkWith(fiber.context)(run(attached, prompt, childUpdates), { uninterruptible: true })
|
||||
FiberMap.setUnsafe(turns, attached.id, forked)
|
||||
return Effect.succeed(forked)
|
||||
})
|
||||
// A `$/cancel_request` for this prompt cancels its turn like `session/cancel`, rather than failing the request.
|
||||
yield* aborted(signal).pipe(Effect.andThen(Fiber.interrupt(turn)), Effect.forkChild)
|
||||
return yield* Fiber.join(turn)
|
||||
}),
|
||||
cancel: Effect.fn("cli.acp.turn.cancel")(function* (params) {
|
||||
yield* FiberMap.remove(turns, params.sessionId)
|
||||
}),
|
||||
close: Effect.fn("cli.acp.turn.close")(function* (sessionID) {
|
||||
if (FiberMap.hasUnsafe(turns, sessionID)) return yield* FiberMap.remove(turns, sessionID)
|
||||
yield* ACPPromise.promise(() =>
|
||||
input.client.session.interrupt({ sessionID }).catch((error) => {
|
||||
if (!isSessionNotFoundError(error)) throw error
|
||||
}),
|
||||
)
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
function aborted(signal: AbortSignal) {
|
||||
return Effect.callback<void>((resume) => {
|
||||
if (signal.aborted) return resume(Effect.void)
|
||||
const abort = () => resume(Effect.void)
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
return Effect.sync(() => signal.removeEventListener("abort", abort))
|
||||
})
|
||||
}
|
||||
|
||||
function preparePrompt(catalog: Catalog, parts: readonly PromptPart[], messageID: string): PreparedPrompt {
|
||||
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
|
||||
const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
|
||||
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
const files = visible.flatMap((part) => (part.type === "file" ? [{ uri: part.url, name: part.filename }] : []))
|
||||
const slash = detectSlashCommand(text)
|
||||
const command = slash ? catalog.commands.find((item) => item.name === slash.name) : undefined
|
||||
const start = turnStart(messageID, slash)
|
||||
return { start, text, files, synthetic, slash, command }
|
||||
}
|
||||
|
||||
// Covers only missing or permission-denied targets; the server still rejects oversized, non-regular, or unlistable ones.
|
||||
function referenceUnreadableFile(part: PromptPart) {
|
||||
if (part.type !== "file" || !part.url.startsWith("file://")) return Effect.succeed(part)
|
||||
return Effect.tryPromise(() => access(fileURLToPath(part.url), constants.R_OK)).pipe(
|
||||
Effect.as(part),
|
||||
Effect.orElseSucceed(() => linkReference(part.filename, part.url)),
|
||||
)
|
||||
}
|
||||
|
||||
function turnStart(messageID: string, slash: PreparedPrompt["slash"]): ACPTranslate.TurnStart {
|
||||
if (slash && builtinCommands.get(slash.name)?.start === "compaction") return { type: "compaction", id: messageID }
|
||||
return { type: "input", id: messageID }
|
||||
}
|
||||
|
||||
function detectSlashCommand(text: string): { readonly name: string; readonly args: string } | undefined {
|
||||
const value = text.trim()
|
||||
if (!value.startsWith("/")) return undefined
|
||||
const [name, ...rest] = value.slice(1).split(/\s+/)
|
||||
if (!name) return undefined
|
||||
return { name, args: rest.join(" ").trim() }
|
||||
}
|
||||
|
||||
export * as ACPTurn from "./turn"
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MessageTooLargeError, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
|
||||
@@ -16,29 +16,25 @@ export default Runtime.handler(
|
||||
const endpoint = yield* Standalone.start()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const connection = yield* ACP.connect(client, ndJsonStream(Writable.toWeb(process.stdout), Bun.stdin.stream()))
|
||||
const failure = yield* Effect.raceFirst(
|
||||
Effect.promise(() => connection.closed).pipe(
|
||||
Effect.map(() =>
|
||||
connection.signal.reason instanceof MessageTooLargeError
|
||||
? `incoming message exceeded the ${connection.signal.reason.maxMessageBytes / 1024 / 1024} MiB limit`
|
||||
: undefined,
|
||||
),
|
||||
),
|
||||
const code = yield* Effect.raceFirst(
|
||||
Effect.promise(() => connection.closed).pipe(Effect.as(0)),
|
||||
endpoint.exited.pipe(
|
||||
Effect.match({
|
||||
onSuccess: (code) => `code ${code}`,
|
||||
onFailure: (error) =>
|
||||
error.cause instanceof CrossSpawnSpawner.KilledBySignal ? `signal ${error.cause.signal}` : error.message,
|
||||
}),
|
||||
Effect.map((reason) => `server exited unexpectedly (${reason})`),
|
||||
// stdout carries ACP, so the diagnostic goes to stderr.
|
||||
Effect.flatMap((reason) =>
|
||||
Effect.sync(() => {
|
||||
process.stderr.write(`opencode acp: server exited unexpectedly (${reason})\n`)
|
||||
return 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
// Closing the handler scope would wait for the private server's graceful shutdown; its lease pipe already
|
||||
// ends the server once this process exits.
|
||||
yield* Effect.sync(() => {
|
||||
// stdout carries ACP, so the diagnostic goes to stderr.
|
||||
if (failure) process.stderr.write(`opencode acp: ${failure}\n`)
|
||||
process.exit(failure ? 1 : 0)
|
||||
})
|
||||
yield* Effect.sync(() => process.exit(code))
|
||||
}),
|
||||
)
|
||||
@@ -4,7 +4,7 @@ import { run } from "@opencode/tui"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { Config } from "../../config"
|
||||
import { Context, Effect, FileSystem, Option, Queue, Schedule, Semaphore } from "effect"
|
||||
import { Context, Effect, Fiber, FileSystem, Option, Queue } from "effect"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { UpdatePreflight } from "../../services/update-preflight"
|
||||
@@ -61,29 +61,13 @@ export default Runtime.handler(Commands, (input) =>
|
||||
})) !== undefined
|
||||
const updater = yield* Updater.Service
|
||||
let installing: string | undefined
|
||||
let latest: Updater.RunResult | undefined
|
||||
const installListeners = new Set<(version: string) => void>()
|
||||
const resultListeners = new Set<(result: Updater.RunResult) => void>()
|
||||
// Background checks, `/update` lookups, and manual installs take turns so two installs never overlap.
|
||||
const checking = yield* Semaphore.make(1)
|
||||
yield* updater
|
||||
const updateListeners = new Set<(version: string) => void>()
|
||||
const update = yield* updater
|
||||
.run((version) => {
|
||||
installing = version
|
||||
installListeners.forEach((notify) => notify(version))
|
||||
updateListeners.forEach((notify) => notify(version))
|
||||
})
|
||||
.pipe(
|
||||
Effect.ensuring(Effect.sync(() => (installing = undefined))),
|
||||
Effect.tap((result) =>
|
||||
Effect.sync(() => {
|
||||
if (!result || (result.type === latest?.type && result.version === latest.version)) return
|
||||
latest = result
|
||||
resultListeners.forEach((notify) => notify(result))
|
||||
}),
|
||||
),
|
||||
checking.withPermits(1),
|
||||
Effect.repeat(Schedule.spaced("10 minutes")),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
.pipe(Effect.ensuring(Effect.sync(() => (installing = undefined))), Effect.forkScoped)
|
||||
preflight.loading()
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
@@ -122,19 +106,21 @@ export default Runtime.handler(Commands, (input) =>
|
||||
},
|
||||
updater: {
|
||||
remote: requestedServer !== undefined,
|
||||
subscribe: (notify) => {
|
||||
if (latest) notify(latest)
|
||||
resultListeners.add(notify)
|
||||
return () => resultListeners.delete(notify)
|
||||
},
|
||||
subscribe: (notify, signal) =>
|
||||
runPromise(
|
||||
Fiber.join(update).pipe(
|
||||
Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))),
|
||||
),
|
||||
{ signal },
|
||||
),
|
||||
check: (signal, notify) => {
|
||||
if (installing) notify(installing)
|
||||
installListeners.add(notify)
|
||||
return runPromise(checking.withPermits(1)(updater.check()), { signal }).finally(() =>
|
||||
installListeners.delete(notify),
|
||||
updateListeners.add(notify)
|
||||
return runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }).finally(() =>
|
||||
updateListeners.delete(notify),
|
||||
)
|
||||
},
|
||||
apply: (version) => runPromise(checking.withPermits(1)(updater.apply(version))),
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
},
|
||||
packages: {
|
||||
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Global } from "@opencode/util/global"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { Config } from "./config"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { EffectFlock } from "@opencode/util/effect-flock"
|
||||
import { Heap } from "./heap"
|
||||
import { CpuProfile } from "./cpu-profile"
|
||||
|
||||
@@ -114,7 +113,7 @@ Effect.gen(function* () {
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node, EffectFlock.node]), {
|
||||
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), {
|
||||
replacements: [
|
||||
Global.node.replace(
|
||||
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { EffectFlock } from "@opencode/util/effect-flock"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
@@ -128,7 +127,6 @@ const make = Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const flock = yield* EffectFlock.Service
|
||||
const installedVersion = yield* Ref.make(OPENCODE_VERSION)
|
||||
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const installedPackage = yield* Effect.gen(function* () {
|
||||
@@ -372,9 +370,6 @@ const make = Effect.gen(function* () {
|
||||
}
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
// Other OpenCode processes may be installing at the same time. Wait longer than the
|
||||
// slowest install (curl runs two 5-minute commands).
|
||||
yield* flock.acquire("cli-upgrade", undefined, { timeoutMs: Duration.toMillis("15 minutes") })
|
||||
if (method === "bun") {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import type { NewSessionResponse, PromptResponse } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { createAcpFixture, expectOk, initialize } from "./subprocess"
|
||||
|
||||
// The first completion reads the file outside cwd; the follow-up completion ends the turn.
|
||||
function readingModel(file: () => string) {
|
||||
return (request: unknown) => {
|
||||
if (JSON.stringify(request).includes('"role":"tool"')) return "done"
|
||||
return new Response(toolCall(file()), { headers: { "content-type": "text/event-stream" } })
|
||||
}
|
||||
}
|
||||
|
||||
function toolCall(file: string) {
|
||||
const call = { index: 0, id: "call_read", type: "function", function: { name: "read", arguments: "" } }
|
||||
const chunks = [
|
||||
{ choices: [{ delta: { role: "assistant", tool_calls: [call] }, finish_reason: null }], usage: null },
|
||||
{
|
||||
choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify({ path: file }) } }] } }],
|
||||
usage: null,
|
||||
},
|
||||
{ choices: [{ delta: {}, finish_reason: "tool_calls" }], usage: null },
|
||||
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } },
|
||||
]
|
||||
return `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`
|
||||
}
|
||||
|
||||
describe("acp additional directories subprocess", () => {
|
||||
test("tools read files in an additional directory without an external directory ask", async () => {
|
||||
const target = { file: "" }
|
||||
await using fixture = await createAcpFixture({ respond: readingModel(() => target.file) })
|
||||
// Keep the unresolved tmpdir spelling, which differs from the real path on macOS.
|
||||
const shared = path.join(fixture.root, "shared")
|
||||
target.file = path.join(shared, "notes.txt")
|
||||
await fs.mkdir(shared)
|
||||
await Bun.write(target.file, "shared root content\n")
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = expectOk(
|
||||
await acp.request<NewSessionResponse>("session/new", {
|
||||
cwd: fixture.home,
|
||||
additionalDirectories: [shared],
|
||||
mcpServers: [],
|
||||
}),
|
||||
)
|
||||
|
||||
const result = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "read the shared notes" }],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.stopReason).toBe("end_turn")
|
||||
expect(JSON.stringify(fixture.llm.requests.at(-1))).toContain("shared root content")
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -1,182 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import type { PermissionRule } from "@opencode/client/promise"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { makeSession, rpcError, startWire, type Wire } from "./wire-fixture"
|
||||
|
||||
const key = "opencode.acp.additionalDirectories"
|
||||
|
||||
const grant = (directory: string): PermissionRule => ({
|
||||
action: "external_directory",
|
||||
resource: path.join(directory, "*"),
|
||||
effect: "allow",
|
||||
})
|
||||
|
||||
const sharedLib = path.resolve("/shared/lib")
|
||||
const productDocs = path.resolve("/product-docs")
|
||||
const old = path.resolve("/old")
|
||||
|
||||
const userGrant: PermissionRule = { action: "external_directory", resource: "/x/**", effect: "allow" }
|
||||
|
||||
const other: PermissionRule[] = [
|
||||
{ action: "read", resource: "*.secret", effect: "deny" },
|
||||
{ action: "external_directory", resource: "/shared/lib/private/*", effect: "deny" },
|
||||
]
|
||||
|
||||
const updates = (acp: Wire) =>
|
||||
acp.server.requests.filter((request) => request.method === "PATCH" && request.path.startsWith("/api/session/"))
|
||||
|
||||
describe("acp additional directories over the wire", () => {
|
||||
test("session/new grants normalized unique directories other than cwd and lists them", async () => {
|
||||
await using acp = await startWire()
|
||||
await acp.initialize()
|
||||
|
||||
const created = await acp.request("session/new", {
|
||||
cwd: "/workspace",
|
||||
additionalDirectories: ["/shared/lib/", "/workspace", "/docs/../product-docs", "/shared/lib", "/workspace/"],
|
||||
mcpServers: [],
|
||||
})
|
||||
|
||||
expect(acp.server.sessions.get(created.sessionId)).toMatchObject({
|
||||
permissions: [grant(sharedLib), grant(productDocs)],
|
||||
metadata: { [key]: [sharedLib, productDocs] },
|
||||
})
|
||||
expect((await acp.request("session/list", { cwd: "/workspace" })).sessions).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionId: created.sessionId,
|
||||
additionalDirectories: [sharedLib, productDocs],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("grants both the written and real spelling of a symlinked root and drops links to cwd", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const root = await fs.realpath(tmp.path)
|
||||
const cwd = path.join(root, "workspace")
|
||||
const shared = path.join(root, "shared")
|
||||
await Promise.all([fs.mkdir(cwd), fs.mkdir(shared)])
|
||||
await Promise.all([
|
||||
fs.symlink(shared, path.join(root, "shared-link")),
|
||||
fs.symlink(cwd, path.join(root, "workspace-link")),
|
||||
])
|
||||
await using acp = await startWire()
|
||||
await acp.initialize()
|
||||
|
||||
const created = await acp.request("session/new", {
|
||||
cwd,
|
||||
additionalDirectories: [path.join(root, "workspace-link"), path.join(root, "shared-link")],
|
||||
mcpServers: [],
|
||||
})
|
||||
|
||||
expect(acp.server.sessions.get(created.sessionId)).toMatchObject({
|
||||
permissions: [grant(path.join(root, "shared-link")), grant(shared)],
|
||||
metadata: { [key]: [path.join(root, "shared-link")] },
|
||||
})
|
||||
expect((await acp.request("session/list", { cwd })).sessions[0]?.additionalDirectories).toEqual([
|
||||
path.join(root, "shared-link"),
|
||||
])
|
||||
})
|
||||
|
||||
test.each(["shared/lib", "", "/shared/*", "/shared/lib?"])(
|
||||
"rejects %p before creating a session",
|
||||
async (directory) => {
|
||||
await using acp = await startWire()
|
||||
await acp.initialize()
|
||||
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.request("session/new", {
|
||||
cwd: "/workspace",
|
||||
additionalDirectories: ["/shared/ok", directory],
|
||||
mcpServers: [],
|
||||
}),
|
||||
),
|
||||
).toMatchObject({ code: -32602, data: { additionalDirectory: directory } })
|
||||
expect(acp.server.sessions.size).toBe(0)
|
||||
},
|
||||
)
|
||||
|
||||
test("load and resume replace ACP grants and keep other session rules and metadata", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_saved", {
|
||||
...makeSession("ses_saved"),
|
||||
metadata: { host: "tui", [key]: [old] },
|
||||
permissions: [grant(old), userGrant, ...other],
|
||||
})
|
||||
await acp.initialize()
|
||||
|
||||
await acp.request("session/load", {
|
||||
cwd: "/workspace",
|
||||
sessionId: "ses_saved",
|
||||
additionalDirectories: ["/shared/lib", "/product-docs"],
|
||||
mcpServers: [],
|
||||
})
|
||||
expect(acp.server.sessions.get("ses_saved")).toMatchObject({
|
||||
metadata: { host: "tui", [key]: [sharedLib, productDocs] },
|
||||
permissions: [grant(sharedLib), grant(productDocs), userGrant, ...other],
|
||||
})
|
||||
|
||||
await acp.request("session/resume", {
|
||||
cwd: "/workspace",
|
||||
sessionId: "ses_saved",
|
||||
additionalDirectories: ["/shared/lib", "/product-docs"],
|
||||
})
|
||||
expect(updates(acp)).toHaveLength(1)
|
||||
|
||||
await acp.request("session/resume", { cwd: "/workspace", sessionId: "ses_saved" })
|
||||
expect(acp.server.sessions.get("ses_saved")?.metadata).toEqual({ host: "tui" })
|
||||
expect(acp.server.sessions.get("ses_saved")?.permissions).toEqual([userGrant, ...other])
|
||||
expect((await acp.request("session/list", { cwd: "/workspace" })).sessions[0]).not.toHaveProperty(
|
||||
"additionalDirectories",
|
||||
)
|
||||
})
|
||||
|
||||
test("forks replace inherited grants with the requested list", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_source", {
|
||||
...makeSession("ses_source"),
|
||||
metadata: { [key]: [old] },
|
||||
permissions: [grant(old), ...other],
|
||||
})
|
||||
await acp.initialize()
|
||||
|
||||
const plain = await acp.request("session/fork", { cwd: "/workspace", sessionId: "ses_source" })
|
||||
const granted = await acp.request("session/fork", {
|
||||
cwd: "/workspace",
|
||||
sessionId: "ses_source",
|
||||
additionalDirectories: ["/shared/lib"],
|
||||
})
|
||||
|
||||
expect(acp.server.sessions.get(plain.sessionId)).toMatchObject({ metadata: {}, permissions: other })
|
||||
expect(acp.server.sessions.get(granted.sessionId)).toMatchObject({
|
||||
metadata: { [key]: [sharedLib] },
|
||||
permissions: [grant(sharedLib), ...other],
|
||||
})
|
||||
expect(acp.server.sessions.get("ses_source")?.permissions).toEqual([grant(old), ...other])
|
||||
})
|
||||
|
||||
test("leaves sessions alone without additional directories", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_saved", {
|
||||
...makeSession("ses_saved"),
|
||||
metadata: { host: "tui" },
|
||||
permissions: [userGrant, ...other],
|
||||
})
|
||||
await acp.initialize()
|
||||
|
||||
const created = await acp.request("session/new", { cwd: "/workspace", mcpServers: [] })
|
||||
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_saved", mcpServers: [] })
|
||||
await acp.request("session/resume", { cwd: "/workspace", sessionId: "ses_saved", additionalDirectories: [] })
|
||||
|
||||
const create = acp.server.requests.find((request) => request.method === "POST" && request.path === "/api/session")
|
||||
expect(create?.body).toEqual({ location: { directory: "/workspace" } })
|
||||
expect(acp.server.sessions.get(created.sessionId)?.permissions).toBeUndefined()
|
||||
expect(updates(acp)).toEqual([])
|
||||
expect(
|
||||
(await acp.request("session/list", { cwd: "/workspace" })).sessions.map(
|
||||
(session) => session.additionalDirectories,
|
||||
),
|
||||
).toEqual([undefined, undefined])
|
||||
})
|
||||
})
|
||||
@@ -118,73 +118,104 @@ describe("acp catalog and config options over the wire", () => {
|
||||
const session = await acp.newSession()
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
acp.server.catalog.agents = [buildAgent, planAgent, configured]
|
||||
const set = (configId: string, value: string) =>
|
||||
acp.request("session/set_config_option", { sessionId: session.sessionId, configId, value })
|
||||
const initialModelReads = modelReads(acp)
|
||||
|
||||
const model = await set("model", "test/second-model")
|
||||
const reloadedModelReads = modelReads(acp)
|
||||
const missingModel = await rpcError(set("model", "test/missing-model"))
|
||||
const missingModelReads = modelReads(acp)
|
||||
const model = await acp.request("session/set_config_option", {
|
||||
sessionId: session.sessionId,
|
||||
configId: "model",
|
||||
value: "test/second-model",
|
||||
})
|
||||
await acp.request("session/set_mode", { sessionId: session.sessionId, modeId: "copilot-build" })
|
||||
const reads = agentReads(acp)
|
||||
const missing = await rpcError(set("mode", "missing"))
|
||||
const missing = await rpcError(
|
||||
acp.request("session/set_config_option", { sessionId: session.sessionId, configId: "mode", value: "missing" }),
|
||||
)
|
||||
|
||||
expect(currentValue(model, "model")).toBe("test/second-model")
|
||||
expect([reloadedModelReads, missingModelReads]).toEqual([initialModelReads + 1, initialModelReads + 2])
|
||||
expect(missingModel).toMatchObject({ code: -32602, data: { modelId: "test/missing-model" } })
|
||||
expect(acp.server.selections).toContainEqual({ sessionID: session.sessionId, agent: "copilot-build" })
|
||||
expect(missing).toMatchObject({ code: -32602, data: { mode: "missing" } })
|
||||
expect(agentReads(acp)).toBeGreaterThan(reads)
|
||||
})
|
||||
|
||||
test.each([
|
||||
[
|
||||
"a sibling session closes",
|
||||
async (acp: Wire) => {
|
||||
const closed = await acp.newSession()
|
||||
const open = await acp.newSession()
|
||||
await acp.request("session/close", { sessionId: closed.sessionId })
|
||||
await initialCommands(acp, open.sessionId, 1)
|
||||
return open.sessionId
|
||||
},
|
||||
],
|
||||
...(["session/load", "session/resume"] as const).map(
|
||||
(method) =>
|
||||
[
|
||||
`${method} re-attaches the session`,
|
||||
async (acp: Wire) => {
|
||||
const session = await acp.newSession()
|
||||
const params = { cwd: "/workspace", sessionId: session.sessionId, mcpServers: [] }
|
||||
await acp.request(method, params)
|
||||
await acp.request(method, params)
|
||||
await initialCommands(acp, session.sessionId, 3)
|
||||
return session.sessionId
|
||||
},
|
||||
] as const,
|
||||
),
|
||||
])("pushes exactly one update per catalog change after %s", async (_, setup) => {
|
||||
test("reloads the catalog once for an unseen model and selects it", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = [testModel]
|
||||
await acp.initialize()
|
||||
const sessionId = await setup(acp)
|
||||
const session = await acp.newSession()
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
const set = (value: string) =>
|
||||
acp.request("session/set_config_option", { sessionId: session.sessionId, configId: "model", value })
|
||||
const reads = modelReads(acp)
|
||||
|
||||
const selected = await set("test/second-model")
|
||||
expect(modelReads(acp)).toBe(reads + 1)
|
||||
expect(await rpcError(set("test/missing-model"))).toMatchObject({ code: -32602 })
|
||||
|
||||
expect(currentValue(selected, "model")).toBe("test/second-model")
|
||||
expect(modelReads(acp)).toBe(reads + 2)
|
||||
expect(acp.server.selections).toEqual([
|
||||
{ sessionID: session.sessionId, model: { providerID: "test", id: secondModel.id } },
|
||||
])
|
||||
})
|
||||
|
||||
test("stops catalog updates for a closed session while other sessions in the cwd keep them", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.catalog.models = [testModel]
|
||||
await acp.initialize()
|
||||
const closed = await acp.newSession()
|
||||
const open = await acp.newSession()
|
||||
await acp.request("session/close", { sessionId: closed.sessionId })
|
||||
const since = acp.updates.length
|
||||
|
||||
await change(acp, sessionId, "config_option_update", () => {
|
||||
await change(acp, open.sessionId, "config_option_update", () => {
|
||||
acp.server.catalog.models = [testModel, secondModel]
|
||||
acp.server.send(ephemeralEvent("model.updated", {}))
|
||||
})
|
||||
await change(acp, sessionId, "available_commands_update", () => {
|
||||
await change(acp, open.sessionId, "available_commands_update", () => {
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
|
||||
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
|
||||
})
|
||||
await change(acp, open.sessionId, "config_option_update", () => {
|
||||
acp.server.catalog.agents = [buildAgent]
|
||||
acp.server.send(ephemeralEvent("agent.updated", {}, { directory: "/workspace" }))
|
||||
})
|
||||
|
||||
expect(updateKinds(acp, since)).toEqual([
|
||||
[sessionId, "config_option_update"],
|
||||
[sessionId, "available_commands_update"],
|
||||
[open.sessionId, "config_option_update"],
|
||||
[open.sessionId, "available_commands_update"],
|
||||
[open.sessionId, "config_option_update"],
|
||||
])
|
||||
})
|
||||
|
||||
test.each(["session/load", "session/resume"] as const)(
|
||||
"pushes one update per catalog change after %s re-attaches a session",
|
||||
async (method) => {
|
||||
await using acp = await startSession()
|
||||
const params = { cwd: "/workspace", sessionId: acp.sessionId, mcpServers: [] }
|
||||
await acp.request(method, params)
|
||||
await acp.request(method, params)
|
||||
const since = acp.updates.length
|
||||
|
||||
await change(acp, acp.sessionId, "config_option_update", () => {
|
||||
acp.server.catalog.models = [testModel]
|
||||
acp.server.send(ephemeralEvent("model.updated", {}))
|
||||
})
|
||||
await change(acp, acp.sessionId, "available_commands_update", () => {
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "ship", description: "Ship it" }]
|
||||
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
|
||||
})
|
||||
await change(acp, acp.sessionId, "config_option_update", () => {
|
||||
acp.server.catalog.agents = [buildAgent]
|
||||
acp.server.send(ephemeralEvent("agent.updated", {}, { directory: "/workspace" }))
|
||||
})
|
||||
|
||||
expect(updateKinds(acp, since)).toEqual([
|
||||
[acp.sessionId, "config_option_update"],
|
||||
[acp.sessionId, "available_commands_update"],
|
||||
[acp.sessionId, "config_option_update"],
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
test.each(["empty", "missing the default"])(
|
||||
"retries when the model list is %s but the default is ready",
|
||||
async (initial) => {
|
||||
@@ -261,32 +292,20 @@ describe("acp catalog and config options over the wire", () => {
|
||||
await using acp = await startSession()
|
||||
const advertised = await acp.waitForUpdate((item) => commandNames(item) !== undefined)
|
||||
|
||||
acp.server.catalog.commands = [
|
||||
reviewCommand,
|
||||
{ name: "compact", description: "Server compact" },
|
||||
{ name: "ship", description: "Ship it" },
|
||||
]
|
||||
acp.server.catalog.commands = [reviewCommand, { name: "compact", description: "Server compact" }]
|
||||
acp.server.send(ephemeralEvent("command.updated", {}, { directory: "/workspace" }))
|
||||
const replaced = await acp.waitForUpdate((item) => item !== advertised && commandNames(item) !== undefined)
|
||||
const compacted = await acp.prompt(acp.sessionId, "/compact")
|
||||
|
||||
expect([advertised, replaced].map((item) => item.update)).toEqual([
|
||||
{
|
||||
expect([advertised, replaced].map((item) => item.update)).toEqual(
|
||||
Array.from({ length: 2 }, () => ({
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "review", description: "Review changes" },
|
||||
{ name: "compact", description: "Compact the session" },
|
||||
],
|
||||
},
|
||||
{
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: [
|
||||
{ name: "review", description: "Review changes" },
|
||||
{ name: "ship", description: "Ship it" },
|
||||
{ name: "compact", description: "Compact the session" },
|
||||
],
|
||||
},
|
||||
])
|
||||
})),
|
||||
)
|
||||
expect(compacted.stopReason).toBe("end_turn")
|
||||
expect(acp.server.submissions.map((item) => item.kind)).toEqual(["compact"])
|
||||
})
|
||||
@@ -303,14 +322,6 @@ async function change(acp: Wire, sessionId: string, kind: string, trigger: () =>
|
||||
)
|
||||
}
|
||||
|
||||
// Each attach sends its commands after the response, so later counts start once every attach has sent them.
|
||||
function initialCommands(acp: Wire, sessionId: string, attaches: number) {
|
||||
return acp.until(
|
||||
() => acp.updates.filter((item) => item.sessionId === sessionId && commandNames(item)).length === attaches,
|
||||
"initial commands",
|
||||
)
|
||||
}
|
||||
|
||||
function updateKinds(acp: Wire, since: number) {
|
||||
return acp.updates.slice(since).map((item) => [item.sessionId, item.update.sessionUpdate])
|
||||
}
|
||||
|
||||
@@ -80,12 +80,6 @@ describe("acp content conversion", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("resource_link to another scheme becomes a markdown link", () => {
|
||||
expect(contentBlockToParts({ type: "resource_link", uri: "https://example.com/spec", name: "spec" })).toEqual([
|
||||
{ type: "text", text: "[spec](https://example.com/spec)" },
|
||||
])
|
||||
})
|
||||
|
||||
test("resource_link zed path becomes a file URL part", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
|
||||
@@ -1,468 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AnyMessage, AnyRequest, CreateElicitationResponse } from "@agentclientprotocol/sdk"
|
||||
import { ACPElicitation } from "../../src/acp/elicitation"
|
||||
import {
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
ephemeralEvent,
|
||||
interrupted,
|
||||
startSession,
|
||||
succeeded,
|
||||
textDelta,
|
||||
toolStarted,
|
||||
toolSucceeded,
|
||||
} from "./wire-fixture"
|
||||
|
||||
const questions = (sessionID: string, id = "frm_question", tool = "call_question") =>
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id,
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: "msg_tools", id: tool } },
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
title: "Runtime",
|
||||
description: "Which runtime?",
|
||||
type: "string",
|
||||
options: [
|
||||
{ value: "Bun", label: "Bun", description: "Fast" },
|
||||
{ value: "Node", label: "Node", description: "Stable" },
|
||||
],
|
||||
custom: true,
|
||||
},
|
||||
{
|
||||
key: "q1",
|
||||
title: "Goals",
|
||||
description: "What matters?",
|
||||
type: "multiselect",
|
||||
options: [{ value: "Fast", label: "Fast", description: "Speed" }],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const capable = { childSessionUpdates: false, formElicitation: true }
|
||||
|
||||
const form = (
|
||||
fields: ACPElicitation.AskedForm["fields"],
|
||||
metadata: ACPElicitation.AskedForm["metadata"] = { kind: "question" },
|
||||
) => ({ id: "frm_test", sessionID: "ses_test", title: "Test", metadata, fields })
|
||||
|
||||
const accept = (content: Record<string, string | number | boolean | string[]>): CreateElicitationResponse => ({
|
||||
action: "accept",
|
||||
content,
|
||||
})
|
||||
|
||||
const pendingUntilAborted = (_request: unknown, signal: AbortSignal) =>
|
||||
new Promise<CreateElicitationResponse>((resolve) => {
|
||||
signal.addEventListener("abort", () => resolve({ action: "cancel" }), { once: true })
|
||||
})
|
||||
|
||||
const firstElicitationCancel = (received: readonly AnyMessage[]): AnyMessage => {
|
||||
const asked = received.find(
|
||||
(message): message is AnyRequest =>
|
||||
"method" in message && "id" in message && message.method === "elicitation/create",
|
||||
)
|
||||
return { jsonrpc: "2.0", method: "$/cancel_request", params: { requestId: asked?.id } }
|
||||
}
|
||||
|
||||
describe("acp elicitation mapping", () => {
|
||||
test("maps every representable field type", () => {
|
||||
expect(
|
||||
ACPElicitation.requestedSchema(
|
||||
form([
|
||||
{
|
||||
key: "email",
|
||||
title: "Email",
|
||||
description: "Work address",
|
||||
type: "string",
|
||||
format: "email",
|
||||
maxLength: 80,
|
||||
pattern: ".+@.+",
|
||||
placeholder: "you@example.com",
|
||||
default: "a@b.co",
|
||||
required: true,
|
||||
},
|
||||
{ key: "name", type: "string", minLength: 2 },
|
||||
{
|
||||
key: "plan",
|
||||
type: "string",
|
||||
options: [
|
||||
{ value: "pro", label: "Pro", description: "Paid" },
|
||||
{ value: "free", label: "Free" },
|
||||
],
|
||||
default: "free",
|
||||
},
|
||||
{ key: "ratio", type: "number", minimum: 0, maximum: 1, default: 0.5 },
|
||||
{ key: "count", type: "integer", minimum: 1, required: true },
|
||||
{ key: "confirm", type: "boolean", default: false },
|
||||
{
|
||||
key: "tags",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ value: "a", label: "A" },
|
||||
{ value: "b", label: "B" },
|
||||
],
|
||||
maxItems: 2,
|
||||
default: ["a"],
|
||||
required: true,
|
||||
},
|
||||
{ key: "server", type: "string", format: "uri", hidden: true, default: "https://example.com" },
|
||||
]),
|
||||
capable,
|
||||
),
|
||||
).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
email: {
|
||||
type: "string",
|
||||
title: "Email",
|
||||
description: "Work address",
|
||||
format: "email",
|
||||
minLength: 1,
|
||||
maxLength: 80,
|
||||
pattern: ".+@.+",
|
||||
default: "a@b.co",
|
||||
},
|
||||
name: { type: "string", minLength: 2 },
|
||||
plan: {
|
||||
type: "string",
|
||||
oneOf: [
|
||||
{ const: "pro", title: "Pro", description: "Paid" },
|
||||
{ const: "free", title: "Free" },
|
||||
],
|
||||
default: "free",
|
||||
},
|
||||
ratio: { type: "number", minimum: 0, maximum: 1, default: 0.5 },
|
||||
count: { type: "integer", minimum: 1 },
|
||||
confirm: { type: "boolean", default: false },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: {
|
||||
anyOf: [
|
||||
{ const: "a", title: "A" },
|
||||
{ const: "b", title: "B" },
|
||||
],
|
||||
},
|
||||
minItems: 1,
|
||||
maxItems: 2,
|
||||
default: ["a"],
|
||||
},
|
||||
},
|
||||
required: ["email", "count", "tags"],
|
||||
})
|
||||
})
|
||||
|
||||
test("adds a free-text property next to options that accept a custom answer", () => {
|
||||
expect(
|
||||
ACPElicitation.requestedSchema(
|
||||
form([
|
||||
{
|
||||
key: "q0",
|
||||
title: "Runtime",
|
||||
type: "string",
|
||||
options: [{ value: "Bun", label: "Bun" }],
|
||||
custom: true,
|
||||
maxLength: 20,
|
||||
},
|
||||
{ key: "q1", type: "multiselect", options: [{ value: "Fast", label: "Fast" }], custom: true },
|
||||
]),
|
||||
capable,
|
||||
)?.properties,
|
||||
).toEqual({
|
||||
q0: { type: "string", title: "Runtime", oneOf: [{ const: "Bun", title: "Bun" }] },
|
||||
q0_custom: { type: "string", title: "Runtime (other)", description: "Type your own answer", maxLength: 20 },
|
||||
q1: { type: "array", items: { anyOf: [{ const: "Fast", title: "Fast" }] } },
|
||||
q1_custom: { type: "string", title: "q1 (other)", description: "Add your own answer" },
|
||||
})
|
||||
})
|
||||
|
||||
test("cancels forms from unsupported clients, unknown flows, and credential-looking fields", () => {
|
||||
const fields: ACPElicitation.AskedForm["fields"] = [{ key: "name", type: "string" }]
|
||||
expect(ACPElicitation.requestedSchema(form(fields), { ...capable, formElicitation: false })).toBeUndefined()
|
||||
expect(ACPElicitation.requestedSchema(form(fields, { kind: "mcp-elicitation" }), capable)).toBeUndefined()
|
||||
expect(ACPElicitation.requestedSchema(form(fields, {}), capable)).toBeUndefined()
|
||||
expect(ACPElicitation.requestedSchema(form(fields, { kind: "websearch.provider" }), capable)).toBeDefined()
|
||||
const credentials: Array<ACPElicitation.AskedForm["fields"]> = [
|
||||
[{ key: "api_key", type: "string" }],
|
||||
[{ key: "q0", title: "GitHub token", type: "string" }],
|
||||
[{ key: "q0", title: "Password", type: "string", hidden: true, default: "" }],
|
||||
]
|
||||
expect(credentials.map((fields) => ACPElicitation.requestedSchema(form(fields), capable))).toEqual(
|
||||
credentials.map(() => undefined),
|
||||
)
|
||||
})
|
||||
|
||||
test("cancels forms it cannot represent faithfully", () => {
|
||||
const options = [{ value: "a", label: "A" }]
|
||||
const unrepresentable: Array<ACPElicitation.AskedForm["fields"]> = [
|
||||
[
|
||||
{ key: "mode", type: "boolean" },
|
||||
{ key: "detail", type: "string", when: [{ key: "mode", op: "eq", value: true }] },
|
||||
],
|
||||
[
|
||||
{ key: "mode", type: "boolean" },
|
||||
{ key: "detail", type: "string", hidden: true, default: "x", when: [{ key: "mode", op: "eq", value: true }] },
|
||||
],
|
||||
[{ key: "login", type: "external", url: "https://example.com/login" }],
|
||||
[{ key: "server", type: "string", hidden: true, required: true }],
|
||||
[{ key: "pick", type: "string", options, custom: true, required: true }],
|
||||
[{ key: "pick", type: "multiselect", options, custom: true, maxItems: 1 }],
|
||||
[{ key: "pick", type: "string", options, default: "b" }],
|
||||
[{ key: "pick", type: "multiselect", options, default: ["a", "b"] }],
|
||||
[
|
||||
{ key: "pick", type: "string", options, custom: true },
|
||||
{ key: "pick_custom", type: "string" },
|
||||
],
|
||||
]
|
||||
expect(unrepresentable.map((fields) => ACPElicitation.requestedSchema(form(fields), capable))).toEqual(
|
||||
unrepresentable.map(() => undefined),
|
||||
)
|
||||
expect(
|
||||
ACPElicitation.requestedSchema(
|
||||
form([
|
||||
{ key: "name", type: "string" },
|
||||
{ key: "pick", type: "string", options, default: "b", hidden: true },
|
||||
]),
|
||||
capable,
|
||||
)?.properties,
|
||||
).toEqual({ name: { type: "string" } })
|
||||
})
|
||||
|
||||
test("maps an accepted response back to answers", () => {
|
||||
const options = [{ value: "a", label: "A" }]
|
||||
const fields: ACPElicitation.AskedForm["fields"] = [
|
||||
{ key: "single", type: "string", options, custom: true },
|
||||
{ key: "multi", type: "multiselect", options, custom: true },
|
||||
{ key: "blank", type: "string", options, custom: true },
|
||||
{ key: "count", type: "integer" },
|
||||
{ key: "server", type: "string", hidden: true, default: "https://example.com" },
|
||||
{ key: "region", type: "string", hidden: true },
|
||||
]
|
||||
expect(
|
||||
ACPElicitation.answer(
|
||||
form(fields),
|
||||
accept({
|
||||
single: "a",
|
||||
single_custom: "typed",
|
||||
multi: ["a"],
|
||||
multi_custom: "extra",
|
||||
blank: "a",
|
||||
blank_custom: " ",
|
||||
count: 3,
|
||||
server: "https://other.example.com",
|
||||
unknown: true,
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
single: "typed",
|
||||
multi: ["a", "extra"],
|
||||
blank: "a",
|
||||
count: 3,
|
||||
server: "https://example.com",
|
||||
})
|
||||
expect(ACPElicitation.answer(form(fields), accept({ multi_custom: "only" }))).toEqual({
|
||||
multi: ["only"],
|
||||
server: "https://example.com",
|
||||
})
|
||||
expect(ACPElicitation.answer(form(fields), { action: "accept" })).toEqual({ server: "https://example.com" })
|
||||
})
|
||||
|
||||
test("has no answer unless the user accepted valid content", () => {
|
||||
const fields: ACPElicitation.AskedForm["fields"] = [{ key: "name", type: "string" }]
|
||||
expect(ACPElicitation.answer(form(fields), { action: "decline" })).toBeUndefined()
|
||||
expect(ACPElicitation.answer(form(fields), { action: "cancel" })).toBeUndefined()
|
||||
expect(ACPElicitation.answer(form(fields), { action: "_custom" })).toBeUndefined()
|
||||
expect(
|
||||
ACPElicitation.answer(form(fields), { action: "accept", content: { name: { nested: true } } }),
|
||||
).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp elicitation over the wire", () => {
|
||||
test("answers a question form through elicitation and continues the turn", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_question", "question"),
|
||||
questions(sessionID),
|
||||
],
|
||||
elicitation: () => accept({ q0: "Bun", q1: ["Fast"], q1_custom: "Small" }),
|
||||
onFormReply: ({ sessionID, formID }) => [
|
||||
ephemeralEvent("form.replied", { sessionID, id: formID, answer: {} }),
|
||||
toolSucceeded(sessionID, "call_question", {}, "answered"),
|
||||
textDelta(sessionID, "msg_after", "thanks"),
|
||||
succeeded(sessionID),
|
||||
],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toMatchObject([
|
||||
{ mode: "form", sessionId: acp.sessionId, toolCallId: "call_question", message: "Questions" },
|
||||
])
|
||||
expect(acp.server.repliedForms).toEqual([
|
||||
{ sessionID: acp.sessionId, formID: "frm_question", answer: { q0: "Bun", q1: ["Fast", "Small"] } },
|
||||
])
|
||||
expect(acp.server.cancelledForms).toEqual([])
|
||||
expect(acp.updates.some((item) => item.update.sessionUpdate === "agent_message_chunk")).toBe(true)
|
||||
})
|
||||
|
||||
test("cancels the form when the client declines, cancels, fails, or sends a wrong value type", async () => {
|
||||
const responses: Array<() => CreateElicitationResponse> = [
|
||||
() => ({ action: "decline" }),
|
||||
() => ({ action: "cancel" }),
|
||||
() => {
|
||||
throw new Error("elicitation UI failed")
|
||||
},
|
||||
() => ({ action: "accept", content: { q0: { nested: true } } }),
|
||||
]
|
||||
const ids = ["frm_decline", "frm_cancel", "frm_fail", "frm_invalid"]
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), ...ids.map((form) => questions(sessionID, form))],
|
||||
elicitation: () => responses[acp.elicitations.length - 1](),
|
||||
onFormCancel: ({ sessionID }) => (acp.server.cancelledForms.length === ids.length ? [succeeded(sessionID)] : []),
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toHaveLength(ids.length)
|
||||
expect(acp.server.cancelledForms.map((item) => item.formID)).toEqual(ids)
|
||||
expect(acp.server.repliedForms).toEqual([])
|
||||
expect(acp.server.interrupts).toEqual([])
|
||||
})
|
||||
|
||||
test("cancels forms outside the allowed flows without asking", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id: "frm_plugin",
|
||||
sessionID,
|
||||
title: "Plugin",
|
||||
metadata: { kind: "plugin" },
|
||||
fields: [{ key: "name", type: "string" }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toEqual([])
|
||||
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_plugin" }])
|
||||
})
|
||||
|
||||
test("cancelling the turn cancels its pending elicitation and the form, and never sends queued ones", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
questions(sessionID, "frm_pending"),
|
||||
questions(sessionID, "frm_queued"),
|
||||
],
|
||||
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
|
||||
elicitation: pendingUntilAborted,
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.elicitations.length === 1, "elicitation request")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
await acp.until(() => acp.server.cancelledForms.length === 2, "form cancellation")
|
||||
expect(acp.elicitations).toHaveLength(1)
|
||||
expect(acp.server.cancelledForms.map((item) => item.formID)).toEqual(["frm_pending", "frm_queued"])
|
||||
expect(acp.server.repliedForms).toEqual([])
|
||||
expect(acp.received).toContainEqual(firstElicitationCancel(acp.received))
|
||||
})
|
||||
|
||||
test("withdraws an elicitation for a form settled elsewhere and moves on to the next ask", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), questions(sessionID, "frm_elsewhere")],
|
||||
elicitation: (request, signal) =>
|
||||
acp.elicitations.length === 1 ? pendingUntilAborted(request, signal) : accept({ q0: "Node" }),
|
||||
onFormReply: ({ sessionID }) => [succeeded(sessionID)],
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.elicitations.length === 1, "elicitation request")
|
||||
acp.server.send(
|
||||
ephemeralEvent("form.replied", { sessionID: acp.sessionId, id: "frm_elsewhere", answer: { q0: "Bun" } }),
|
||||
questions(acp.sessionId, "frm_next"),
|
||||
)
|
||||
|
||||
expect((await prompt).stopReason).toBe("end_turn")
|
||||
expect(acp.received).toContainEqual(firstElicitationCancel(acp.received))
|
||||
expect(acp.elicitations).toHaveLength(2)
|
||||
expect(acp.server.repliedForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_next", answer: { q0: "Node" } }])
|
||||
expect(acp.server.cancelledForms).toEqual([])
|
||||
expect(acp.server.interrupts).toEqual([])
|
||||
})
|
||||
|
||||
test("leaves the session alone when the user's answer arrives after the form settled", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [delivered(sessionID, id), questions(sessionID)],
|
||||
elicitation: () => accept({ q0: "Bun" }),
|
||||
fetch: (request) => {
|
||||
if (!request.path.endsWith("/form/frm_question/reply")) return undefined
|
||||
acp.server.send(succeeded(acp.sessionId))
|
||||
return Response.json(
|
||||
{ _tag: "FormAlreadySettledError", id: "frm_question", message: "Form already settled: frm_question" },
|
||||
{ status: 409 },
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.server.requests.filter((request) => request.path.endsWith("/reply"))).toHaveLength(1)
|
||||
expect(acp.server.cancelledForms).toEqual([])
|
||||
expect(acp.server.interrupts).toEqual([])
|
||||
})
|
||||
|
||||
test("prefixes a foreground child form's tool call and message with the child", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
childCreated("ses_child", sessionID, "Review code"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
||||
questions("ses_child", "frm_child", "call_child"),
|
||||
],
|
||||
elicitation: () => accept({ q0: "Node" }),
|
||||
onFormReply: ({ sessionID }) => [succeeded(sessionID), succeeded(acp.sessionId)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toMatchObject([
|
||||
{ sessionId: acp.sessionId, toolCallId: "ses_child:call_child", message: "Review code: Questions" },
|
||||
])
|
||||
expect(acp.server.repliedForms).toEqual([{ sessionID: "ses_child", formID: "frm_child", answer: { q0: "Node" } }])
|
||||
})
|
||||
|
||||
test("omits the tool call when the child's tool calls only reach the client as child updates", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true, childSessionUpdates: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
childCreated("ses_child", sessionID, "Review code"),
|
||||
questions("ses_child", "frm_child", "call_child"),
|
||||
],
|
||||
elicitation: () => accept({ q0: "Node" }),
|
||||
onFormReply: ({ sessionID }) => [succeeded(sessionID), succeeded(acp.sessionId)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toHaveLength(1)
|
||||
expect(acp.elicitations[0]).toMatchObject({ sessionId: acp.sessionId, message: "Review code: Questions" })
|
||||
expect(acp.elicitations[0]).not.toHaveProperty("toolCallId")
|
||||
})
|
||||
})
|
||||
@@ -12,12 +12,9 @@ describe("acp errors", () => {
|
||||
new ACPError.InvalidModelError({ providerId: "anthropic", modelId: "claude-missing" }),
|
||||
new ACPError.InvalidEffortError({ effort: "extreme" }),
|
||||
new ACPError.InvalidModeError({ mode: "turbo" }),
|
||||
new ACPError.InvalidRequestError({ message: "Invalid session ID", field: "sessionID" }),
|
||||
]
|
||||
|
||||
expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([
|
||||
-32602, -32602, -32602, -32602, -32602, -32602,
|
||||
])
|
||||
expect(cases.map((error) => ACPError.toRequestError(error).code)).toEqual([-32602, -32602, -32602, -32602, -32602])
|
||||
})
|
||||
|
||||
test("includes safe validation details", () => {
|
||||
@@ -82,25 +79,6 @@ describe("acp error boundary over the wire", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("maps rejected prompt submissions to invalid params with the server's message", async () => {
|
||||
await using acp = await startSession({
|
||||
fetch: (request) =>
|
||||
request.method === "POST" && request.path.endsWith("/prompt")
|
||||
? Response.json(
|
||||
{ _tag: "InvalidRequestError", message: "File not readable: missing.png", field: "files" },
|
||||
{ status: 400 },
|
||||
)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
expect(await rpcError(acp.prompt(acp.sessionId, "hello"))).toEqual({
|
||||
code: -32602,
|
||||
message: "Invalid params: File not readable: missing.png",
|
||||
data: { field: "files" },
|
||||
})
|
||||
expect(acp.logs).toEqual([])
|
||||
})
|
||||
|
||||
test("reports an unavailable server once the server stops", async () => {
|
||||
await using acp = await startSession()
|
||||
await acp.server.stop()
|
||||
|
||||
@@ -17,20 +17,6 @@ describe("acp lifecycle subprocess", () => {
|
||||
expect(await acp.close()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
test("an incoming message over the size limit exits with an error", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const [code] = await Promise.all([
|
||||
acp.exited,
|
||||
// The agent stops reading partway through the line, so the write may fail.
|
||||
acp.notify("opencode/oversized", { data: "a".repeat(32 * 1024 * 1024) }).catch(() => undefined),
|
||||
])
|
||||
await acp[Symbol.asyncDispose]()
|
||||
expect(code).toBe(1)
|
||||
expect(acp.stderr()).toContain("opencode acp: incoming message exceeded the 32 MiB limit\n")
|
||||
}, 60_000)
|
||||
|
||||
test("close capability and close request", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
const acp = fixture.spawn()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AnyRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import { Cause } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
@@ -133,33 +132,6 @@ describe("acp permissions over the wire", () => {
|
||||
expect(acp.server.replies).toEqual([{ sessionID: "ses_child", requestID: "perm_child", decision: "once" }])
|
||||
})
|
||||
|
||||
test("asks for a background child's permission after the parent turn ends without the child capability", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => turn(sessionID, id, childCreated("ses_background", sessionID, "Research")),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
acp.server.send(
|
||||
durableEvent("session.execution.started", { sessionID: "ses_background" }),
|
||||
permissionAsked("ses_background", "perm_background", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/notes.md" },
|
||||
}),
|
||||
)
|
||||
await acp.until(() => acp.server.replies.length === 1, "background permission reply")
|
||||
|
||||
expect(acp.permissions).toMatchObject([
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: { toolCallId: "ses_background:perm_background", title: "Research: /workspace/notes.md" },
|
||||
},
|
||||
])
|
||||
expect(acp.server.replies).toEqual([
|
||||
{ sessionID: "ses_background", requestID: "perm_background", decision: "once" },
|
||||
])
|
||||
})
|
||||
|
||||
test("rejects explicit rejection, cancellation, and permission UI failure", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
@@ -187,23 +159,6 @@ describe("acp permissions over the wire", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("logs a failed server reply and still answers later asks", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(sessionID, id, permissionAsked(sessionID, "perm_failed"), permissionAsked(sessionID, "perm_next")),
|
||||
fetch: (request) =>
|
||||
request.path.endsWith("/permission/perm_failed/reply") ? new Response(null, { status: 500 }) : undefined,
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
expect(await acp.prompt(acp.sessionId, "hello")).toMatchObject({ stopReason: "end_turn" })
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_failed", "perm_next"])
|
||||
expect(decisions(acp)).toEqual([["perm_next", "once"]])
|
||||
expect(acp.logs.map((log) => ({ message: log.message, cause: Cause.squash(log.cause) }))).toMatchObject([
|
||||
{ message: ["ACP permission reply failed"], cause: { name: "ClientError", reason: "UnexpectedStatus" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("serializes permission requests and replies within one session", async () => {
|
||||
const releaseFirst = Promise.withResolvers<RequestPermissionResponse>()
|
||||
await using acp = await startSession({
|
||||
@@ -230,34 +185,6 @@ describe("acp permissions over the wire", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps streaming other children while one child's permission is pending", async () => {
|
||||
const release = Promise.withResolvers<RequestPermissionResponse>()
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_a", sessionID, "A"),
|
||||
childCreated("ses_b", sessionID, "B"),
|
||||
permissionAsked("ses_a", "perm_a"),
|
||||
textDelta("ses_b", "msg_b", "still streaming"),
|
||||
succeeded("ses_b"),
|
||||
succeeded("ses_a"),
|
||||
),
|
||||
permission: () => release.promise,
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "agent_message_chunk", "child B's chunk")
|
||||
await acp.until(() => acp.permissions.length === 1, "child A's permission")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["ses_a:perm_a"])
|
||||
expect(acp.server.replies).toEqual([])
|
||||
release.resolve({ outcome: { outcome: "selected", optionId: "once" } })
|
||||
expect((await prompt).stopReason).toBe("end_turn")
|
||||
expect(decisions(acp)).toEqual([["perm_a", "once"]])
|
||||
})
|
||||
|
||||
test("does not let one session's blocked permission stall another session", async () => {
|
||||
const releaseBlocked = Promise.withResolvers<RequestPermissionResponse>()
|
||||
await using acp = await startWire({ onPrompt: () => undefined, permission: () => releaseBlocked.promise })
|
||||
@@ -324,32 +251,6 @@ describe("acp permissions over the wire", () => {
|
||||
params: { requestId: asked?.id },
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects asks queued behind a cancelled one without sending them to the client", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
permissionAsked(sessionID, "perm_pending"),
|
||||
permissionAsked(sessionID, "perm_queued"),
|
||||
],
|
||||
onInterrupt: ({ sessionID }) => [interrupted(sessionID)],
|
||||
permission: (_request, signal) =>
|
||||
new Promise((resolve) => {
|
||||
signal.addEventListener("abort", () => resolve({ outcome: { outcome: "cancelled" } }), { once: true })
|
||||
}),
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.until(() => acp.permissions.length === 1, "permission request")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect(await prompt).toMatchObject({ stopReason: "cancelled" })
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_pending"])
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_pending", "reject"],
|
||||
["perm_queued", "reject"],
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp edit previews over the wire", () => {
|
||||
@@ -445,36 +346,6 @@ describe("acp edit previews over the wire", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("asks without previews when a patch does not apply to the current file", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "first.ts"), "changed\n")
|
||||
const patchText = ["*** Begin Patch", "*** Update File: first.ts", "@@", "-one", "+two", "*** End Patch"].join("\n")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_patch", "patch"),
|
||||
toolCalled(sessionID, "call_patch", { patchText }),
|
||||
permissionAsked(sessionID, "perm_patch", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
||||
}),
|
||||
],
|
||||
onPermissionReply: ({ sessionID }) => [succeeded(sessionID)],
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
kind: "edit",
|
||||
locations: [{ path: path.join(dir.path, "first.ts") }],
|
||||
})
|
||||
expect(acp.permissions[0]?.toolCall.content).toBeUndefined()
|
||||
expect(decisions(acp)).toEqual([["perm_patch", "once"]])
|
||||
})
|
||||
|
||||
test("reports the same absolute locations for a moved file in the permission and tool updates", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "old.ts"), "one\n")
|
||||
|
||||
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user