Compare commits

...
Author SHA1 Message Date
rekram1-node 5fbdf3ee60 docs: restore security scope wording 2026-10-02 01:24:18 +00:00
rekram1-node 4827ef1f27 docs: restore no-sandbox paragraph 2026-10-02 01:00:17 +00:00
rekram1-node 2dedc5e274 docs: describe v2 permissions and server auth 2026-10-02 00:42:49 +00:00
8a3185b66c docs(websearch): include TinyFish provider (#52604)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-10-01 19:34:42 -05:00
opencode-agent[bot]andrekram1-node 37f3e239c6 docs(cli): fix command examples (#52603)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-01 19:34:08 -05:00
opencode-agent[bot]andrekram1-node 2deb0e250f docs(mcp): correct session metadata key (#52602)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-01 19:33:37 -05:00
Aiden Cline e41756cfc4 fix(ai): isolate Groq and Vertex metadata keys (#52598) 2026-10-01 19:24:08 -05:00
Shoubhit Dash 8b24fc8c2a feat(app): show last turn changes in review panel (#51640) 2026-10-02 06:39:33 +08:00
Filip 0f9f0a9ae3 fix(core): ignore directories named AGENTS.md during instruction discovery (#52576) 2026-10-01 23:12:26 +02:00
Aiden Cline f9e61fd9a3 fix(core): drop legacy catalog context pricing (#52582) 2026-10-01 16:09:38 -05:00
James Long 5256112bac feat(cli): check for updates every 10 minutes (#52552) 2026-10-01 16:29:04 -04:00
Filip afc5d1e21f test(core): test Azure plugin behavior instead of fakes (#52570) 2026-10-01 21:42:39 +02:00
opencode-agent[bot] 80ecb3fc89 chore: update nix node_modules hashes 2026-10-01 19:19:55 +00:00
Shoubhit Dash 31a683f5ca chore(acp): bump @agentclientprotocol/sdk to 1.6.0 (#52559) 2026-10-02 00:38:00 +05:30
Aiden Cline 5d707f1a2b fix(ai): classify Together input token rejections as context overflow (#52133) 2026-10-01 14:07:15 -05:00
Aiden Cline e2948f5a51 fix(core): skip automatic copies of directly read instructions (#52382) 2026-10-01 13:59:02 -05:00
Filip fbe8a9f7b4 feat(core): discover Azure deployments (#50053) 2026-10-01 20:42:12 +02:00
Aiden Cline 033b9ea560 fix(ai): make Claude capability defaults forward-compatible (#52535) 2026-10-01 13:20:54 -05:00
Shoubhit Dash 7bb3fbf095 feat(acp): support additional workspace directories (#52524) 2026-10-01 23:47:00 +05:30
Shoubhit Dash 507e117466 chore(ci): remove unused ffmpeg install (#52547) 2026-10-01 23:38:06 +05:30
Shoubhit Dash 1549712761 fix(acp): fall back to a reference for unreadable resource links (#52520) 2026-10-01 23:03:39 +05:30
opencode-agent[bot]andrekram1-node 4f37be6265 fix(ai): place Bedrock tool images by model family (#52528)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-01 11:58:36 -05:00
Rémy SanchezandAiden Cline 7b04288099 fix(ai): support prompt caching for DigitalOcean inference (#51559)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-01 11:56:04 -05:00
70 changed files with 2600 additions and 792 deletions

No files matched your search

-6
View File
@@ -94,12 +94,6 @@ 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:
+4 -1
View File
@@ -20,7 +20,10 @@ If you need true isolation, run OpenCode inside a Docker container or VM.
### Server Mode
Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to require HTTP Basic Auth. Without this, the server runs unauthenticated (with a warning). It is the end user's responsibility to secure the server - any functionality it provides is not a vulnerability.
OpenCode clients normally discover or start a local background HTTP service. The CLI binds to loopback by default and
supplies a generated HTTP Basic Auth password when one is not configured; its server process refuses to start without a
password. If you expose the service beyond your machine, use appropriate network access controls as well as authentication.
An application embedding the fetch handler without a password must provide its own access control.
### Out of Scope
+2 -2
View File
@@ -118,7 +118,7 @@
"opencode2": "./bin/opencode2.cjs",
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@agentclientprotocol/sdk": "1.6.0",
"@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.2.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.6.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-XxXrmX7aZkDgOB0Rg9cu+ZFyiUUc5lF2n9seO3Gc4OR+MTdfZOwIqF6m3LvsmmM8K3qgPmXkDH8/IFM2u9vdcQ=="],
"@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
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-yCdtDQsXERjfL9bJg7YXNloMBOPwFkPMFVNAzXlarBM=",
"aarch64-linux": "sha256-iQ1bLIszETcFoD4CENpoaDZJZF/KBKZFeTp9r7ogJ9Y=",
"aarch64-darwin": "sha256-G7oIrTXEFQ5iEF8KSpA4xJPw9xwqRDpbIR472iIoqC4="
"x86_64-linux": "sha256-g3k0cAFGqzmRYlcIkg1NDvlx1WxHYhnYPL0/a8E+qTg=",
"aarch64-linux": "sha256-a+3ymqdxOONGe2Tpq4GUccl1b+Dwzxlb9LFXgE1gZ+0=",
"aarch64-darwin": "sha256-h8xIzuMmaWfJqjHCO74xUDCWNKQLFrIGoKYZ+2TauYc="
}
}
+1
View File
@@ -49,6 +49,7 @@ 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
@@ -807,15 +807,12 @@ 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 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 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 endsInServerToolUse = (message: LLMRequest["messages"][number]) => {
@@ -992,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") return version.major >= 5
if (version.family !== "fable" && version.family !== "mythos") return false
if (version.family === "opus" && version.major >= 5) return true
return version.major > 5 || (version.major === 5 && version.minor >= 1)
}
+31 -1
View File
@@ -319,6 +319,9 @@ 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,
@@ -328,8 +331,19 @@ 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)
@@ -403,7 +417,22 @@ 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"])
content.push(yield* lowerToolResult(part, documentNames, normalizeID))
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)
const cachePoint = BedrockCache.block(breakpoints, part.cache)
if (cachePoint) content.push(cachePoint)
}
@@ -413,6 +442,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
else messages.push({ role: "user", content })
}
flushImages()
return messages
})
+12 -7
View File
@@ -212,9 +212,11 @@ const OpenAIChatUsage = Schema.StructWithRest(
prompt_tokens: optionalNull(Schema.Number),
completion_tokens: optionalNull(Schema.Number),
total_tokens: optionalNull(Schema.Number),
// Zai reports cache hits as top-level `cached_tokens`; DeepSeek uses `prompt_cache_hit_tokens`.
// Provider-specific cache accounting fields.
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({
@@ -903,16 +905,19 @@ 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`, and Zai uses top-level `cached_tokens`.
// `prompt_cache_hit_tokens`, Zai uses top-level `cached_tokens`, and
// DigitalOcean uses top-level `cache_read_input_tokens` / `cache_created_input_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 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 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 reasoning = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const nonCached = ProviderShared.subtractTokens(input, ProviderShared.sumTokens(cached, cacheWrite))
return new Usage({
+10
View File
@@ -1,4 +1,5 @@
// Shared counter and TTL mapping for provider cache-marker lowering.
import type { CacheHint } from "../../schema/index.js"
export interface Breakpoints {
remaining: number
@@ -11,3 +12,12 @@ 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) }
}
}
+2
View File
@@ -35,6 +35,8 @@ 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,
+76
View File
@@ -0,0 +1,76 @@
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: "anthropic",
providerMetadataKey: "vertex",
protocol: Protocol.make({
id: AnthropicMessages.protocol.id,
body: {
+1 -1
View File
@@ -71,7 +71,7 @@ export const protocol = Protocol.make({
export const route = Route.make({
id: "groq-chat",
provider: id,
providerMetadataKey: "openai",
providerMetadataKey: "groq",
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL }),
framing: OpenAIChat.framing,
+1
View File
@@ -14,6 +14,7 @@ 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"
+2 -14
View File
@@ -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 CacheHint, type ModelID, type OpenString } from "../schema/index.js"
import { HttpOptions, ProviderID, 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 { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
import { cacheControl } from "../protocols/utils/cache.js"
import { isRecord, ProviderShared } from "../protocols/shared.js"
export const id = ProviderID.make("openrouter")
@@ -131,18 +131,6 @@ 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) =>
+28
View File
@@ -8,6 +8,7 @@ import {
AmazonBedrock,
AnthropicCompatible,
CloudflareAIGateway,
DigitalOcean,
GoogleVertexMessages,
Meta,
MiniMax,
@@ -117,6 +118,33 @@ 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("'auto' emits Anthropic cache markers on Anthropic-compatible routes", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+6
View File
@@ -197,9 +197,15 @@ 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}`, () =>
@@ -0,0 +1,54 @@
{
"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"
}
}
]
}
@@ -0,0 +1,53 @@
{
"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"
}
}
]
}
@@ -0,0 +1,54 @@
{
"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"
}
}
]
}
@@ -0,0 +1,53 @@
{
"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
View File
@@ -15,6 +15,7 @@ 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,6 +314,9 @@ 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) =>
@@ -648,6 +648,177 @@ 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(
@@ -0,0 +1,78 @@
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,
)
})
}
@@ -0,0 +1,123 @@
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,6 +302,7 @@ describe("Google Vertex providers", () => {
expect(model.provider).toBe("google-vertex")
expect(response.text).toBe("Hello.")
expect(response.usage?.providerMetadata).toHaveProperty("vertex")
}),
)
@@ -171,6 +171,7 @@ 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"), "anthropic"],
[GoogleVertexMessages.configure(vertex).model("model"), "vertex"],
[Anthropic.configure({ apiKey: "test" }).model("model"), "anthropic"],
[
AnthropicCompatible.configure({ baseURL: "https://example.test/v1", provider: "minimax" }).model("model"),
@@ -64,6 +64,7 @@ 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"],
@@ -532,6 +532,29 @@ 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, {
+1 -1
View File
@@ -24,7 +24,7 @@
"typecheck": "tsgo -b"
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@agentclientprotocol/sdk": "1.6.0",
"@clack/core": "1.0.0-alpha.1",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
+5 -1
View File
@@ -171,7 +171,11 @@ function resourceLinkToPart(link: ResourceLink): PromptPart {
mime: link.mimeType ?? "text/plain",
}
}
return { type: "text", text: link.uri }
return linkReference(link.name, link.uri)
}
export function linkReference(name: string | undefined, uri: string): PromptPart {
return { type: "text", text: name ? `[${name}](${uri})` : uri }
}
function filenameFromUri(uri: string | undefined): string | undefined {
+83
View File
@@ -0,0 +1,83 @@
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"
+11
View File
@@ -28,6 +28,11 @@ 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", {
@@ -57,6 +62,7 @@ const Errors = Schema.Union([
InvalidModelError,
InvalidEffortError,
InvalidModeError,
InvalidAdditionalDirectoryError,
AuthRequiredError,
UnknownAuthMethodError,
InvalidRequestError,
@@ -88,6 +94,11 @@ 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":
+24 -8
View File
@@ -41,6 +41,7 @@ import { OPENCODE_VERSION } from "../version"
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 { ACPError } from "./error"
import { ACPPromise } from "./promise"
import type { ACPSessions, Attached } from "./sessions"
@@ -171,7 +172,7 @@ export function make(input: {
loadSession: true,
mcpCapabilities: { http: true, sse: false },
promptCapabilities: { embeddedContext: true, image: true },
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
sessionCapabilities: { additionalDirectories: {}, close: {}, delete: {}, fork: {}, list: {}, resume: {} },
_meta: { [ACPTranslate.ChildSessionUpdatesCapability]: true },
},
authMethods: [authMethod],
@@ -184,17 +185,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 } }),
input.client.session.create({
location: { directory: params.cwd },
...ACPDirectories.grant(directories),
}),
)
const attached = yield* input.sessions.attach(created, params.cwd, params.mcpServers)
return { sessionId: attached.id, configOptions: yield* currentOptions(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)),
@@ -212,12 +219,16 @@ export function make(input: {
}),
)
return {
sessions: page.data.map((session) => ({
sessionId: session.id,
cwd: session.location.directory,
title: withTimestampedFallback(session),
updatedAt: new Date(session.time.updated).toISOString(),
})),
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(),
}
}),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
}
}),
@@ -233,7 +244,9 @@ export function make(input: {
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) }
}),
@@ -243,7 +256,10 @@ export function make(input: {
return {}
}),
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 })),
+17 -4
View File
@@ -22,10 +22,12 @@ import {
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 { promptContentToParts } from "./content"
import { linkReference, promptContentToParts, type PromptPart } from "./content"
import { ACPElicitation } from "./elicitation"
import { ACPError } from "./error"
import { ACPPermission } from "./permission"
@@ -372,7 +374,10 @@ export const make = Effect.fnUntraced(function* (input: {
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 prompt = preparePrompt(catalog, params.prompt, SessionMessage.ID.create())
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)) {
@@ -414,8 +419,7 @@ function aborted(signal: AbortSignal) {
})
}
function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messageID: string): PreparedPrompt {
const parts = promptContentToParts(prompt)
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")
@@ -426,6 +430,15 @@ function preparePrompt(catalog: Catalog, prompt: PromptRequest["prompt"], messag
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 }
+15 -11
View File
@@ -1,4 +1,4 @@
import { ndJsonStream } from "@agentclientprotocol/sdk"
import { MessageTooLargeError, 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,25 +16,29 @@ 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 code = yield* Effect.raceFirst(
Effect.promise(() => connection.closed).pipe(Effect.as(0)),
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,
),
),
endpoint.exited.pipe(
Effect.match({
onSuccess: (code) => `code ${code}`,
onFailure: (error) =>
error.cause instanceof CrossSpawnSpawner.KilledBySignal ? `signal ${error.cause.signal}` : error.message,
}),
// 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
}),
),
Effect.map((reason) => `server exited unexpectedly (${reason})`),
),
)
// 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(() => process.exit(code))
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)
})
}),
)
+30 -16
View File
@@ -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, Fiber, FileSystem, Option, Queue } from "effect"
import { Context, Effect, FileSystem, Option, Queue, Schedule, Semaphore } from "effect"
import { ServerConnection } from "../../services/server-connection"
import { Updater } from "../../services/updater"
import { UpdatePreflight } from "../../services/update-preflight"
@@ -61,13 +61,29 @@ export default Runtime.handler(Commands, (input) =>
})) !== undefined
const updater = yield* Updater.Service
let installing: string | undefined
const updateListeners = new Set<(version: string) => void>()
const update = yield* updater
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
.run((version) => {
installing = version
updateListeners.forEach((notify) => notify(version))
installListeners.forEach((notify) => notify(version))
})
.pipe(Effect.ensuring(Effect.sync(() => (installing = undefined))), Effect.forkScoped)
.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 }),
)
preflight.loading()
const config = yield* Config.Service
const npm = yield* Npm.Service
@@ -106,21 +122,19 @@ export default Runtime.handler(Commands, (input) =>
},
updater: {
remote: requestedServer !== undefined,
subscribe: (notify, signal) =>
runPromise(
Fiber.join(update).pipe(
Effect.flatMap((result) => (result === undefined ? Effect.void : Effect.sync(() => notify(result)))),
),
{ signal },
),
subscribe: (notify) => {
if (latest) notify(latest)
resultListeners.add(notify)
return () => resultListeners.delete(notify)
},
check: (signal, notify) => {
if (installing) notify(installing)
updateListeners.add(notify)
return runPromise(Fiber.join(update).pipe(Effect.flatMap(() => updater.check())), { signal }).finally(() =>
updateListeners.delete(notify),
installListeners.add(notify)
return runPromise(checking.withPermits(1)(updater.check()), { signal }).finally(() =>
installListeners.delete(notify),
)
},
apply: (version) => runPromise(updater.apply(version)),
apply: (version) => runPromise(checking.withPermits(1)(updater.apply(version))),
},
packages: {
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
+2 -1
View File
@@ -13,6 +13,7 @@ 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"
@@ -113,7 +114,7 @@ Effect.gen(function* () {
Effect.provide(Config.layer),
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]), {
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node, EffectFlock.node]), {
replacements: [
Global.node.replace(
Global.layerWith(process.env.OPENCODE_CONFIG_DIR ? { config: process.env.OPENCODE_CONFIG_DIR } : {}),
+5
View File
@@ -1,5 +1,6 @@
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"
@@ -127,6 +128,7 @@ 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* () {
@@ -370,6 +372,9 @@ 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 })
@@ -0,0 +1,58 @@
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)
})
@@ -0,0 +1,182 @@
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])
})
})
+6
View File
@@ -80,6 +80,12 @@ 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({
@@ -17,6 +17,20 @@ 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()
@@ -56,7 +56,22 @@ describe("acp prompt content subprocess", () => {
}),
)
const missing = expectOk(
await acp.request<PromptResponse>("session/prompt", {
sessionId: session.sessionId,
prompt: [
{ type: "text", text: "Use this missing file." },
{
type: "resource_link",
uri: pathToFileURL(path.join(fixture.home, "missing.md")).href,
name: "missing.md",
},
],
}),
)
expect(linked.stopReason).toBe("end_turn")
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(3)
expect(missing.stopReason).toBe("end_turn")
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(4)
}, 60_000)
})
+39 -2
View File
@@ -2,6 +2,10 @@ import { describe, expect, test } from "bun:test"
import type { StopReason } from "@agentclientprotocol/sdk"
import type { OpenCodeEvent } from "@opencode/client/promise"
import { Schema } from "effect"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { tmpdir } from "../fixture/tmpdir"
import {
childCreated,
delivered,
@@ -80,12 +84,15 @@ describe("acp prompt turns over the wire", () => {
})
test("submits assistant-only context as synthetic input before the visible prompt", async () => {
await using dir = await tmpdir()
const readme = pathToFileURL(path.join(dir.path, "README.md")).href
await Bun.write(path.join(dir.path, "README.md"), "# readme\n")
await using acp = await startSession()
await acp.prompt(acp.sessionId, [
{ type: "text", text: "visible" },
{ type: "text", text: "hidden context", annotations: { audience: ["assistant"] } },
{ type: "resource_link", uri: "file:///workspace/README.md", name: "README.md", mimeType: "text/markdown" },
{ type: "resource_link", uri: readme, name: "README.md", mimeType: "text/markdown" },
])
expect(acp.server.submissions).toEqual([
@@ -100,7 +107,37 @@ describe("acp prompt turns over the wire", () => {
expect.objectContaining({
kind: "prompt",
text: "visible",
files: [{ uri: "file:///workspace/README.md", name: "README.md" }],
files: [{ uri: readme, name: "README.md" }],
}),
])
})
test("attaches readable file links and references unreadable ones in place", async () => {
await using dir = await tmpdir()
const file = pathToFileURL(path.join(dir.path, "notes.md")).href
const folder = pathToFileURL(path.join(dir.path, "src")).href
const missing = pathToFileURL(path.join(dir.path, "missing.md")).href
await Bun.write(path.join(dir.path, "notes.md"), "# notes\n")
await mkdir(path.join(dir.path, "src"))
await using acp = await startSession()
const response = await acp.prompt(acp.sessionId, [
{ type: "text", text: "compare" },
{ type: "resource_link", uri: file, name: "notes.md" },
{ type: "resource_link", uri: missing, name: "missing.md" },
{ type: "resource_link", uri: folder, name: "src" },
{ type: "text", text: "please" },
])
expect(response.stopReason).toBe("end_turn")
expect(acp.server.submissions).toEqual([
expect.objectContaining({
kind: "prompt",
text: `compare\n[missing.md](${missing})\nplease`,
files: [
{ uri: file, name: "notes.md" },
{ uri: folder, name: "src" },
],
}),
])
})
+1 -1
View File
@@ -26,7 +26,7 @@ describe("acp session lifecycle over the wire", () => {
loadSession: true,
mcpCapabilities: { http: true, sse: false },
promptCapabilities: { embeddedContext: true, image: true },
sessionCapabilities: { close: {}, delete: {}, fork: {}, list: {}, resume: {} },
sessionCapabilities: { additionalDirectories: {}, close: {}, delete: {}, fork: {}, list: {}, resume: {} },
_meta: { "opencode/child-session-updates": true },
},
agentInfo: { name: "OpenCode" },
+24 -2
View File
@@ -61,7 +61,16 @@ const SyntheticBody = Schema.Struct({
delivery: Delivery,
resume: Schema.optional(Schema.Boolean),
})
const CreateBody = Schema.Struct({ location: Schema.Struct({ directory: Schema.String }) })
const Permissions = Schema.Array(
Schema.Struct({ action: Schema.String, resource: Schema.String, effect: Schema.Literals(["allow", "deny", "ask"]) }),
)
const Metadata = Schema.Record(Schema.String, Schema.MutableJson)
const CreateBody = Schema.Struct({
location: Schema.Struct({ directory: Schema.String }),
permissions: Schema.optional(Permissions),
metadata: Schema.optional(Metadata),
})
const UpdateBody = Schema.Struct({ permissions: Schema.optional(Permissions), metadata: Schema.optional(Metadata) })
const ModelBody = Schema.Struct({
model: Schema.Struct({ providerID: Schema.String, id: Schema.String, variant: Schema.optional(Schema.String) }),
})
@@ -712,7 +721,13 @@ function startServer(options: WireOptions, changed: () => void) {
return Response.json(page(sessions, query, 100))
}),
POST: body(CreateBody, (_req, input) =>
Response.json({ data: createSession(makeSession("", { cwd: input.location.directory })) }),
Response.json({
data: createSession({
...makeSession("", { cwd: input.location.directory }),
...(input.permissions ? { permissions: [...input.permissions] } : {}),
...(input.metadata ? { metadata: input.metadata } : {}),
}),
}),
),
},
"/api/session/:sessionID": {
@@ -727,6 +742,13 @@ function startServer(options: WireOptions, changed: () => void) {
if (invalid) return invalid
return fake.sessions.delete(req.params.sessionID) ? noContent() : notFound(req.params.sessionID)
}),
PATCH: body(UpdateBody, (req, input) => {
const session = fake.sessions.get(req.params.sessionID)
if (!session) return notFound(req.params.sessionID)
if (input.permissions) session.permissions = [...input.permissions]
if (input.metadata) session.metadata = input.metadata
return noContent()
}),
},
"/api/session/:sessionID/fork": {
POST: route((req) => {
+8 -1
View File
@@ -1,8 +1,10 @@
import { NodeServices } from "@effect/platform-node"
import { EffectFlock } from "@opencode/util/effect-flock"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { AppProcess } from "@opencode/util/process"
import { expect, spyOn, test } from "bun:test"
import { Effect, FileSystem, PlatformError, Stream } from "effect"
import { Effect, FileSystem, Layer, PlatformError, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { existsSync, readdirSync, readFileSync } from "node:fs"
import path from "node:path"
@@ -59,6 +61,11 @@ function fixture(
const commands: string[][] = []
const updater = yield* Updater.Service.pipe(
Effect.provide(Updater.layer),
Effect.provide(
LayerNode.compile(EffectFlock.node, {
replacements: [Global.node.replace(Layer.succeed(Global.Service, global))],
}),
),
Effect.provideService(Global.Service, global),
Effect.provideService(FileSystem.FileSystem, {
...fs,
+1
View File
@@ -93,6 +93,7 @@ const HOSTS: Readonly<Record<string, Readonly<Record<string, string>>>> = {
},
"cloudflare-workers-ai": { "@ai-sdk/openai-compatible": "@opencode/ai/providers/cloudflare-workers-ai" },
deepseek: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/deepseek" },
digitalocean: { "@ai-sdk/openai-compatible": "@opencode/ai/providers/digitalocean" },
"fireworks-ai": { "@ai-sdk/openai-compatible": "@opencode/ai/providers/fireworks" },
"google-vertex": { "@ai-sdk/openai-compatible": "@opencode/ai/providers/google-vertex/chat" },
"kimi-for-coding": protocols("moonshot"),
@@ -60,14 +60,17 @@ export const Plugin = define({
})
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
if (!discovery.global) return []
if (!discovery.global || !(yield* fs.isFile(globalFile))) return []
const file = yield* read(globalFile)
return file ? [file] : []
})
const projectSource = Effect.fn("ConfigInstructionPlugin.projectSource")(function* () {
if (!project) return []
const walked = yield* Effect.forEach(yield* fs.up({ targets: ["AGENTS.md"], start, stop }), fs.resolve)
const walked = yield* Effect.forEach(
yield* fs.up({ targets: ["AGENTS.md"], start, stop, type: "file" }),
fs.resolve,
)
const discovered = new Set(walked.filter((file) => discovery.global || file !== globalFile))
const files = yield* Effect.forEach(discovered, read, { concurrency: "unbounded" })
if (files.some((file) => file === undefined)) return Instructions.unavailable
-14
View File
@@ -24,7 +24,6 @@ type Cost = {
readonly cache_read?: Money.USDPerMillionTokens
readonly cache_write?: Money.USDPerMillionTokens
readonly tiers?: readonly (Cost & { readonly tier: { readonly type: "context"; readonly size: number } })[]
readonly context_over_200k?: Omit<Cost, "tiers" | "context_over_200k">
}
type Modality = "text" | "audio" | "image" | "video" | "pdf"
@@ -147,19 +146,6 @@ function cost(input: SourceModel["cost"]): Model.Info["cost"] {
write: item.cache_write ?? Money.USDPerMillionTokens.zero,
},
})) ?? []),
...(input?.context_over_200k
? [
{
tier: { type: "context" as const, size: 200_000 },
input: input.context_over_200k.input,
output: input.context_over_200k.output,
cache: {
read: input.context_over_200k.cache_read ?? Money.USDPerMillionTokens.zero,
write: input.context_over_200k.cache_write ?? Money.USDPerMillionTokens.zero,
},
},
]
: []),
]
}
+1 -1
View File
@@ -1 +1 @@
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-context automation","famLine truncated
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MiMo-V2.6-Flash","description":"MiMo Flash model for multimodal coding agents and long-context automation","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.14,"output":0.28,"cache_read":0.0028},"canonical_model_id":"xiaomi/mimo-v2.6-flash"},"XiaomiMiMo/MiMo-V2.5":{"id":"XiaomiMiMo/MiMo-V2.5","name":"MiMo-V2.5","description":"Open MiMo model for multimodal coding agents and long-context automation","famLine truncated
+488 -191
View File
@@ -1,4 +1,5 @@
import { Clock, Effect, Schema, Semaphore, Stream } from "effect"
import { Clock, Effect, FiberHandle, Option, Schema, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { ChildProcess } from "effect/unstable/process"
import { define } from "@opencode/plugin/effect/plugin"
import { Form } from "@opencode/schema/form"
@@ -7,14 +8,22 @@ import { App } from "../../app.js"
import { Bus } from "../../bus.js"
import { Credential } from "../../credential.js"
import { Integration } from "../../integration.js"
import { IntegrationConnection } from "../../integration/connection.js"
import { Model } from "../../model.js"
import { Provider } from "../../provider.js"
import { iife } from "../../util/iife.js"
import { which } from "../../util/which.js"
import type { PluginInternal } from "../internal.js"
import { configuredSettings } from "./configured.js"
const cognitiveScope = "https://cognitiveservices.azure.com/.default"
const foundryScope = "https://ai.azure.com/.default"
const managementScope = "https://management.azure.com/.default"
const methodID = Integration.MethodID.make("azure-cli")
// A resource name becomes a hostname label and a query literal, so anything else never leaves the process. Azure
// allows only letters, digits, and hyphens in it.
// https://learn.microsoft.com/azure/ai-services/cognitive-services-custom-subdomains
const resourcePattern = /^[a-zA-Z0-9][a-zA-Z0-9-]*$/
const decodeJSON = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))
const decodeToken = Schema.decodeUnknownEffect(
Schema.Struct({
@@ -23,209 +32,443 @@ const decodeToken = Schema.decodeUnknownEffect(
expiresOn: Schema.optional(Schema.NonEmptyString),
}),
)
export const AzurePlugin = define({
id: "opencode.provider.azure",
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(Provider.ID.azure)
const processes = yield* AppProcess.Service
const bus = yield* Bus.Service
const tokens = new Map<string, { access: string; expires: number }>()
const loading = Semaphore.makeUnsafe(1)
const loaded: { resource?: string } = {}
const ResourceDeployments = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
const decodeResourceDeployment = Schema.decodeUnknownOption(
Schema.Struct({ id: Schema.NonEmptyString, model: Schema.NonEmptyString, status: Schema.String }),
)
const ManagementDeployments = Schema.Struct({
value: Schema.Array(Schema.Unknown),
nextLink: Schema.optional(Schema.NonEmptyString),
})
const decodeManagementDeployment = Schema.decodeUnknownOption(
Schema.Struct({
name: Schema.NonEmptyString,
properties: Schema.Struct({
model: Schema.Struct({ name: Schema.NonEmptyString }),
provisioningState: Schema.String,
}),
}),
)
const ResourceQuery = Schema.Struct({ query: Schema.String })
const Resources = Schema.Struct({ data: Schema.Array(Schema.Struct({ id: Schema.NonEmptyString })) })
const command = (args: string[]) =>
processes
.run(ChildProcess.make("az", args, { extendEnv: true, stdin: "ignore" }), { timeout: "10 seconds" })
.pipe(
Effect.flatMap(AppProcess.requireSuccess),
Effect.flatMap((result) => decodeJSON(result.stdout.toString("utf8"))),
type Deployment = { readonly name: string; readonly model: string }
export function make(
endpoints = {
resource: (name: string) => `https://${name}.openai.azure.com/openai`,
management: "https://management.azure.com",
},
) {
return define({
id: "opencode.provider.azure",
effect: Effect.fn(function* (ctx) {
const configured = yield* configuredSettings(Provider.ID.azure)
const processes = yield* AppProcess.Service
const bus = yield* Bus.Service
const credentials = yield* Credential.Service
const providers = yield* Provider.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const tokens = new Map<string, { access: string; expires: number }>()
// A resource keeps its Azure Resource Manager ID until it is deleted, so discovery looks it up once.
const resourceIDs = new Map<string, string>()
const loading = Semaphore.makeUnsafe(1)
const discovery = yield* FiberHandle.make<void, never>()
const loaded: {
resource?: string
url?: string
deployments?: readonly Deployment[]
connection?: Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
} = {}
const command = (args: string[]) =>
processes
.run(ChildProcess.make("az", args, { extendEnv: true, stdin: "ignore" }), { timeout: "10 seconds" })
.pipe(
Effect.flatMap(AppProcess.requireSuccess),
Effect.flatMap((result) => decodeJSON(result.stdout.toString("utf8"))),
)
const token = Effect.fn("AzurePlugin.token")(function* (scope: string) {
const now = yield* Clock.currentTimeMillis
const cached = tokens.get(scope)
if (cached && cached.expires - now > 5 * 60_000) return cached
const result = yield* command(["account", "get-access-token", "--scope", scope, "--output", "json"]).pipe(
Effect.flatMap(decodeToken),
)
const token = Effect.fn("AzurePlugin.token")(function* (scope: string) {
const now = yield* Clock.currentTimeMillis
const cached = tokens.get(scope)
if (cached && cached.expires - now > 5 * 60_000) return cached
const result = yield* command(["account", "get-access-token", "--scope", scope, "--output", "json"]).pipe(
Effect.flatMap(decodeToken),
)
const expires = result.expires_on !== undefined ? result.expires_on * 1000 : Date.parse(result.expiresOn ?? "")
if (!Number.isFinite(expires))
return yield* Effect.fail(new Error("Azure CLI returned an invalid token expiration"))
const refreshed = { access: result.accessToken, expires }
tokens.set(scope, refreshed)
return refreshed
})
const available = Boolean(which("az"))
const form = () =>
iife(() => {
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
return Form.Fields.make([
{
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
},
])
const expires = result.expires_on !== undefined ? result.expires_on * 1000 : Date.parse(result.expiresOn ?? "")
if (!Number.isFinite(expires))
return yield* Effect.fail(new Error("Azure CLI returned an invalid token expiration"))
const refreshed = { access: result.accessToken, expires }
tokens.set(scope, refreshed)
return refreshed
})
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID: Provider.ID.azure,
method: { type: "key", label: "API key", form: form() },
})
if (!available) return
editor.method.update({
integrationID: Provider.ID.azure,
method: {
id: methodID,
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
form: form(),
},
authorize: (answer) =>
Effect.succeed({
mode: "auto" as const,
url: "",
instructions: "Sign in with `az login` before continuing.",
callback: Effect.gen(function* () {
const resourceName =
typeof answer.resourceName === "string" ? answer.resourceName : resolveResourceName(configured)
if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required"))
const current = yield* token(cognitiveScope)
loaded.resource = resourceName
yield* ctx.provider.reload()
return Credential.OAuth.make({
type: "oauth",
methodID,
access: current.access,
refresh: "azure-cli",
expires: current.expires,
metadata: { resourceName },
})
}),
}),
refresh: (credential) =>
token(cognitiveScope).pipe(
Effect.map((current) =>
Credential.OAuth.make({ ...credential, access: current.access, expires: current.expires }),
const management = (request: HttpClientRequest.HttpClientRequest) =>
token(managementScope).pipe(
Effect.flatMap((current) =>
http.execute(
request.pipe(
HttpClientRequest.bearerToken(current.access),
HttpClientRequest.acceptJson,
HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app)),
),
),
),
})
})
const load = Effect.fn("AzurePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) {
loaded.resource = undefined
return
}
const resource =
typeof credential.metadata?.resourceName === "string" ? credential.metadata.resourceName : undefined
loaded.resource = resource
})
yield* load()
yield* ctx.provider.transform((evt) => {
for (const item of evt.list()) {
if (
item.provider.id !== Provider.ID.azure &&
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
)
continue
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
const websocket = responsesWebSocketCapable(item.provider)
if (!resourceName && !websocket) continue
evt.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
...(resourceName === undefined ? {} : { resourceName }),
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
const available = Boolean(which("az"))
const form = () =>
iife(() => {
if (resolveResourceName(configured) || typeof configured?.baseURL === "string") return
return Form.Fields.make([
{
type: "string",
key: "resourceName",
title: "Enter Azure Resource Name",
placeholder: "e.g. my-models",
required: true,
},
])
})
}
})
yield* ctx.model.transform((models) => {
for (const item of models.provider.list()) {
if (
item.provider.id !== Provider.ID.azure &&
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
yield* ctx.integration.transform((editor) => {
editor.method.update({
integrationID: Provider.ID.azure,
method: { type: "key", label: "API key", form: form() },
})
if (!available) return
editor.method.update({
integrationID: Provider.ID.azure,
method: {
id: methodID,
type: "oauth",
label: "Microsoft Entra ID (Azure CLI)",
form: form(),
},
authorize: (answer) =>
Effect.succeed({
mode: "auto" as const,
url: "",
instructions: "Sign in with `az login` before continuing.",
callback: Effect.gen(function* () {
const resourceName =
(typeof answer.resourceName === "string" ? answer.resourceName.trim() : "") ||
resolveResourceName(configured)
if (!resourceName) return yield* Effect.fail(new Error("Azure resource name is required"))
const current = yield* token(cognitiveScope)
return Credential.OAuth.make({
type: "oauth",
methodID,
access: current.access,
refresh: "azure-cli",
expires: current.expires,
metadata: { resourceName },
})
}),
}),
refresh: (credential) =>
token(cognitiveScope).pipe(
Effect.map((current) =>
Credential.OAuth.make({ ...credential, access: current.access, expires: current.expires }),
),
),
})
})
const load = Effect.fn("AzurePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
// Startup awaits this, so it reads the stored credential: resolving the connection would refresh an
// expired token through the Azure CLI.
const stored =
connection?.type === "credential" ? yield* credentials.get(Credential.ID.make(connection.id)) : undefined
return { connection, resource: credentialResource(stored?.value) }
})
// Resource Graph searches every subscription the Azure CLI account can read, not only the selected one.
// https://learn.microsoft.com/rest/api/azureresourcegraph/resourcegraph/resources/resources
const findResource = Effect.fn("AzurePlugin.findResource")(function* (resource: string) {
const response = yield* HttpClientRequest.post(
`${endpoints.management}/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01`,
).pipe(
HttpClientRequest.schemaBodyJson(ResourceQuery)({ query: resourceQuery(resource) }),
Effect.flatMap(management),
Effect.flatMap(HttpClientResponse.schemaBodyJson(Resources)),
Effect.timeout("10 seconds"),
)
continue
const resourceName = resolveResourceName(item.provider.settings, loaded.resource)
for (const model of models.list(item.provider.id)) {
models.update(item.provider.id, model.id, (draft) => {
if (resourceName && typeof draft.settings?.baseURL === "string")
draft.settings.baseURL = expandResourceName(
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
const id = response.data[0]?.id
if (!id) return yield* Effect.fail(new Error(`Azure resource "${resource}" was not found`))
return id
})
const managementDeployments = Effect.fn("AzurePlugin.managementDeployments")(function* (resource: string) {
const key = resource.toLowerCase()
const id = resourceIDs.get(key) ?? (yield* findResource(resource))
resourceIDs.set(key, id)
const origin = new URL(endpoints.management).origin
return yield* Stream.paginate(`${endpoints.management}${id}/deployments?api-version=2024-10-01`, (url) =>
management(HttpClientRequest.get(url)).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(ManagementDeployments)),
Effect.timeout("10 seconds"),
Effect.flatMap((response) =>
// Every page carries the management token, so a page link must stay on the management endpoint.
// https://learn.microsoft.com/rest/api/aiservices/accountmanagement/deployments/list
response.nextLink !== undefined && URL.parse(response.nextLink)?.origin !== origin
? Effect.fail(new Error("Azure returned a deployment page outside the management endpoint"))
: Effect.succeed([
response.value.flatMap((raw): Deployment[] => {
const item = Option.getOrUndefined(decodeManagementDeployment(raw))
return item?.properties.provisioningState === "Succeeded"
? [{ name: item.name, model: item.properties.model.name }]
: []
}),
Option.fromNullishOr(response.nextLink),
] as const),
),
),
).pipe(
Stream.runCollect,
// A moved or recreated resource has a new ID, so the next discovery looks it up again.
Effect.tapError(() => Effect.sync(() => resourceIDs.delete(key))),
)
})
const resourceDeployments = Effect.fn("AzurePlugin.resourceDeployments")(function* (
url: string,
credential: Credential.Value,
) {
return yield* http
.execute(
HttpClientRequest.get(url).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app)),
credential.type === "key"
? HttpClientRequest.setHeader("api-key", credential.key)
: HttpClientRequest.bearerToken(credential.access),
),
)
.pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(ResourceDeployments)),
Effect.timeout("10 seconds"),
Effect.map((response) =>
response.data.flatMap((raw): Deployment[] => {
const item = Option.getOrUndefined(decodeResourceDeployment(raw))
return item?.status === "succeeded" ? [{ name: item.id, model: item.model }] : []
}),
),
)
})
// Azure documents the management API as the deployment inventory, but only an Azure CLI session can reach it:
// Azure Resource Manager accepts Entra ID tokens, never resource keys.
// https://learn.microsoft.com/rest/api/aiservices/accountmanagement/deployments/list
// The resource's own inventory serves API keys and identities without Azure Resource Manager read access. Only
// data-plane version 2022-12-01 has it; later versions dropped `/deployments` and keep `/models`, which lists
// models the resource can deploy rather than its deployments.
// https://github.com/Azure/azure-rest-api-specs/blob/main/specification/cognitiveservices/data-plane/OpenAIAuthoring/stable/2022-12-01/azureopenai.json
const deployments = (url: string, resource: string, credential: Credential.Value) =>
credential.type === "oauth"
? managementDeployments(resource).pipe(Effect.catch(() => resourceDeployments(url, credential)))
: resourceDeployments(url, credential)
// Local and quick, so a switch rebinds the provider before discovery for the new connection calls Azure.
const rebind = () =>
loading.withPermit(
Effect.gen(function* () {
const current = yield* load()
if (
IntegrationConnection.key(current.connection) === IntegrationConnection.key(loaded.connection) &&
current.resource === loaded.resource
)
return
Object.assign(loaded, current, { url: undefined, deployments: undefined })
yield* ctx.provider.reload()
}),
)
const discover = Effect.fn("AzurePlugin.discover")(function* () {
const connection = loaded.connection
const settings = (yield* providers.get(Provider.ID.azure))?.settings
const name = loaded.resource ?? resolveResourceName(settings)
// A custom endpoint may expose other deployments than the resource does, so it keeps the catalog.
const url =
connection && name !== undefined && resourcePattern.test(name) && typeof settings?.baseURL !== "string"
? `${endpoints.resource(name)}/deployments?api-version=2022-12-01`
: undefined
if (loaded.connection !== connection) return
// Keep the last inventory through transient failures only for the same connection and resource.
if (loaded.url !== url) {
loaded.url = url
if (loaded.deployments) {
loaded.deployments = undefined
yield* ctx.model.reload()
}
}
if (!connection || !name || !url) return
const credential = yield* ctx.integration.connection
.resolve(connection)
.pipe(Effect.orElseSucceed(() => undefined))
if (!credential || (credential.type === "oauth" && credential.methodID !== methodID)) return
const found = yield* deployments(url, name, credential).pipe(
// Azure promises no order; normalize it so a reordered response does not rebuild the model list.
Effect.map((list) => list.toSorted((a, b) => a.name.localeCompare(b.name))),
Effect.catch((cause) =>
Effect.logWarning("failed to sync Azure deployments", { cause }).pipe(Effect.as(undefined)),
),
)
if (!found) return
if (
loaded.connection !== connection ||
IntegrationConnection.key(connection) !==
IntegrationConnection.key(yield* ctx.integration.connection.active(Provider.ID.azure))
)
return
if (JSON.stringify(found) === JSON.stringify(loaded.deployments)) return
const catalog = new Map(
Array.from((yield* providers.snapshot()).records.get(Provider.ID.azure)?.models.keys() ?? [], (id) => [
id.toLowerCase(),
id,
]),
)
const unmatched = found.filter((deployment) => !catalogModel(catalog, deployment))
if (unmatched.length > 0)
yield* Effect.logWarning("Azure deployments of models outside the catalog need explicit configuration", {
deployments: unmatched.map((deployment) => deployment.name),
})
loaded.deployments = found
yield* ctx.model.reload()
})
const refresh = () => rebind().pipe(Effect.andThen(FiberHandle.run(discovery, discover())))
// The connection's resource wins for Azure itself, matching the runtime merge of credentials over settings.
const resourceFor = (provider: Provider.Info) =>
provider.id === Provider.ID.azure
? (loaded.resource ?? resolveResourceName(provider.settings))
: resolveResourceName(provider.settings, loaded.resource)
Object.assign(loaded, yield* load())
yield* ctx.provider.transform((evt) => {
for (const item of evt.list()) {
if (
item.provider.id !== Provider.ID.azure &&
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
)
continue
const resourceName = resourceFor(item.provider)
const websocket = responsesWebSocketCapable(item.provider)
if (!resourceName && !websocket) continue
evt.update(item.provider.id, (provider) => {
provider.settings = {
...provider.settings,
...(resourceName === undefined ? {} : { resourceName }),
...(websocket ? { transport: provider.settings?.transport ?? "websocket" } : {}),
...(resourceName !== undefined && typeof provider.settings?.baseURL === "string"
? { baseURL: expandResourceName(provider.settings.baseURL, resourceName) }
: {}),
}
})
}
}
})
const item = evt.get(Provider.ID.azure)
if (!item) return
// Bind resource settings and discovery to their account, so a switch hides them until the rebind.
// Keep the full templates here for explicit configuration; the model transform narrows the visible list.
evt.add({
info: item.provider,
models: Array.from(item.models.values()),
sourceConnection: loaded.connection,
})
})
yield* ctx.model.transform((models) => {
for (const item of models.provider.list()) {
if (
item.provider.id !== Provider.ID.azure &&
!item.provider.package.startsWith("@opencode/ai/providers/azure/")
)
continue
const resourceName = resourceFor(item.provider)
for (const model of models.list(item.provider.id)) {
models.update(item.provider.id, model.id, (draft) => {
if (resourceName && typeof draft.settings?.baseURL === "string")
draft.settings.baseURL = expandResourceName(
draft.settings.baseURL,
resolveResourceName(draft.settings, resourceName) ?? resourceName,
)
})
}
}
if (!loaded.deployments) return
// Narrowing here rather than in the provider catalog keeps every catalog model available as the base
// of a model the user configures explicitly; those are applied after this transform.
const catalog = models.list(Provider.ID.azure)
const deployed = deployedModels(loaded.deployments, catalog)
for (const model of catalog) {
if (!deployed.has(model.id)) models.remove(Provider.ID.azure, model.id)
}
for (const [id, model] of deployed) {
models.update(Provider.ID.azure, id, (draft) => Object.assign(draft, model))
}
})
const reload = () => loading.withPermit(load().pipe(Effect.andThen(ctx.provider.reload())))
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("azure")),
Stream.runForEach(reload),
Effect.forkScoped({ startImmediately: true }),
)
// A switch interrupts discovery for the previous connection instead of waiting for its Azure calls.
yield* bus.subscribe(Credential.Event.Switched).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("azure")),
Stream.runForEach(() => refresh()),
Effect.forkScoped({ startImmediately: true }),
)
// Deployments load in the background so startup never waits on Azure; the catalog serves until they arrive.
// Later changes load when the connection changes, so a new deployment needs a reconnect or restart.
yield* refresh().pipe(Effect.forkScoped)
// Entra bearer tokens are minted per request from the target URL's scope, so they are injected
// at the transport hooks rather than stored as a credential.
const bearer = Effect.fn("AzurePlugin.bearer")(function* (url: string) {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
const target = new URL(url)
const scope =
target.hostname.endsWith(".services.ai.azure.com") && !target.pathname.startsWith("/models")
? foundryScope
: cognitiveScope
const current = yield* token(scope).pipe(Effect.orDie)
return `Bearer ${current.access}`
})
yield* ctx.session.hook(
"http.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const authorization = yield* bearer(evt.request.url)
if (!authorization) return
evt.request.headers.delete("api-key")
evt.request.headers.delete("x-api-key")
evt.request.headers.set("authorization", authorization)
evt.request.headers.set("user-agent", App.useragent(ctx.app))
}),
{ providerID: Provider.ID.azure },
)
yield* ctx.session.hook(
"experimental.ws.handshake",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const authorization = yield* bearer(evt.url)
if (!authorization) return
delete evt.headers["api-key"]
delete evt.headers["x-api-key"]
evt.headers.authorization = authorization
evt.headers["user-agent"] = App.useragent(ctx.app)
}),
{ providerID: Provider.ID.azure },
)
}),
})
// Entra bearer tokens are minted per request from the target URL's scope, so they are injected
// at the transport hooks rather than stored as a credential.
const bearer = Effect.fn("AzurePlugin.bearer")(function* (url: string) {
const connection = yield* ctx.integration.connection.active(Provider.ID.azure)
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
if (credential?.type !== "oauth" || credential.methodID !== methodID) return
const target = new URL(url)
const scope =
target.hostname.endsWith(".services.ai.azure.com") && !target.pathname.startsWith("/models")
? foundryScope
: cognitiveScope
const current = yield* token(scope).pipe(Effect.orDie)
return `Bearer ${current.access}`
})
yield* ctx.session.hook(
"http.request",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const authorization = yield* bearer(evt.request.url)
if (!authorization) return
evt.request.headers.delete("api-key")
evt.request.headers.delete("x-api-key")
evt.request.headers.set("authorization", authorization)
evt.request.headers.set("user-agent", App.useragent(ctx.app))
}),
{ providerID: Provider.ID.azure },
)
yield* ctx.session.hook(
"experimental.ws.handshake",
(evt) =>
Effect.gen(function* () {
if (evt.model.providerID !== Provider.ID.azure) return
const authorization = yield* bearer(evt.url)
if (!authorization) return
delete evt.headers["api-key"]
delete evt.headers["x-api-key"]
evt.headers.authorization = authorization
evt.headers["user-agent"] = App.useragent(ctx.app)
}),
{ providerID: Provider.ID.azure },
)
}),
} satisfies PluginInternal.InternalPlugin)
}
export const AzurePlugin = make()
function resolveResourceName(settings: Readonly<Record<string, unknown>> | undefined, fallback?: string) {
const configured = settings?.resourceName
@@ -239,6 +482,60 @@ function expandResourceName(baseURL: string, resourceName: string) {
.replaceAll("${AZURE_COGNITIVE_SERVICES_RESOURCE_NAME}", resourceName)
}
// The Azure CLI method stores the resource as credential metadata, the API key method as its form answer.
function credentialResource(credential: Credential.Value | undefined) {
const resource =
credential?.type === "key"
? credential.configuration?.resourceName
: credential?.methodID === methodID
? credential.metadata?.resourceName
: undefined
return typeof resource === "string" && resource.trim() !== "" ? resource : undefined
}
function resourceQuery(resource: string) {
return [
"resources",
"| where type =~ 'microsoft.cognitiveservices/accounts' and kind in~ ('AIServices', 'OpenAI')",
// The custom subdomain is the resource name of every endpoint, and Entra ID authentication requires one.
// Subdomains are globally unique, so a name matches at most one resource.
// https://learn.microsoft.com/azure/ai-services/cognitive-services-custom-subdomains
"| extend resourceName = tostring(properties.customSubDomainName)",
`| where resourceName =~ '${resource}'`,
"| project id",
"| take 1",
].join(" ")
}
// A deployment's ID is its name, while limits, costs, and routes come from the catalog model it deploys. Azure compares
// names without case and may return another case, so IDs are lowercase like the catalog's.
// https://learn.microsoft.com/azure/azure-resource-manager/management/resource-name-rules
function deployedModels(deployments: readonly Deployment[], catalog: readonly Model.MutableInfo[]) {
const models = new Map(catalog.map((model) => [model.id.toLowerCase(), model]))
return new Map(
deployments.flatMap((deployment) => {
const model = catalogModel(models, deployment)
if (!model) return []
const id = Model.ID.make(deployment.name.toLowerCase())
const info: Model.MutableInfo = {
...structuredClone(model),
id,
modelID: Model.ID.make(deployment.name),
name: id === model.id.toLowerCase() ? model.name : `${model.name} (${deployment.name})`,
}
return [[id, info] as const]
}),
)
}
// Azure spells some models unlike the catalog: a model name plus a separate version, such as `gpt-4` for GPT-4 Turbo,
// `gpt-35-turbo`, or mixed case such as `DeepSeek-V4-Flash`. A deployment named after a catalog model then stands for
// that model, as it did before discovery; any other is left to explicit configuration.
// https://learn.microsoft.com/azure/foundry/openai/concepts/retired-models
function catalogModel<T>(models: ReadonlyMap<string, T>, deployment: Deployment) {
return models.get(deployment.model.toLowerCase()) ?? models.get(deployment.name.toLowerCase())
}
function responsesWebSocketCapable(provider: Provider.Info) {
if (provider.package !== "@opencode/ai/providers/azure/responses") return false
const settings = provider.settings
+1
View File
@@ -69,6 +69,7 @@ const builtins = new Map<string, () => Promise<unknown>>([
["@opencode/ai/providers/cloudflare-workers-ai", () => import("@opencode/ai/providers/cloudflare-workers-ai")],
["@opencode/ai/providers/deepinfra", () => import("@opencode/ai/providers/deepinfra")],
["@opencode/ai/providers/deepseek", () => import("@opencode/ai/providers/deepseek")],
["@opencode/ai/providers/digitalocean", () => import("@opencode/ai/providers/digitalocean")],
["@opencode/ai/providers/fireworks", () => import("@opencode/ai/providers/fireworks")],
["@opencode/ai/providers/google", () => import("@opencode/ai/providers/google")],
["@opencode/ai/providers/google-vertex", () => import("@opencode/ai/providers/google-vertex")],
+2 -1
View File
@@ -94,9 +94,10 @@ export const Plugin = {
targets: [FILENAME],
start: result.content.type === "list-page" ? resolved : dirname(resolved),
stop: root,
type: "file",
})
const candidates = (yield* Effect.forEach(discovered, fs.resolve)).filter(
(file) => !FSUtil.contains(dirname(file), root),
(file) => !FSUtil.contains(dirname(file), root) && file !== resolved,
)
if (candidates.length === 0) return
yield* sessionInstructions.load({ sessionID: context.sessionID, paths: candidates })
+1
View File
@@ -574,6 +574,7 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
"@opencode/ai/providers/cloudflare-workers-ai": workersAIChat,
"@opencode/ai/providers/deepinfra": deepinfraChat,
"@opencode/ai/providers/deepseek": deepseekChat,
"@opencode/ai/providers/digitalocean": openaiChat,
"@opencode/ai/providers/fireworks": openaiChat,
"@opencode/ai/providers/groq": openaiChat,
"@opencode/ai/providers/meta/chat": openaiChat,
@@ -431,7 +431,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
it.effect("canonicalizes boundaries and honors project opt-out", () =>
Effect.gen(function* () {
const observed: { values: { targets: string[]; start: string; stop?: string }[] } = { values: [] }
const observed: { values: FSUtil.UpOptions[] } = { values: [] }
const observingFS = Layer.effect(
FSUtil.Service,
FSUtil.Service.pipe(
@@ -487,7 +487,7 @@ describe("ConfigInstructionPlugin.Plugin", () => {
)
const repo = path.resolve("/repo")
expect(observed.values).toEqual([{ targets: ["AGENTS.md"], start: repo, stop: repo }])
expect(observed.values).toEqual([{ targets: ["AGENTS.md"], start: repo, stop: repo, type: "file" }])
}),
)
})
+9 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect } from "bun:test"
import { describe, expect, test } from "bun:test"
import { Cause, Deferred, Effect, Fiber, Layer } from "effect"
import { Agent } from "@opencode/core/agent"
import { Database } from "@opencode/core/database/database"
@@ -148,6 +148,14 @@ describe("Permission", () => {
}),
)
test("matches Windows rule resources against slash-normalized file access resources", () => {
const rules: Permission.Ruleset = [
{ action: "external_directory", resource: "C:\\Users\\x\\proj\\*", effect: "allow" },
]
expect(Permission.evaluate("external_directory", "C:/Users/x/proj/src/*", rules).effect).toBe("allow")
expect(Permission.evaluate("external_directory", "C:/Users/x/other/*", rules).effect).toBe("ask")
})
it.effect("allows managed output reads without granting external directory access", () =>
Effect.gen(function* () {
yield* setup([
File diff suppressed because it is too large. Load diff
@@ -89,10 +89,15 @@ const identity = {
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_nearby"),
}
const readCall = (sessionID: Session.ID, id: string, readPath: string): Parameters<Tool.Snapshot["execute"]>[0] => ({
const readCall = (
sessionID: Session.ID,
id: string,
readPath: string,
page: ReadToolFileSystem.PageInput = {},
): Parameters<Tool.Snapshot["execute"]>[0] => ({
sessionID,
...identity,
call: { type: "tool-call", id, name: "read", input: { path: readPath } },
call: { type: "tool-call", id, name: "read", input: { path: readPath, ...page } },
})
const writeAgents = (file: string, content: string) => Effect.promise(() => fs.writeFile(file, content))
@@ -131,7 +136,7 @@ describe("SessionInstructions", () => {
yield* writeAgents(rootPath, "root-instructions")
yield* writeAgents(subPath, "sub-instructions")
yield* writeAgents(deepPath, "deep-instructions")
yield* writeAgents(otherPath, "other-instructions")
yield* writeAgents(otherPath, "other-instructions\nmore rules")
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "deep", "file.txt"), "file content"))
yield* Effect.promise(() => fs.writeFile(path.resolve(dir, "sub", "other", "file2.txt"), "file content 2"))
@@ -155,13 +160,23 @@ describe("SessionInstructions", () => {
expect(firstInjected[0]!.metadata).toEqual({ instruction: { paths: [deepPath, subPath] } })
expect(firstInjected[0]!.text).not.toContain("root-instructions")
// Neither a full nor a partial read adds an automatic copy of the file itself.
const read = yield* executeTool(registry, readCall(sessionID, "call-direct", "sub/other/AGENTS.md"))
expect(read.content?.[0]).toMatchObject({ type: "text", text: expect.stringContaining("more rules") })
const partial = yield* executeTool(
registry,
readCall(sessionID, "call-partial", "sub/other/AGENTS.md", { limit: 1 }),
)
expect(partial.metadata).toEqual({ truncated: true })
expect(yield* synthetics(sessionID)).toHaveLength(1)
// A sibling read under sub/other discovers only the new AGENTS.md; sub is already
// injected for this session so it is not re-emitted, and the root is still excluded.
yield* executeTool(registry, readCall(sessionID, "call-other", "sub/other/file2.txt"))
const secondInjected = yield* synthetics(sessionID)
expect(secondInjected).toHaveLength(2)
expect(secondInjected[1]!.text).toBe(`Instructions from: ${otherPath}\nother-instructions`)
expect(secondInjected[1]!.text).toBe(`Instructions from: ${otherPath}\nother-instructions\nmore rules`)
expect(secondInjected[1]!.description).toBe(`Loaded ${path.relative(dir, otherPath)}`)
expect(secondInjected[1]!.metadata).toEqual({ instruction: { paths: [otherPath] } })
expect(secondInjected.some((message) => message.text.includes("root-instructions"))).toBe(false)
+11
View File
@@ -296,6 +296,17 @@ test("spells Workers AI thinking controls through the chat template", () => {
})
test("spells Chat Completions variants for hosting providers", () => {
expect(
resolve(model("@opencode/ai/providers/digitalocean", "openai-gpt-5-nano", undefined, "digitalocean"), [
{ type: "effort", values: ["minimal", "low", "medium", "high"] },
]),
).toEqual([
{ id: "minimal", settings: { reasoningEffort: "minimal" } },
{ id: "low", settings: { reasoningEffort: "low" } },
{ id: "medium", settings: { reasoningEffort: "medium" } },
{ id: "high", settings: { reasoningEffort: "high" } },
])
expect(
resolve(model("@opencode/ai/providers/openai-compatible", "deepseek-ai/deepseek-v4-pro", undefined, "nvidia"), [
{ type: "effort", values: ["none", "high", "max"] },
+39 -28
View File
@@ -2,7 +2,7 @@ import type { FileDiffInfo } from "@opencode/client/promise"
import type { SessionReviewLineComment } from "@opencode/session-ui/session-review"
import { previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
import { checksum } from "@opencode/util/encode"
import { createQuery, skipToken, useQueryClient } from "@tanstack/solid-query"
import { createQuery, useQueryClient } from "@tanstack/solid-query"
import { debounce } from "@solid-primitives/scheduled"
import { createComputed, createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
@@ -17,7 +17,6 @@ import {
} from "./kinds"
export type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
type FileSelection = { startLine: number; endLine: number; startChar: number; endChar: number }
export type Demand = { tree: number; files: number; panel: number; details: number }
@@ -109,12 +108,10 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
) {
list.push("branch")
}
// Turn snapshots are captured only for Git sessions.
if (project?.vcs === "git" && view.id) list.push("turn")
return list
})
const vcsMode = createMemo<VcsMode | undefined>(() => {
const value = mode()
return value === "git" || value === "branch" ? value : undefined
})
const vcsKey = createMemo(
() =>
[
@@ -130,22 +127,25 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
const demand = input.demand
return demand.tree + demand.files + demand.panel > 0
})
const vcsQuery = createQuery(() => {
const value = vcsMode()
const turnKey = () => [ctx.id, view.server.id, "session-turn", view.id] as const
const diffQuery = createQuery(() => {
const value = mode()
const turn = value === "turn"
return {
queryKey: [...vcsKey(), value] as const,
queryKey: turn ? turnKey() : ([...vcsKey(), value] as const),
enabled: view.server.connected && wantsReview() && !!view.project?.vcs,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
queryFn: value
? () =>
// A finished turn does not change on focus or filesystem events; refresh it when the session goes idle.
refetchOnWindowFocus: !turn,
queryFn: turn
? () => view.server.client.session.diff({ sessionID: view.id })
: () =>
view.server.client.vcs
.diff({
location: { directory: directory() },
mode: value === "git" ? "working" : value,
})
.then((result) => result.data)
: skipToken,
.then((result) => result.data),
}
})
// The summary's changes row: the session directory's working tree, loaded only while the summary shows.
@@ -180,20 +180,17 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
on(
() => !layout.narrow() && layout.side.opened(view),
(open, previous) => {
if (!open || previous || vcsQuery.isFetching) return
if (!open || previous || diffQuery.isFetching) return
if (input.demand.tree > 0) {
refresh()
return
}
if (vcsMode() && view.server.connected && view.project?.vcs) void vcsQuery.refetch()
if (view.server.connected && view.project?.vcs) void diffQuery.refetch()
},
{ defer: true },
),
)
const diffs = (): FileDiffInfo[] => {
if (mode() === "git" || mode() === "branch") return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
return []
}
const diffs = (): FileDiffInfo[] => (diffQuery.isFetched ? (diffQuery.data ?? []) : [])
const renderable = createMemo(() => diffs().filter(filterRenderableDiff))
const kinds = createMemo(() => reviewDiffKinds(renderable()))
const activeFile = () => {
@@ -205,17 +202,12 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
const count = () => diffs().length
const hasChanges = () => count() > 0
const ready = () => {
// A project without VCS never enables vcsQuery, so its status stays "pending" forever.
// A project without VCS never enables diffQuery, so its status stays "pending" forever.
const project = view.project
if (project && !project.vcs) return true
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
return true
return !diffQuery.isPending
}
const loadDiff = async (path: string, version?: number): Promise<FileDiffInfo | undefined> => {
const value = vcsMode()
if (!value) return undefined
const root = reviewRootDirectory(view.project?.worktree ?? directory())
const scoped = reviewDiffDirectory(root, path)
const source = diffs().find((diff) => diff.file === path)
const valid = (diff: FileDiffInfo | undefined): FileDiffInfo | undefined => {
if (!diff || !source) return undefined
@@ -223,6 +215,24 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
if (reviewDiffNeedsLoad(diff)) return undefined
return diff
}
const value = mode()
// Oversized full-file patches come back empty; bounded context usually fits.
if (value === "turn") {
return queryClient
.fetchQuery({
queryKey: [...turnKey(), "bounded", version] as const,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () => view.server.client.session.diff({ sessionID: view.id, context: 3 }),
})
.then((result) => valid(result.find((diff) => diff.file === path)))
.catch((error: unknown) => {
console.debug("[session-review] failed to load bounded turn diff", { path, error })
return undefined
})
}
const root = reviewRootDirectory(view.project?.worktree ?? directory())
const scoped = reviewDiffDirectory(root, path)
const request = (scope: string, context?: number) =>
queryClient
.fetchQuery({
@@ -376,6 +386,7 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
(next, previous) => {
if (next !== "idle" || previous === undefined || previous === "idle") return
refresh()
void queryClient.invalidateQueries({ queryKey: turnKey() })
},
{ defer: true },
),
@@ -416,7 +427,7 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
count,
deferRender: () => state.deferRender,
details: (): FileDiffInfo[] | undefined => (detailsQuery.isFetched ? (detailsQuery.data ?? []) : undefined),
diffVersion: () => vcsQuery.dataUpdatedAt,
diffVersion: () => diffQuery.dataUpdatedAt,
diffs,
renderable,
kinds,
+2 -2
View File
@@ -27,7 +27,7 @@ export function ReviewTitle(props: { review: ReviewModel }) {
export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }) {
const ctx = useExtension()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const loading = () => !props.review.ready()
const noGit = () => props.review.noGit()
const text = () => {
if (props.review.mode() === "git") return ctx.t("empty.git")
@@ -60,7 +60,7 @@ export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }
export function ReviewPanelEmpty(props: { review: ReviewModel }) {
const ctx = useExtension()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const loading = () => !props.review.ready()
const noGit = () => props.review.noGit()
return (
<Switch>
@@ -17,7 +17,7 @@ export type UpdateState =
export type UpdateSource = {
readonly remote: boolean
readonly subscribe: (notify: (notice: ClientNotice) => void, signal: AbortSignal) => Promise<void>
readonly subscribe: (notify: (notice: ClientNotice) => void) => () => void
readonly check: (
signal: AbortSignal,
onInstall: (version: string) => void,
@@ -115,15 +115,8 @@ export const { use: useUpdateNotification, provider: UpdateNotificationProvider
}
onMount(() => {
const updater = props.updater
if (!updater) return
const controller = new AbortController()
onCleanup(() => controller.abort())
void updater
.subscribe((notice) => notify({ ...notice, source: "client" }), controller.signal)
.catch((error) => {
if (!controller.signal.aborted) log.error("update check failed", { error })
})
if (!props.updater) return
onCleanup(props.updater.subscribe((notice) => notify({ ...notice, source: "client" })))
})
onCleanup(
+9 -1
View File
@@ -32,6 +32,8 @@ export namespace FSUtil {
readonly start: string
readonly stop?: string
readonly mode?: "all" | "first"
/** Only match regular files or directories (following symlinks). By default any existing path matches. */
readonly type?: "file" | "directory"
}
export interface Interface extends FileSystem.FileSystem {
@@ -165,7 +167,13 @@ export namespace FSUtil {
while (true) {
for (const target of options.targets) {
const search = join(current, target)
if (yield* fs.exists(search)) {
const found =
options.type === "file"
? yield* isFile(search)
: options.type === "directory"
? yield* isDir(search)
: yield* fs.exists(search)
if (found) {
result.push(search)
if (options.mode === "first") return result
}
@@ -3,7 +3,7 @@ title: "Commands"
description: "Reference for the opencode command line."
---
Every command accepts `--help` for its full flag list, for example `opencode run --help`. Commands that talk to a server also accept `--standalone` to run a private server and `--server <url>` to target a specific one.
Every command accepts `--help` for its full flag list, for example `opencode run --help`. Some server-backed commands accept `--standalone` to run a private server or `--server <url>` to target a specific one; check the command's help for its supported flags.
## run
@@ -167,7 +167,7 @@ $ opencode auth login anthropic
Log in with a specific authentication method.
```bash
$ opencode auth login anthropic --method api-key
$ opencode auth login anthropic --method key
```
Log out of a saved account.
@@ -462,13 +462,13 @@ $ opencode api GET /api/session
Call an operation ID with a query parameter.
```bash
$ opencode api v2.session.list --param limit=10
$ opencode api session.list --param limit=10
```
Send a JSON body.
```bash
$ opencode api v2.session.create --data '{"title": "New session"}'
$ opencode api session.create --data '{"title": "New session"}'
```
Add a request header.
@@ -540,7 +540,7 @@ $ opencode upgrade
Upgrade to a specific version with a specific package manager.
```bash
$ opencode upgrade 1.18.15 --method bun
$ opencode upgrade 2.0.21 --method bun
```
View all subcommands and flags.
@@ -353,7 +353,7 @@ Use permission actions to hide or deny tools without disconnecting their server.
## Context
For calls made on behalf of a session, OpenCode sends the session ID in `CallToolRequest.params._meta.sessionID`. This applies to direct tools and Code Mode over stdio and Streamable HTTP:
For calls made on behalf of a session, OpenCode sends the session ID in `CallToolRequest.params._meta["ai.opencode/sessionID"]`. This applies to direct tools and Code Mode over stdio and Streamable HTTP:
```json
{
@@ -361,7 +361,7 @@ For calls made on behalf of a session, OpenCode sends the session ID in `CallToo
"params": {
"name": "lookup",
"arguments": { "query": "example" },
"_meta": { "sessionID": "ses_..." }
"_meta": { "ai.opencode/sessionID": "ses_..." }
}
}
```
+31 -3
View File
@@ -268,7 +268,11 @@ choose **Microsoft Entra ID (Azure CLI)** when connecting Azure.
az login
```
For a resource in another tenant or subscription, select both explicitly.
Enter the resource name when connecting if it is not already supplied by configuration or the environment. A resource
name saved with a connection takes precedence over configuration; connect again to change it.
Azure CLI connections use the account selected in the Azure CLI. For a resource in another tenant or subscription,
select both explicitly.
```bash
az login --tenant TENANT_ID
@@ -278,8 +282,32 @@ az account set --subscription NAME_OR_ID
Instead of configuration, `AZURE_RESOURCE_NAME` supplies the resource name to the OpenCode server. The legacy
`AZURE_COGNITIVE_SERVICES_RESOURCE_NAME` variable also works.
OpenCode does not query Azure management APIs or discover deployments. If a deployment does not match its catalog
model name, map an OpenCode model ID to the deployment with `modelID`.
### Deployments
Azure serves a model only through a deployment, so OpenCode lists the deployments of your resource and shows those
instead of the whole Azure catalog. The list loads in the background after startup, after connecting, and after
switching accounts. To pick up a deployment added later, restart OpenCode or connect again.
- Until the list loads for a connection, or if it fails, the whole catalog stays available.
- A failed reload for the same connection keeps its last complete inventory.
- Switching accounts never shows the previous account's inventory; the new account starts from the whole catalog.
- Deployments inherit limits, costs, and capabilities from their catalog models.
Deployment names are their model IDs, in lowercase because Azure ignores case in names, for example
`azure/gpt-production`. A deployment named after its model, such as `gpt-5-mini`, keeps the catalog ID. These IDs stay
stable when other deployments are added or removed.
OpenCode matches a deployment to the catalog by the model it deploys, or by its name when Azure spells the model
differently, as with `gpt-4` for GPT-4 Turbo. A deployment named after another model, such as `gpt-5` deploying
`gpt-5-mini`, shows that model's name, limits, and costs.
With the Azure CLI, OpenCode lists deployments through the Azure management API, which needs read access to the
resource. If that fails, and always with an API key, it uses the resource's legacy deployment inventory. Discovery reads
every page before publishing the list. A custom `settings.baseURL` keeps the catalog.
Configure a deployment explicitly when its model is not in the catalog, such as a fine-tuned model, or to map a model ID
to a deployment yourself. OpenCode logs a warning naming such deployments. Explicitly configured models are always
kept.
```jsonc title="opencode.jsonc"
{
+2 -1
View File
@@ -12,7 +12,7 @@ The first search asks you to allow web search and select a provider. OpenCode re
## Providers
OpenCode includes four search providers:
OpenCode includes five search providers:
| Provider | ID | Environment variable |
| --- | --- | --- |
@@ -20,6 +20,7 @@ OpenCode includes four search providers:
| Firecrawl | `firecrawl` | `FIRECRAWL_API_KEY` |
| Parallel | `parallel` | `PARALLEL_API_KEY` |
| Tavily | `tavily` | `TAVILY_API_KEY` |
| TinyFish | `tinyfish` | `TINYFISH_API_KEY` (optional) |
Connect an account from the TUI with `/connect`, or set the provider's environment variable before starting OpenCode.