mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-02 13:36:22 +00:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fbdf3ee60 | ||
|
|
4827ef1f27 | ||
|
|
2dedc5e274 | ||
|
|
8a3185b66c | ||
|
|
37f3e239c6 | ||
|
|
2deb0e250f | ||
|
|
e41756cfc4 | ||
|
|
8b24fc8c2a | ||
|
|
0f9f0a9ae3 | ||
|
|
f9e61fd9a3 | ||
|
|
5256112bac | ||
|
|
afc5d1e21f | ||
|
|
80ecb3fc89 | ||
|
|
31a683f5ca | ||
|
|
5d707f1a2b | ||
|
|
e2948f5a51 | ||
|
|
fbe8a9f7b4 | ||
|
|
033b9ea560 | ||
|
|
7bb3fbf095 | ||
|
|
507e117466 | ||
|
|
1549712761 | ||
|
|
4f37be6265 | ||
|
|
7b04288099 |
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`, () =>
|
||||
|
||||
+54
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+53
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+54
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+53
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
@@ -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":
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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" },
|
||||
],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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")],
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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_..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -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"
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in new issue
Block a user