mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-29 20:17:40 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f83038253 | ||
|
|
4c33a253aa | ||
|
|
135995d4fc | ||
|
|
4d9090790b | ||
|
|
621a5b0706 | ||
|
|
16ef62c851 | ||
|
|
b694d2bf26 | ||
|
|
89d37a0f6d | ||
|
|
bc5ff1cfd9 | ||
|
|
c14a1f5acf | ||
|
|
7db941a892 | ||
|
|
83fafda63a | ||
|
|
a565ea8c74 | ||
|
|
3126808e0e | ||
|
|
948047a8ca | ||
|
|
0f931d3939 | ||
|
|
7ef4a1a56e | ||
|
|
b29dab3231 | ||
|
|
d76f968c60 | ||
|
|
b30c4d00d1 | ||
|
|
6712cc4c2c |
@@ -377,6 +377,7 @@
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jose": "6.0.11",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
|
||||
@@ -38,9 +38,15 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
|
||||
// whole policy pass for these — emitting hints would be harmless but pointless.
|
||||
const RESPECTS_INLINE_HINTS = new Set([
|
||||
"alibaba-messages",
|
||||
"anthropic-messages",
|
||||
"anthropic-compatible-messages",
|
||||
"cloudflare-ai-gateway-messages",
|
||||
"google-vertex-messages",
|
||||
"meta-messages",
|
||||
"minimax-messages",
|
||||
"moonshot-messages",
|
||||
"zai-coding-messages",
|
||||
"bedrock-converse",
|
||||
"openrouter",
|
||||
])
|
||||
|
||||
@@ -10,14 +10,19 @@ const WebSearch = Schema.Struct({
|
||||
name: Schema.Literal("web_search"),
|
||||
user_location: MetaResponses.WebSearch.fields.user_location,
|
||||
})
|
||||
const MetaCacheControl = Schema.Struct({
|
||||
type: Schema.tag("ephemeral"),
|
||||
ttl: Schema.optional(Schema.Literals(["5m", "1h"])),
|
||||
})
|
||||
const FunctionTool = Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
input_schema: JsonObject,
|
||||
cache_control: Schema.optional(MetaCacheControl),
|
||||
})
|
||||
const Body = Schema.Struct({
|
||||
...AnthropicMessages.AnthropicMessagesBody.fields,
|
||||
tools: optionalArray(
|
||||
Schema.Union([
|
||||
Schema.Struct({ name: Schema.String, description: Schema.String, input_schema: JsonObject }),
|
||||
WebSearch,
|
||||
]),
|
||||
),
|
||||
tools: optionalArray(Schema.Union([FunctionTool, WebSearch])),
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("MetaMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
|
||||
@@ -426,6 +426,7 @@ interface ActiveContent {
|
||||
readonly type: "text" | "reasoning"
|
||||
readonly id: string
|
||||
readonly thinking?: MistralThinkingContent
|
||||
readonly thinkingUnits?: MistralThinkingUnit[]
|
||||
}
|
||||
|
||||
export interface ParserState {
|
||||
@@ -502,8 +503,8 @@ const closeActive = (state: ParserState, events: LLMEvent[]) => {
|
||||
state.lifecycle,
|
||||
events,
|
||||
state.active.id,
|
||||
thinkingMetadata(state.active.thinking ?? { type: "thinking", thinking: [] }),
|
||||
thinkingText(state.active.thinking?.thinking ?? []),
|
||||
thinkingMetadata({ ...state.active.thinking, type: "thinking", thinking: state.active.thinkingUnits ?? [] }),
|
||||
thinkingText(state.active.thinkingUnits ?? []),
|
||||
)
|
||||
return { ...state, lifecycle, active: undefined }
|
||||
}
|
||||
@@ -524,20 +525,23 @@ const appendThinking = (state: ParserState, events: LLMEvent[], part: MistralOut
|
||||
const current = state.active?.type === "reasoning" ? state : closeActive(state, events)
|
||||
const units = thinkingUnits(part.thinking)
|
||||
const active = current.active ?? { type: "reasoning" as const, id: `reasoning-${current.nextContent}` }
|
||||
// Keep native units out of streamed events until the block is complete.
|
||||
const accumulated = active.thinkingUnits ?? []
|
||||
accumulated.push(...units)
|
||||
const thinking = {
|
||||
...active.thinking,
|
||||
...part,
|
||||
type: "thinking" as const,
|
||||
thinking: [...(active.thinking?.thinking ?? []), ...units],
|
||||
thinking: [],
|
||||
}
|
||||
const text = thinkingText(units)
|
||||
return {
|
||||
...current,
|
||||
lifecycle:
|
||||
text.length > 0
|
||||
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text, thinkingMetadata(thinking))
|
||||
: Lifecycle.reasoningStart(current.lifecycle, events, active.id, thinkingMetadata(thinking)),
|
||||
active: { ...active, thinking },
|
||||
? Lifecycle.reasoningDelta(current.lifecycle, events, active.id, text)
|
||||
: Lifecycle.reasoningStart(current.lifecycle, events, active.id),
|
||||
active: { ...active, thinking, thinkingUnits: accumulated },
|
||||
nextContent: current.active ? current.nextContent : current.nextContent + 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,17 @@ import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message } from "../src/index.js"
|
||||
import { Auth } from "../src/route.js"
|
||||
import { compileRequest } from "../src/route/client.js"
|
||||
import { AmazonBedrock, AnthropicCompatible, GoogleVertexMessages } from "../src/providers.js"
|
||||
import {
|
||||
Alibaba,
|
||||
AmazonBedrock,
|
||||
AnthropicCompatible,
|
||||
CloudflareAIGateway,
|
||||
GoogleVertexMessages,
|
||||
Meta,
|
||||
MiniMax,
|
||||
Moonshot,
|
||||
ZAICodingPlan,
|
||||
} from "../src/providers.js"
|
||||
import * as AnthropicMessages from "../src/protocols/anthropic-messages.js"
|
||||
import * as Gemini from "../src/protocols/gemini.js"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
@@ -127,6 +137,34 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const messagesModels = [
|
||||
["alibaba-messages", Alibaba.configure({ region: "ap-southeast-1", apiKey: "test" }).messages("qwen3.8-max")],
|
||||
[
|
||||
"cloudflare-ai-gateway-messages",
|
||||
CloudflareAIGateway.configure({ accountId: "test", gatewayId: "test", apiKey: "test" }).model(
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
),
|
||||
],
|
||||
["meta-messages", Meta.configure({ apiKey: "test" }).messages("muse-spark-1.3")],
|
||||
["minimax-messages", MiniMax.configure({ apiKey: "test" }).model("MiniMax-M3")],
|
||||
["moonshot-messages", Moonshot.configure({ apiKey: "test" }).messages("kimi-k3")],
|
||||
["zai-coding-messages", ZAICodingPlan.configure({ apiKey: "test" }).messages("glm-5.3")],
|
||||
] as const
|
||||
|
||||
messagesModels.forEach(([route, model]) =>
|
||||
it.effect(`'auto' emits cache markers on ${route}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(LLM.request({ model, system: "Sys", prompt: "hi" }))
|
||||
|
||||
expect(prepared.route).toBe(route)
|
||||
expect(prepared.body).toMatchObject({
|
||||
system: [{ type: "text", text: "Sys", cache_control: { type: "ephemeral" } }],
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
Vendored
+3
-3
@@ -10,7 +10,7 @@
|
||||
"usage"
|
||||
],
|
||||
"name": "alibaba-messages/qwen-3-7-plus-streams-thinking-disabled",
|
||||
"recordedAt": "2026-09-08T03:10:57.819Z"
|
||||
"recordedAt": "2026-09-29T05:26:24.450Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -21,14 +21,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.7-plus\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}]}],\"stream\":true,\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\"}}"
|
||||
"body": "{\"model\":\"qwen3.7-plus\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event:ping\ndata:{\"type\":\"ping\"}\n\nevent:message_start\ndata:{\"message\":{\"model\":\"qwen3.7-plus\",\"id\":\"msg_c4d58b4f-a61d-9d0c-a9e4-cb45d34b1120\",\"role\":\"assistant\",\"type\":\"message\",\"content\":[],\"usage\":{\"input_tokens\":20,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent:content_block_start\ndata:{\"type\":\"content_block_start\",\"content_block\":{\"type\":\"text\",\"text\":\"\"},\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"3\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"7887\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_stop\ndata:{\"type\":\"content_block_stop\",\"index\":0}\n\nevent:message_delta\ndata:{\"delta\":{\"stop_reason\":\"end_turn\"},\"type\":\"message_delta\",\"usage\":{\"output_tokens\":5,\"cache_creation_input_tokens\":0,\"input_tokens\":32,\"cache_read_input_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\nevent:message_stop\ndata:{\"type\":\"message_stop\"}\n\n"
|
||||
"body": "event:ping\ndata:{\"type\":\"ping\"}\n\nevent:message_start\ndata:{\"message\":{\"model\":\"qwen3.7-plus\",\"id\":\"msg_39067759-5f01-9b3a-b2c1-09111797f7b5\",\"role\":\"assistant\",\"type\":\"message\",\"content\":[],\"usage\":{\"input_tokens\":20,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent:content_block_start\ndata:{\"type\":\"content_block_start\",\"content_block\":{\"type\":\"text\",\"text\":\"\"},\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"3\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"788\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"7\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_stop\ndata:{\"type\":\"content_block_stop\",\"index\":0}\n\nevent:message_delta\ndata:{\"delta\":{\"stop_reason\":\"end_turn\"},\"type\":\"message_delta\",\"usage\":{\"cache_creation\":{\"ephemeral_5m_input_tokens\":0},\"output_tokens\":5,\"cache_creation_input_tokens\":0,\"input_tokens\":32,\"cache_read_input_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\nevent:message_stop\ndata:{\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
Vendored
+3
-3
@@ -9,7 +9,7 @@
|
||||
"structured-output"
|
||||
],
|
||||
"name": "alibaba-messages/qwen-3-8-max-follows-a-json-schema",
|
||||
"recordedAt": "2026-09-08T03:11:30.530Z"
|
||||
"recordedAt": "2026-09-29T05:26:41.466Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -20,14 +20,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return a JSON object with one key \\\"city\\\" set to the capital city of France.\"}]}],\"stream\":true,\"max_tokens\":1024,\"thinking\":{\"type\":\"disabled\"},\"output_config\":{\"format\":{\"type\":\"json_schema\",\"schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}"
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return a JSON object with one key \\\"city\\\" set to the capital city of France.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":1024,\"thinking\":{\"type\":\"disabled\"},\"output_config\":{\"format\":{\"type\":\"json_schema\",\"schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event:ping\ndata:{\"type\":\"ping\"}\n\nevent:message_start\ndata:{\"message\":{\"model\":\"qwen3.8-max\",\"id\":\"msg_16e067c1-984b-94d3-9abb-11792d794271\",\"role\":\"assistant\",\"type\":\"message\",\"content\":[],\"usage\":{\"input_tokens\":18,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent:content_block_start\ndata:{\"type\":\"content_block_start\",\"content_block\":{\"type\":\"text\",\"text\":\"\"},\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"{\\\"\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"city\\\":\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\" \\\"Paris\\\"}\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_stop\ndata:{\"type\":\"content_block_stop\",\"index\":0}\n\nevent:message_delta\ndata:{\"delta\":{\"stop_reason\":\"end_turn\"},\"type\":\"message_delta\",\"usage\":{\"output_tokens\":6,\"cache_creation_input_tokens\":0,\"input_tokens\":32,\"cache_read_input_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\nevent:message_stop\ndata:{\"type\":\"message_stop\"}\n\n"
|
||||
"body": "event:ping\ndata:{\"type\":\"ping\"}\n\nevent:message_start\ndata:{\"message\":{\"model\":\"qwen3.8-max\",\"id\":\"msg_61ee9b9d-a224-9b69-98f7-9f9ea3937b41\",\"role\":\"assistant\",\"type\":\"message\",\"content\":[],\"usage\":{\"input_tokens\":18,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent:content_block_start\ndata:{\"type\":\"content_block_start\",\"content_block\":{\"type\":\"text\",\"text\":\"\"},\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"{\\\"\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\"city\\\":\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"type\":\"text_delta\",\"text\":\" \\\"Paris\\\"}\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_stop\ndata:{\"type\":\"content_block_stop\",\"index\":0}\n\nevent:message_delta\ndata:{\"delta\":{\"stop_reason\":\"end_turn\"},\"type\":\"message_delta\",\"usage\":{\"cache_creation\":{\"ephemeral_5m_input_tokens\":0},\"output_tokens\":6,\"cache_creation_input_tokens\":0,\"input_tokens\":32,\"cache_read_input_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\nevent:message_stop\ndata:{\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Vendored
+3
-3
@@ -10,7 +10,7 @@
|
||||
"tool-choice"
|
||||
],
|
||||
"name": "alibaba-messages/qwen-3-8-max-obeys-named-tool-choice",
|
||||
"recordedAt": "2026-09-08T03:11:06.590Z"
|
||||
"recordedAt": "2026-09-29T05:26:40.212Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -21,14 +21,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Find the current weather in Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"]}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\"}}"
|
||||
"body": "{\"model\":\"qwen3.8-max\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Find the current weather in Paris.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"]},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event:ping\ndata:{\"type\":\"ping\"}\n\nevent:message_start\ndata:{\"message\":{\"model\":\"qwen3.8-max\",\"id\":\"msg_0e8f1abf-f2bc-9a86-a6a7-14804ac17eac\",\"role\":\"assistant\",\"type\":\"message\",\"content\":[],\"usage\":{\"input_tokens\":45,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent:content_block_start\ndata:{\"type\":\"content_block_start\",\"content_block\":{\"name\":\"get_weather\",\"input\":{},\"id\":\"toolu_cf9cab33261f4709ae096d8a\",\"type\":\"tool_use\"},\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"{\\\"city\\\": \\\"Paris\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"\\\"\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"}\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_stop\ndata:{\"type\":\"content_block_stop\",\"index\":0}\n\nevent:message_delta\ndata:{\"delta\":{\"stop_reason\":\"end_turn\"},\"type\":\"message_delta\",\"usage\":{\"output_tokens\":19,\"cache_creation_input_tokens\":0,\"input_tokens\":288,\"cache_read_input_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\nevent:message_stop\ndata:{\"type\":\"message_stop\"}\n\n"
|
||||
"body": "event:ping\ndata:{\"type\":\"ping\"}\n\nevent:message_start\ndata:{\"message\":{\"model\":\"qwen3.8-max\",\"id\":\"msg_dab3e1c5-e75e-978d-902e-1b0a82918cff\",\"role\":\"assistant\",\"type\":\"message\",\"content\":[],\"usage\":{\"input_tokens\":45,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent:content_block_start\ndata:{\"type\":\"content_block_start\",\"content_block\":{\"name\":\"get_weather\",\"input\":{},\"id\":\"toolu_9a1c3c06a04744cc97df33a2\",\"type\":\"tool_use\"},\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"{\\\"city\\\": \\\"Paris\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"\\\"\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_delta\ndata:{\"delta\":{\"partial_json\":\"}\",\"type\":\"input_json_delta\"},\"type\":\"content_block_delta\",\"index\":0}\n\nevent:content_block_stop\ndata:{\"type\":\"content_block_stop\",\"index\":0}\n\nevent:message_delta\ndata:{\"delta\":{\"stop_reason\":\"end_turn\"},\"type\":\"message_delta\",\"usage\":{\"cache_creation\":{\"ephemeral_5m_input_tokens\":0},\"output_tokens\":19,\"cache_creation_input_tokens\":0,\"input_tokens\":288,\"cache_read_input_tokens\":0,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\nevent:message_stop\ndata:{\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+7
-7
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+13
-6
@@ -2,9 +2,16 @@
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "muse-spark-1.3",
|
||||
"tags": ["prefix:meta-messages", "provider:meta", "protocol:meta-messages", "tool", "tool-loop", "reasoning"],
|
||||
"tags": [
|
||||
"prefix:meta-messages",
|
||||
"provider:meta",
|
||||
"protocol:meta-messages",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"reasoning"
|
||||
],
|
||||
"name": "meta-messages/replays-encrypted-thinking-through-a-tool-loop",
|
||||
"recordedAt": "2026-09-07T17:27:03.540Z"
|
||||
"recordedAt": "2026-09-29T05:29:52.700Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -15,14 +22,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using lookup_weather. After receiving the result, report Paris's weather in one short sentence.\"}]}],\"tools\":[{\"name\":\"lookup_weather\",\"description\":\"Look up current weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"auto\"},\"stream\":true,\"max_tokens\":1024,\"thinking\":{\"type\":\"adaptive\",\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
|
||||
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using lookup_weather. After receiving the result, report Paris's weather in one short sentence.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"lookup_weather\",\"description\":\"Look up current weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"auto\"},\"stream\":true,\"max_tokens\":1024,\"thinking\":{\"type\":\"adaptive\",\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6a9ef3e5a96d9a155dd4463b\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgGyJ4hIKk41uslnSV0PGvrTkwJ5D-t5skfjtkIt-ABXsehMcLKJJ8RJHRKW2-XhkpPrex0aOkIdqWl99vpCgtOHIFFaSc4b3oGxA8XDx4T_2aKANfYrR1DYwYzGe6ZZ-DQnU0bnpVUzCcXkghkLdyTcJr2p8cvVo1rymFpB0wZsbQRBhxCMR6PrY4i0aOTe8_waq_Po1a4l3YzKnzZIIvYP090o5kltv7MAwqChXjQeTJlKq7ECFa6HVeoTa43oiLmoD2Bzih0VfvBvQfbSh2uDypRM7-f65sp35-VuZfwsM33ZTvefzDbd8zd0D50I6wcybsj8gulDhWWpY7cxoN1Xasy-ImvisACGeppIN66maIFq5dMT2s8_CQ8a2EQ0hw9kwJaIiygZCdRofs-T1yaGWvnQxL6MpNh6SXux9T1ZfuexNrXOhz5cR3_s1euhq3-hI3GDCUDLAtkjiigSE3w6PExrwHTHnvkuAOTP0Lw7DH4NtKv-RFFeZaQOk_0Bi2EV43tR0Hq_chtEDiSpoezDjexPWfdZvhRIaO6PwyJaQ8jVOaLUr6bh6jQ3IM19lv6R7lY4Neno8fLxMwTbf7v7q02lfhOaZe3jR1fqOFUq8e4VXV76_rJBgzThqPNqO8OI_betgX5d2KMcwWAkaZjmrP3rxA7g5xp_Q7bkFiBaaTd3HbYIk_cKbtLOS6TIIphb4SX6ADA2qJ0zoo98P2NOceZODT2WrLjKCHl5dT6b9l2feH9m21pW576ULfKhVSMzuy0cmmWI7rv2P4q-2Fw2klsDVJAc6q22bFjfDgzhybKuhuM_p1SYb8aswNrgggV-cqHWDpF1FdVpL5fHMO74l1uD8Sj_9wuuD0asMQustuvsnYq2EdI0lLvONFMApWCU0s3QA8_P0Iyf0YoCZOm5QGnA7l3O5nAPkFF4kxLSsgRe9zuP6A4oKBDLN8EHwZ3pB4WZGWpUT12cJxplmT3_n8xKaMgaz13qs27uvUm3wMI1seyfpkMwUPHmt1ftpk9f_1OAg3fvQgIkWPTp6K9NzVzEry6SP7zbPfls8yn529Qrbki7ZM_oz3xEvDg367ZCe97eVnjiqRrYsYM2MmDsQq9lUwkgXb84kpK6a4pEzX9NDvhTqhw7RI6RL9E-2Ki4ciUfHv5LVTZ0rNweCo6SYXC11f8FCeObIW_Esr2mqYpORFS4SDMU-4I1HQfD9z6cPjL22APpv9a7Pp1phI8x_aQZkPU7LkyBWyVNDeusI7wq_LC0UgJ0vCAguKcAvN7gxwpCo5MEEZivRuYBldEMwRo4he2WQQke9KEokqREdlPF3Lvaav0fqPfXqhJyHAq8bYBHWdzMjDJ_dJwhdbLQ-iNLmrK5JNg2vPDglWSznBXbrSX4iyMFk1Ni2Y1SQUIOOxS_InIwCg\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"I'll look up the current weather in Paris.\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"id\":\"call_01a07ce8bb767c109c12f0a202e2ac19\",\"input\":{},\"name\":\"lookup_weather\",\"type\":\"tool_use\"},\"index\":2,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"partial_json\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"type\":\"input_json_delta\"},\"index\":2,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":2,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":113,\"input_tokens\":451,\"output_tokens\":155,\"output_tokens_details\":{\"thinking_tokens\":87}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6abb4ccec3772e65cd44417d\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgFT_IXY3RXUUduHqe1_WSgTF5_gMh29lCBV6vhfXW34eU6nJO7gUfRPczMK4nZfQhrqQewRujwjtNKR69DHHqTP35Q0jDnuZ4GZX9qBZT1OH9JhcMl1FxvccpatEcuH2Nju9Rf12KFHAOm9eKK6wFiaZgEHcT1wPbRYVZVZ8VxLeKk-ED3wkmWFhUFJRLSgJguWBMvEMbtIUlAKsJ6qkUxClfQKMm3eft1yf9ajseT32eloGzBE48IhyXu6tpeUDs9UatFgZOHKtHmvtKsPqRw-ddbimLnMtdkexwa8xR7EVTVuwBezunqhssfYAdR4c4t6VDpACh2UL3LcVUF9b0reiwpUfdpYrwjpB3bTCWyzGjwvIwGeuoR6VrWHz5nqVbt3bBprJXqJ4IRLKAXFFlMhClkz0MFNPBufcXSdh9i9NjbNp5c8lXCSkhMfhYX9B9KbQKl3DxUCjEY_VZk0nQJwt9zQVwTzWPzl9fTkBWrdebpXCg-z4lPmU5GLyGY3MlF24eDoSfJfqibF1HHqztluSHi4XEulrwMh4dpZmLhlLxlFxwnC8LjY_Rg8uOyzeoCzsMj-R9WrZ6n9jLZrxJPVo17nFjsuSwt6zMRS9Cna6adGFso0jIKF4BNTcZXlVt0b_TWA9AY4xpGSaAQXKYRBdAZNzTK5hj-bvyfWVTBepTBKm7pAtJW2e05mVBFDqRwEr3kcKkMTRMEj8vztSX4sNjV7Sw6bDbPjb_uFeoaNkq1ua9N7ZkKsyLn80ysWQ6IL-B_AVM1toAI0bVVYQmHbRSz1MgybcT5vjK2TWfCgxBuqw1ARU3oEM1iWTvpuHkFDNGeZnT4IWc_z_vLtQVnqSlqXPtnAmJ0WJgvQGSBhojSa9QMxRz_l4LZHmXUtfdMdUHTbrZwbfoL6eIyZf43ah-sXILJWTX9wXuYqnaclwvuO3EFiDUioSIBxWvAj2HNe5HpzayuvJqneRAukiVynt9SOo79VcBKAFsHW_KhUz58KjndfvOFP7byXv_W6Xa00ve_3GEaCANjFIl7ratBEuFYtpbVTT7TGIyGmuPQTVnGe7CAcFiAvGSwoaMwqvdGheKxeR7_cZvSYREeUnL5mXev8-MKY6_AtddOanQhf9pshxAXrIUtVFqin5MGhL2j787niURU9-o_VhLgzaIqfjbfMKkzdZ3-ESyDloOeD3CwoAsD_AVIrQZQHYbpYzFYasH0VrItar07ItxWZt_sCehTOi7Jtk5G8e7fd1HtCWmrp2iArioSIp8aLOphI9lazM7taIpfF_y0EMt3x5ENSvhtEmSk4kCJQnNyRv21uHT4uD0XjdQJbHeoRhzHDxoLHUOqAX9fGBzMFO8ZsWJxl8ebiCJeJ9Jzzr9bXeUKH5cP2VymoXPs3AWBrTpw1WfADM5sjHDJLzw\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"I'll look up the current weather in Paris.\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"id\":\"call_01a0eba40810714cbd0a8a84496badc7\",\"input\":{},\"name\":\"lookup_weather\",\"type\":\"tool_use\"},\"index\":2,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"partial_json\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"type\":\"input_json_delta\"},\"index\":2,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":2,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"input_tokens\":564,\"output_tokens\":97,\"output_tokens_details\":{\"thinking_tokens\":29}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -33,14 +40,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using lookup_weather. After receiving the result, report Paris's weather in one short sentence.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"redacted_thinking\",\"data\":\"Q-PaDgGyJ4hIKk41uslnSV0PGvrTkwJ5D-t5skfjtkIt-ABXsehMcLKJJ8RJHRKW2-XhkpPrex0aOkIdqWl99vpCgtOHIFFaSc4b3oGxA8XDx4T_2aKANfYrR1DYwYzGe6ZZ-DQnU0bnpVUzCcXkghkLdyTcJr2p8cvVo1rymFpB0wZsbQRBhxCMR6PrY4i0aOTe8_waq_Po1a4l3YzKnzZIIvYP090o5kltv7MAwqChXjQeTJlKq7ECFa6HVeoTa43oiLmoD2Bzih0VfvBvQfbSh2uDypRM7-f65sp35-VuZfwsM33ZTvefzDbd8zd0D50I6wcybsj8gulDhWWpY7cxoN1Xasy-ImvisACGeppIN66maIFq5dMT2s8_CQ8a2EQ0hw9kwJaIiygZCdRofs-T1yaGWvnQxL6MpNh6SXux9T1ZfuexNrXOhz5cR3_s1euhq3-hI3GDCUDLAtkjiigSE3w6PExrwHTHnvkuAOTP0Lw7DH4NtKv-RFFeZaQOk_0Bi2EV43tR0Hq_chtEDiSpoezDjexPWfdZvhRIaO6PwyJaQ8jVOaLUr6bh6jQ3IM19lv6R7lY4Neno8fLxMwTbf7v7q02lfhOaZe3jR1fqOFUq8e4VXV76_rJBgzThqPNqO8OI_betgX5d2KMcwWAkaZjmrP3rxA7g5xp_Q7bkFiBaaTd3HbYIk_cKbtLOS6TIIphb4SX6ADA2qJ0zoo98P2NOceZODT2WrLjKCHl5dT6b9l2feH9m21pW576ULfKhVSMzuy0cmmWI7rv2P4q-2Fw2klsDVJAc6q22bFjfDgzhybKuhuM_p1SYb8aswNrgggV-cqHWDpF1FdVpL5fHMO74l1uD8Sj_9wuuD0asMQustuvsnYq2EdI0lLvONFMApWCU0s3QA8_P0Iyf0YoCZOm5QGnA7l3O5nAPkFF4kxLSsgRe9zuP6A4oKBDLN8EHwZ3pB4WZGWpUT12cJxplmT3_n8xKaMgaz13qs27uvUm3wMI1seyfpkMwUPHmt1ftpk9f_1OAg3fvQgIkWPTp6K9NzVzEry6SP7zbPfls8yn529Qrbki7ZM_oz3xEvDg367ZCe97eVnjiqRrYsYM2MmDsQq9lUwkgXb84kpK6a4pEzX9NDvhTqhw7RI6RL9E-2Ki4ciUfHv5LVTZ0rNweCo6SYXC11f8FCeObIW_Esr2mqYpORFS4SDMU-4I1HQfD9z6cPjL22APpv9a7Pp1phI8x_aQZkPU7LkyBWyVNDeusI7wq_LC0UgJ0vCAguKcAvN7gxwpCo5MEEZivRuYBldEMwRo4he2WQQke9KEokqREdlPF3Lvaav0fqPfXqhJyHAq8bYBHWdzMjDJ_dJwhdbLQ-iNLmrK5JNg2vPDglWSznBXbrSX4iyMFk1Ni2Y1SQUIOOxS_InIwCg\"},{\"type\":\"text\",\"text\":\"I'll look up the current weather in Paris.\"},{\"type\":\"tool_use\",\"id\":\"call_01a07ce8bb767c109c12f0a202e2ac19\",\"name\":\"lookup_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_01a07ce8bb767c109c12f0a202e2ac19\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"lookup_weather\",\"description\":\"Look up current weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"none\"},\"stream\":true,\"max_tokens\":1024,\"thinking\":{\"type\":\"adaptive\",\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
|
||||
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using lookup_weather. After receiving the result, report Paris's weather in one short sentence.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"redacted_thinking\",\"data\":\"Q-PaDgFT_IXY3RXUUduHqe1_WSgTF5_gMh29lCBV6vhfXW34eU6nJO7gUfRPczMK4nZfQhrqQewRujwjtNKR69DHHqTP35Q0jDnuZ4GZX9qBZT1OH9JhcMl1FxvccpatEcuH2Nju9Rf12KFHAOm9eKK6wFiaZgEHcT1wPbRYVZVZ8VxLeKk-ED3wkmWFhUFJRLSgJguWBMvEMbtIUlAKsJ6qkUxClfQKMm3eft1yf9ajseT32eloGzBE48IhyXu6tpeUDs9UatFgZOHKtHmvtKsPqRw-ddbimLnMtdkexwa8xR7EVTVuwBezunqhssfYAdR4c4t6VDpACh2UL3LcVUF9b0reiwpUfdpYrwjpB3bTCWyzGjwvIwGeuoR6VrWHz5nqVbt3bBprJXqJ4IRLKAXFFlMhClkz0MFNPBufcXSdh9i9NjbNp5c8lXCSkhMfhYX9B9KbQKl3DxUCjEY_VZk0nQJwt9zQVwTzWPzl9fTkBWrdebpXCg-z4lPmU5GLyGY3MlF24eDoSfJfqibF1HHqztluSHi4XEulrwMh4dpZmLhlLxlFxwnC8LjY_Rg8uOyzeoCzsMj-R9WrZ6n9jLZrxJPVo17nFjsuSwt6zMRS9Cna6adGFso0jIKF4BNTcZXlVt0b_TWA9AY4xpGSaAQXKYRBdAZNzTK5hj-bvyfWVTBepTBKm7pAtJW2e05mVBFDqRwEr3kcKkMTRMEj8vztSX4sNjV7Sw6bDbPjb_uFeoaNkq1ua9N7ZkKsyLn80ysWQ6IL-B_AVM1toAI0bVVYQmHbRSz1MgybcT5vjK2TWfCgxBuqw1ARU3oEM1iWTvpuHkFDNGeZnT4IWc_z_vLtQVnqSlqXPtnAmJ0WJgvQGSBhojSa9QMxRz_l4LZHmXUtfdMdUHTbrZwbfoL6eIyZf43ah-sXILJWTX9wXuYqnaclwvuO3EFiDUioSIBxWvAj2HNe5HpzayuvJqneRAukiVynt9SOo79VcBKAFsHW_KhUz58KjndfvOFP7byXv_W6Xa00ve_3GEaCANjFIl7ratBEuFYtpbVTT7TGIyGmuPQTVnGe7CAcFiAvGSwoaMwqvdGheKxeR7_cZvSYREeUnL5mXev8-MKY6_AtddOanQhf9pshxAXrIUtVFqin5MGhL2j787niURU9-o_VhLgzaIqfjbfMKkzdZ3-ESyDloOeD3CwoAsD_AVIrQZQHYbpYzFYasH0VrItar07ItxWZt_sCehTOi7Jtk5G8e7fd1HtCWmrp2iArioSIp8aLOphI9lazM7taIpfF_y0EMt3x5ENSvhtEmSk4kCJQnNyRv21uHT4uD0XjdQJbHeoRhzHDxoLHUOqAX9fGBzMFO8ZsWJxl8ebiCJeJ9Jzzr9bXeUKH5cP2VymoXPs3AWBrTpw1WfADM5sjHDJLzw\"},{\"type\":\"text\",\"text\":\"I'll look up the current weather in Paris.\"},{\"type\":\"tool_use\",\"id\":\"call_01a0eba40810714cbd0a8a84496badc7\",\"name\":\"lookup_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_01a0eba40810714cbd0a8a84496badc7\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\"}\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"lookup_weather\",\"description\":\"Look up current weather\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"none\"},\"stream\":true,\"max_tokens\":1024,\"thinking\":{\"type\":\"adaptive\",\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6a9ef3e77f6ef36fdd8f4925\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgH-GUZkiqnb_seFaP70it_GxAEIUjt2BLllpyzfckiBOUVwr72xGQsSnj0y9aOhoaQ24A_HB0MHh4XWyYziVdRgq6lDPLHkJlmMzbusZxUZUx7D7B8kD8HrBbiTeIFbFlHaM9MiZW_PpYY9fpkdc7aeuX0mORdK8XTT3JPjvW1HPp4Qqp_iEOl9G2iM2Y6xno8SqeTcbc4EQs1LePaKrq86dBPmXBjkgQbYfvIw57o0SBEyelcudtnzJnaluOyOQdV2Ytk_r_xrYSCoBwgbf2KBCOFjdeQyruuwVZZ32JJdVOpWLw8eopUwAo2xYOZP0g8S8hTmOFvDKuzXipm1OoAXwD8Swm6ED3IIo0hHA5xSfMygDCee47nd-EpShNPamkCKodfX1QvePEJsIQK2iTgkh8IGUeEEne5dxgLuXvEAbeqGDvEy6T7IoSgZnc-KPtH6SoWM_kgc_eF_oN73Nxg2prMyCqTUNg5Qs2WLPjA9wSLmmnCoiDr1bYNIuQyn6adgv0-nZZXETJoRAHJBj65Asa8kbLyCYesb192178xCj2aBSdwyj-jc0i328sa9STS7mUSI7KvOt0Yi3kllLs1aSnHW-ogsUJUM7tTf83VO9fRRU_aW4H6qr4OAr8jbBKSD3bxE0AekDd24ZS-8YIjiP1tMOAPZ2JGQrNqbxnaeqhCZzD2nl-E8TMXaAJIk4L3oZtV80xJiUW7mLEf_jQBPAWhph4ujbkDaufGvNrl4FXyBJ-XpKMIA5z0jAYU2b3ul5-Qn5Km7Oc8fPu1M_0XGAOKG-pF9ppr4y-an4B4mKDYoAiSHp3cjv-fW57D87wBfQPaJRjOzWhzqENO-5MlB0CEsnDtrLveE2ui8wSECszParUNk5SEaeSYvrXLY0QkaE7CTK9ljwTKjJM34r1a8o16KFxGuzMrloNlxIYIaO_suM9CDuf8wNwe8KkAV2Kt28g9LwkL5b3mtI7ZSjTdHYOOhFFMzs-sjXboQoGAJGOhMnCQNc3E5-goSzvOzEIXehSTs8Mzyq8d3C2k5F9PSgdO6ZouHVURDgbEXTqWqoqjQ8huk5AbrUX16QwhJambd6P-3i--Idz9CbIVuWIqiMm80TvYlMttobYxTyFDIJrKJTrVA0PkAOFvOMfYt27d7z2ZMrTf6Vtm_DNniuhVbvVU3ZcWbb4LO-XkwH3bfHB-sckpwURP34KzOMvogaqWaMAZlabdd-lQKCc7tMhmo9BrYwHHvLxAELD2qQnyym43Yo1iJNAyrqoUAFk6K029fDon7h9ybmKZdBxRtS3arUesY_91Xep0tA0Y1UYUKo8zKjK3Op_DbVyC5OoR8rxpumKiqk2PtG7RPf-WjEXGkleGqZtxix7ILSkQXxW6McY1r_6LAh2Nv0nUzqS019tTb7mx2OLyt2g\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"It's sunny in Paris.\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"input_tokens\":210,\"output_tokens\":50,\"output_tokens_details\":{\"thinking_tokens\":35}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6abb4cd0b3f15768e1ae4287\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgF70ZwJMdvZQh6urOFUjgHa2IrdJbBv8RFXDpivwOudmj9QoCi81uRWeArjn_7V2nNk6i6jlHMjN7ACeZUE-faBnl4bTI10Fs0w0SSgcdkMaume2hpXBrZB85wlEttQviz-Pc5EHhAzU5QFIrK83CQkhJENqcfY3NJW1-55JFqlXmWSFV0GtNp3clG7V0LR1Zaq54hqIUY5iwf1pB8VIXLxPca3LwXMGbQPgjOgj_WNKHEBGl6mIafeca1OlGuDrJPN9tWBhlfxJCkcRU0zl14NA8chEyr96N40KrPewjVAlWO3jSejy9V7PnglilNewhUXOEQ75w2tYiZDajBfz6iG_n27zD3uvgD3QQthdyeUhJqlBaX81zMIe39Ctfo-cX-62cKwE3_EHy_7SxkwY5Olbf_bwL4GGzR39MObZoyv8-ZU1kwwVuSLXuDFvIx6iaAMZ50U4xXYbOphAaw2rylql0_kiPlFS9V3Fr_Jvx4vplj-8xh9JI7Uu9t9O7ltECpN9Qfu32fqxzWy9-ZVIh15gzhyegNeICBOcz6b9s3CIFHnzRcU69Ru9-lbydKv7scII_2xxLd-wlHQDCXHXyTeTeZjbTRiul0u5J2m8gGefF-j0lVChJOh8F4vBltzYTFTiQoSP8KtL61VJOt6dLsxOQA0Rq4PR5Lcs2fAfJbKb7jYB23bmXvQQ1d76keuvq_yoSIhrSyxLvXRCxouoWttkI8QC0u7O5pZUIPVfiYMI-j3bbc63BFOu4wAGbckm8Txo-gAY8UYAAwMBX17SD5EcTR82njKmd1R0Y8XOJLiwZRlN0ou-syq8xhahVgd0VcyB1HKr1f9mbhJdqFkKGpWh9-SR6jT6fKCCFxeMA3caczpG5wP_q5SGgyUiK0GWYpHabjSeJWac-04TDOfqL4QwLEKuL4PjlINe9OZY1n1lPJa2_MS9YGt6qlgLe3uZSEBwHk-ul5_yRetU8bd7lYcwDtUHT6puEkdjqeYwYQ8XWkUjoYwvSIMB_2v_owa5p9gPzf5Fr_zWqG7pizrStNNHq1MjXQX8Pl4Z6e2LZ2NjItfjR1GKdErNEJWXyh0oHCQKD1fIOXl64lpQorAHUcKV2HZm_SkwV7kxvzAfhdeT9Fe3AXZmtPBS1m0ZMMYNMYsNKb813JnWu8LvNg_zgXLBWsiB-hl4kCk1NKVAsCoA0i8JUFnGGm_uQp6t06a5NQYEXvi5A0akZTacle4UTBflNdsIVR6tSaO_m49nnr-XYSIh3tEzfjOsXpFz5L_A9SBDdPSGpcMJrs00nl1wDpF78OKC8NhwkIx8ydwF5A-ecXf91SbgqzVgv2T-wzuziA1wjJbPsGzTxP_wULUc2P5-ftFAMpsiRHB_-7hq6ZGo3HwNWjK2DTTN_89u7RCjuErEVPXUv-2fQ\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"It's sunny in Paris right now.\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"input_tokens\":152,\"output_tokens\":55,\"output_tokens_details\":{\"thinking_tokens\":38}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Vendored
+11
-4
@@ -2,9 +2,16 @@
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "muse-spark-1.3",
|
||||
"tags": ["prefix:meta-messages", "provider:meta", "protocol:meta-messages", "text", "reasoning", "adaptive"],
|
||||
"tags": [
|
||||
"prefix:meta-messages",
|
||||
"provider:meta",
|
||||
"protocol:meta-messages",
|
||||
"text",
|
||||
"reasoning",
|
||||
"adaptive"
|
||||
],
|
||||
"name": "meta-messages/streams-text-with-adaptive-thinking",
|
||||
"recordedAt": "2026-09-07T17:26:18.565Z"
|
||||
"recordedAt": "2026-09-29T05:29:46.263Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -15,14 +22,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
|
||||
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"adaptive\",\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6a9ef3b8e46a777cfdc94a93\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgFtwwNGqSqKxPk9Zj-j9xogYigpDsDHrLKPV-DxmXcbhtXDPrGPuj5qB4l1FslhuEQFCA8zIRyNGaCmzM0JG4GOZe3LpFYM2iegBbi24l4V7souryAwUs_7PrInxObuguMwh2S66ML6PwlfVRVTAwPsgDSCl6Y9tkoDHr2KBRY2fzJhmjkotuFRK2LYvNp9pKi89JxnoIKK8B_UJc_B2r4vU_Txq9_rWTmo2eXFoGOSMZNaHpVrvACRQKC1jc6-8Pj5JkJN11_Cs2fHqlaCwOoPDVJxHXUXP1AMHU1mG0jR4qf9wUaz8kMmyCBBijlfQJ4G7nsQs7LJO9gS2ropmynDoO5IEIAFf7ekxLw54Fx3JigC0_qz4Jx4a04Ey8JBzWRlZNdqggpq2_I_VoT3S0zhCYDp8ZAJQs2JqvvF-ZmLcMPH1PmpkgRySqXl5TDxERIpdL3aOUzTl-PTHVMIGEcSAz84fFwU4p1kS5llGEGC6jHlClscEEto1Y-qthL6BoeDALzk7GHEMVQD0bfC4E5yGCh0_xlWqsG3XPj0wpYKBc0hfh7vjO1NpzPxCtaDjnRwR6TzSo6x_HrEL3VELArHhpYAXl30fNO-EepduZ3Hd_kWqWj_GhdIM4dRh2zoytbhIesVD7OvBqU47urKFnpXw4kswJ-NoVVNk00ysMMHwobT9BdJL9UCoXlNzyErPe8LkA9f0VrKkuLCg5FDr_DCEYXHXr7fdRmC3JTKuzvxffvPYppUt3hs5gbdXWgT2iZzunt4bSfFne7BPkj9OGREyfLcukvGAh_oqPLXuu0lert9wbTAqRUTyM5Y1nSjw3h2B3jv91mH78mGvhy8LeVNLkrSMxEa4GtrrUvlzkPnZayc_LE2Sr7JnQjhwylVNWvkcIAjstOZShjPKe4pUJwj-cUXmXZB7Zwr-Q9WLtprFgQoBvadd5DHD4aynsYEabKZVcOqhV8l2zyaUCGobUzdPqBC5kk5-VPmNTqm6Qs91mb1y_5ydmHjTWmSW6qtN_op-Xfwqr_hllvOjhnAvvDJWUOKH-YIgZw_k_db2mzW1hO093jSDMYv6wpiy_5XWMNJBjcH8hvR_pFjXIqScUnKPranEZvCyGfHR-UBNJ89s_JEE49chcIt8LiukBeXQ_TOe29kzWnT1tYtfGQXP8FY2Wukw5yhvP3coGp1-LPZRuw3BLP2tWmiijmyj5lMikp8MIeqOztN8zgsArZ9jq21EtDSNfpO_0C4GEOtCZQYvZHRpDeY966XDnN7Blenn_8FZ95M-M-1My8hsmF__JrnY4ZFZr2274f_FbyJaSqGj6H-YRuzawO2TVLWHxAr6v2LCiB_QX01PwUI1yaJmP0mI-Qz5zXgSHzfVzhF7l1zEnM8qIYcSXzD1Gp3rjpn2sq-rR-JLqeHq080Hl0X9c8lzozw3qCpvcTfA-4ZTdXU9ax3nt7emcPVFo9N_JBjYashC7KvjkIWjMhQoTbIRlO18gjEa-Dvtsmo4_xE1ApYRoylbhxg3_fQJjKLfCBv32V6BM8qWKINL7d1nYP7PtAKnglCdnT_dK0_pCK6ejGfmdWZgVj0DhVZUCXf2kN530Y0hoCb1jBgwJ16a5gUqJMtnWScarIGSVrsYMA_uEt7DwQzGYAGESVIgi8JVATkxoq4EbyOLCHX7MIKmAPGV2WWTjWAWLnE2ITjlXtBkUAwjq2v957BMgIqxSNxEjG75Gd0i1XjZAujCSCXMrywZ72-VdQy4cD6kzH9mv04fZ7_dqn9eKPHBY7_T7V6myRDj_ImkffXcygmd7F-_-kHMExI6ABas1WtfDjkYgbRM0jMtIHYL-W9xT8vCSLSdvHcb5Y8XSXT5GTwI7eIlBHnCvFrgunbCo9bJJlT2Gc1RcgeZ4rg3yu1MNF4Mfk6DBwDhduGh5l7nypL00B0HokLfF4H7N1oSKL9jdVE3oMyJuA9NV1SJiyqcneSVHBzqjFZ5sszFXPlBKEz31izxFToAF4tDYMR507zYLcxyLA4AmHlkmn0qeozPb5qk-qZB6yLywtewv8O1KqxieAEJnEmyiqGnoKBdkjpeYmplErSxJyzoRX4fXb9mOdb-izJgvqSQ5uLm0_EXf3OklpMoAJ2CPJ6cxpg_5bhiwbgEtafCuDyXVL9Zzi872yUb_Z-ATlqU1M2Ai67f1piHIUDzMn4kulXQhPMb_R4eznMbX_aRigQh92-t1USoFtX2vmyCUQageFcrHHK524gPQ7rkOfDvZ8GHPXi3YZFnzGkSKjQD0jumFtzDiRqYkkMECep0zgY8MjuWLMjlMuigpkzSm8GPZ2aFeIMxJCYR8iMf7fH9NyHAJnicx4wdW_UqqgY5jfVtDvPSL0SRScyyK0jNdmofDHjWp4Tehih5OOw81E3BYLCiIv2p2zTMRSTZxFyG3HI91tIEaJu_RnIMFxzCilaP1LcbMrjyFzBtLJiXXJ--eFYyIMYKgfbWIOTsjLLJGC8NS2wuzaXB-C6GLsuSEHci5a8f5KGqaXK99LvHtY4k36WhEoVUjQEGY7HNJrtdjBUlqDgnT9iO06gGd2QCGvlP4us-Z3q2Bcyhf0LzOgn_Bpjc6YBk4TNF1AMxG15qmhnsrWcGElkpk3Vh9npC8--vkvhh7pOPfTZlNjQQh900OemY9gwxHyCBbt1096g4qkYOntGJmw-wVjyWxOt9K9CaM9WxY3wFyOmwqiFL1PK4CMeKMZk54iZ1ZiyXDOvsO7qUa9XQGDy3W8ic6R3DUzdkxEEBFYHAjDy3qE\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"37887\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"input_tokens\":23,\"output_tokens\":266,\"output_tokens_details\":{\"thinking_tokens\":254}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6abb4cc8199fc8fa6763470e\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgEdq_xGJ7Hl5i3AUq-IGolHfh7VsPu8914OD3BSVMiPCf1QVZ4O553OKpiejFGIkjBEfR2gGxcXlTsKuK78wfAiGAft2yQIHPeCJt4Csoxc3UUX405gUHqO9M06WeU118pTrlITW4g4dElALKmE2zi_pkQzEvU07jPqsF4n-3-9FkEnJ3D5Ag0UsU4LI1Mn5-EwPyPkitspoB-ytXsugMGQTMMfInzNq0OKDDMAO88aRQv24EslYBviairfM5c-k6dqOdGfsg_SntRiZJVNoo1yowGhQuiMKXreeQiQj_hrSVEXo8PVybmaCXtfW0I_2rI7JKR1uO-fxUHZckcCvLKMS3Wa2cwPUM9MPAnq05l3lbD1LpqZ_UPlf534p8_0BmF4hVGI2pamDWkL--7UaWrGFYtwBc3r9GVW0aIZC1ArnGjXsInKCnJuJ-elKu8H0Wp8-lxrsEfqtmxCamrsil8jOkIMezwSMUGtawhcOSdOnyQRje2b3OXiRx7Y-XZjrBlErbS13tdMJhXGlVqp9tazXlKdIt9CpMfMxjYW-Z5VkrJNymsBcjW1r2x5DC2VzGcqcha91rBkskrhKGWix95cdaEqUkP66DW9jThk4RS__E_oyt8DMTpvCxoZ5pe_mLkuS-ZpYKFeZbRegegIQErP_yuhvPWCwTYZymW4uUTVEmxdJ1otAST4ICF4ChsbHLLzJc4XHO7TA5GcwEc69m-poLTFrJvuYRErYhHHONOGWZGoyaPXApsmytUW6-lH2OiVpViVrLg_qvDw1NwyTgguQfP1d6JJYTmnjgN-Pa9170lqGzMI1fVjsC9zY5e6qe6NiyOwpxbFoV-jfdzyfeKX5LMVacpj2xV6dk6dYW7zcIZ3rJJvCpnwJROCjKbSRa4RvBFWNW2xjiWi1tYltzsz1YR1RHZJEN8szlfJjdMrfJdR5GbOiiDMcwGgGNOF5kbQqLgkggzR-B4lUNGLvSyBjAd_AdLpab0r9j1XdKBPWLFiXfAtX8lPSfeCSV41LFxKopB-MIVOpgtrwW6dsEayU7_w4Tc1nBTtYOGf2cGUV2XYzEpt5vxzCDC-og8WZ2lr3hOczp543-_I8n2ZOQEEk1k3suykPiQen_wgcpRccm18Aqsjy2t3uitZ-p1p4g0s6tG6Y_DCS4ku713v9Uw2ezHosl4Qk5JVk-KNvi9ZCYb9Jmc7PqHubP6Ua_UTyIeTiBB60zK5EgatIglUv3_NudfsplEH8YJkMvoAPJk-HW7c2WwzoymfA4TFoCg8vt3YHGAtuiyqF17-S3sfhl9J6oX-7FFpGn6bRqgwguC6sHUQw3bqWtyG4LjUy3_qzWfCI5zSm1hxjd6YRaxkdlv37Ol3Ua-65b3gRmQoUlA7omPbHuC8r2TNnG_ltsp9EMJlz7HN-Gyggw\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"37887\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"input_tokens\":23,\"output_tokens\":161,\"output_tokens_details\":{\"thinking_tokens\":149}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+11
-4
@@ -2,9 +2,16 @@
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "muse-spark-1.3",
|
||||
"tags": ["prefix:meta-messages", "provider:meta", "protocol:meta-messages", "text", "reasoning", "enabled"],
|
||||
"tags": [
|
||||
"prefix:meta-messages",
|
||||
"provider:meta",
|
||||
"protocol:meta-messages",
|
||||
"text",
|
||||
"reasoning",
|
||||
"enabled"
|
||||
],
|
||||
"name": "meta-messages/streams-text-with-enabled-thinking",
|
||||
"recordedAt": "2026-09-07T17:26:20.464Z"
|
||||
"recordedAt": "2026-09-29T05:29:50.023Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -15,14 +22,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":1024,\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
|
||||
"body": "{\"model\":\"muse-spark-1.3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":2048,\"thinking\":{\"type\":\"enabled\",\"budget_tokens\":1024,\"display\":\"omitted\"},\"output_config\":{\"effort\":\"low\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6a9ef3bbb7e691b3ea26427b\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgEpowvGua1C8z2RSICyyeSLruZ1JBUhrhLhgqf_XauiaESRHyHgIPVF0Cfxp7eLB_ESNObq7vJNDLdffE-RaN5nEauXhRxH6fJErC-4H_KELZFGF-qNnGu2DWl-gEojkwwkq1HtTtT3rOEk5isGjdribnDvR7a1zkpG3I0BxE_UyseISDSbkev5tyV7DkiH4xyv2ZmwuO3HHGgmAztEek9vm0ZTElZ8PIPvHG6P38aYGTGKrTjyOhmN3LBa_NFvtQQkUMKRB3n7L_N5KB1NbYkPzLEJ6L5dL9DJZBSsZms0O62QZ2zs2oWZ-ekQWL4oIuiRozWVpmwzjtkJl9WXtyvdL-4MACucXMSN9V7hWzC50g-DgdeFPSslsA9gy619wTNPPIxPymGWeD9LYKR3iwTRLNYzuycHakNqikhiwz9MweMod5GLTdbtypZJcV7ourz58jmK4YKsqleVPVvXM6bIoNuT2yGj23-tAYxQszGXQMHxSTnaLAHl6sUdiwTqX4wbFYfX-ogkIZsrca9mv5E2gkH1wJl19GPCxFaSDCHxh3QAW0RIExKUJly9Ydpoegmojfo0F4Hjt13zDm03fLEQ0eY8GjxIIHDt_zVRQGTW8d4tgZpTt4K9H1TTefG34EerN7jl7L-WNUe_gKorN4aSYBS6S6ma4Vb91NlUQ4cEGBKVQvumYe16p9Xj6Te-tCuiBmTOCq6iDsTH-eMlip_Bc18MD2BCGr2NdF4PB2vqHGSPIvSHDRXBjoPNtm18opAsUnOBu7-tysb8-EKFd5z7JbWgegQWNs11FdX0uFtC-U9ROwbHLJ3IHk4m2UHc2USwi7v4Xe6FtnkJ0VCZTajFUTXW33lwO3UsHuqEbxVnU_Rzuj6Ryjb08bpXpJBjIEGf2wBS3_I78cuXKOChHu-evtnjezc77joRUP8_I8pWsQbQ6bHABD95p8q6ZdQpDr9XQALbVg3ZvD5_QgF59rIxkW9a2L78oQpu0O3UT7-jxNFe38-OXuEr3VTv7RzISu1zjvBZD0k9-2q-T9BAJmsK6oPajJQWKwHBYF3w6vbsQ8tRDdh2uerhlAIWU0YL92gPCmT3mR6HxSGjAx08t-gib_YrzvWn0oTfLNk_BNfAMpG7Mj2GYNbyTkd_tjNZ2fOwVKbygsoFIUEvh2B-xyHOYuhXfxzI8-Gt55FMGDLLKcHfgmOZAUz1xVkIdm6bcuRFuQppgTzYIr66GN6tsr9KxTN2GZ2NHCwexe0hSAGfKVyLr8QBxTDRlzubBqe-osgQJrJtS7uM1MuWK_8grkZrNsn6g3ami0JboEmM_Ct3gVhkXo6lhCCcjn1gnGX-HOLczT3Xty1wYkeayUpi9x326nrSmypxzbzCRS3bOaPvSoBCszri0uTfUnVQK5kTVuRD30fGxauPhA\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"37887\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"input_tokens\":23,\"output_tokens\":143,\"output_tokens_details\":{\"thinking_tokens\":131}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
"body": "event: message_start\ndata: {\"message\":{\"content\":[],\"id\":\"msg_6abb4ccc0beea22bb95a41de\",\"model\":\"muse-spark-1.3\",\"role\":\"assistant\",\"stop_reason\":null,\"stop_sequence\":null,\"type\":\"message\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}},\"type\":\"message_start\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"data\":\"Q-PaDgGoJiKtl-javJ6dY04Ws79OK_mVBNQgu19iuCSru9wUy0jS0-87v1Lr1jC3I6cI8gnBvTl3HcRgR8MqmTOddFg5f_QSqhUweOVsCzaNL18Bjkye_cK3jGmlc9IsGLJ8dGFvFMPwflkMcofmwZ1P0EsMivXgwsDsWuiHP3PAP6_DfKgpVnNoz1oxNXgoVMTaNylNHtKHXpeC4_doHgWONMjhca771bIh_x0sRT-mQlONkr6HHgzzpCftEGOgnELEC7DlmJ46CDe_VSuaiUk8VLeNV4xyNm4XBHoyFuEn2N4tmG6mxo3cQlN2er8lY39XmY5gqy4ZIJddoY4sVFxBfL-axfDDv89hAhTYgXxH4dj4MyAj5TPWdyVZwunw2dRwf9JobQW-HCGcPxW6rqE7znOAegWSLD-mKRGZXNp7-kTY5Wicv9toV-VsBIFc1YpHFRpw36pxZftd2JnbW38dNYH0fO_QYnCknHxE9RA68uFpu_JvvpHOO0sFAuqwOCMynQyqFZRY9vVJBMYnuB_RIY_WCEuZwt7hV9es1aPk2QJyAWFh-LnNDzp9ktLaNwnnJxRpEK1r0PMbUsIQJMuxcNH7lCstE2dGBf02O4lH4HlZSHNsLcZuUjb01HCISf7cQXzIjG6rO_47m52h6bMpYZITElM66XVlKRE5V3wF_7sQ2BaT4g8GLxxTkgh-C0x8ISPVaU773cDvJSuOI-5FlUbviG9oQG_0PxU5ulQDCMSEM4wZENu6ZRlngTloFSxHJfeQvShiy9pC9kv-DuOT1stUT4HqpV0bV7WGrJjlX9l17JbMgjMBwzhNWTJwGiuw2Ya2cCveYgSzxMZtcI_YH9mv0n53c2h7_1Lq2-kVDx_R5UngrMLu9YxjIwIgK_UtbtnTXJ-q5X4DexgovoBPjzc2GOQeK0DoOnfOczVLMALAkQMnyxCQ65ZuawVuJFmIKY9ooznuqWjl5mLxVCn5Fyhk2uTKjFlbW1JO-RdvNF9CMRD2y2bY2Igy4w9Gh1R_xAhM7rwF0dt_p2g8CUjqsEyIn9bSGuRuzXfkxNz7KYfyCD2rkZc1gw8oU7jiLEpNl5gCkcj7bNql7lcDYgJtGSDRwZlIzZcb7MaxFHyFw7dXGtrOIvLL5eF8TOidjaRaY27xkCCBkQXWeIiX0qV5AuojjrpRAJsVVvdT5s2dHd1IYHU0nnsePU1Dd7gyB59upU9W1AaFrXjx0pn4MsNuYDcyLyzihq1lCeK23OSoXkwH9UI-zaLkV6MGUf_K0jMJPr6FrMgYF1x_SpXYOKSXPHhY5UE3pGLIL5_vFCRkAkMcvpw-H3tyljgnCZwt3g-Yuy4Uj05MS3PseQxuUor-0trgRBaBW4AdB925t2D41RJ4xp3HnEpjEkNFJ8Hsqbs3QxyVoTAvElXOrQ\",\"type\":\"redacted_thinking\"},\"index\":0,\"type\":\"content_block_start\"}\n\nevent: content_block_stop\ndata: {\"index\":0,\"type\":\"content_block_stop\"}\n\nevent: content_block_start\ndata: {\"content_block\":{\"text\":\"\",\"type\":\"text\"},\"index\":1,\"type\":\"content_block_start\"}\n\nevent: content_block_delta\ndata: {\"delta\":{\"text\":\"37887\",\"type\":\"text_delta\"},\"index\":1,\"type\":\"content_block_delta\"}\n\nevent: content_block_stop\ndata: {\"index\":1,\"type\":\"content_block_stop\"}\n\nevent: message_delta\ndata: {\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"type\":\"message_delta\",\"usage\":{\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"input_tokens\":23,\"output_tokens\":198,\"output_tokens_details\":{\"thinking_tokens\":186}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Vendored
+5
-5
File diff suppressed because one or more lines are too long
+3
-3
@@ -11,7 +11,7 @@
|
||||
"reasoning"
|
||||
],
|
||||
"name": "minimax-messages/m2-7-streams-default-thinking",
|
||||
"recordedAt": "2026-09-07T16:53:20.721Z"
|
||||
"recordedAt": "2026-09-29T05:27:50.902Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -22,14 +22,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M2.7\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}]}],\"stream\":true,\"max_tokens\":1536}"
|
||||
"body": "{\"model\":\"MiniMax-M2.7\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":1536}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"bd0c49bb4f5e6f26b77bab9b5a917484\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M2.7\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":57,\"output_tokens\":0}}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"The user\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" asks: \\\"What is 173 multiplied by 219? Reply with only the final integer.\\\" So we compute 173 * 219. Compute:\\n\\n173 * 219 = \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"173 * (200 + 19) = 173*200 + 173*19 = 34600 + (173*19). 173*19 = 173*20\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" - 173 = 3460 - 173 = 3287. So total = 34600 + 3287 = 37887.\\n\\nAlternatively compute directly: 219 * \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"173 = 219 * 173 = (219 * 100) + (219 * 70) + (219 * 3) = 21900 + 15330 + 657\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" = 37887.\\n\\nThus answer is 37887. We must reply with only the final integer: \\\"37887\\\". No extra text.\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"1625da2f905565076ee90b9c2919db39b1719731c0820eca2f2a1c9c28324e87\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"37887\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":57,\"output_tokens\":187,\"output_tokens_details\":{\"thinking_tokens\":184}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"419c5ecf093decc95250bc4cc3afce52\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M2.7\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_creation_input_tokens\":21,\"cache_read_input_tokens\":36}}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"The user\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" asks: \\\"What is 173 multiplied by 219? Reply with only the final integer.\\\"\\n\\nWe need to calculate 173 * 219 = ?\\n\\nLet's compute.\\n\\n173 *\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 219:\\n\\nOption 1: compute 173 * 200 = 34,600 (actually 173*200 = 34,600\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"? Wait 173 * 200 = 34,600? Let's check: 173 * 2 = 346, then times 100 = 34,600. Yes\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".)\\n\\n173 * 19 = 173 * 20 - 173 = 3,460 - 173 = 3\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\",287.\\n\\nAdd them: 34,600 + 3,287 = 37,887.\\n\\nThus product is 37,887.\\n\\nAlternatively, compute \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"219 * 173: 219*100 = 21,900; 219*70 = 15,330; 219*3 = 657\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"; sum = 21,900 + 15,330 = 37,230; +657 = 37,887. Same.\\n\\nThus answer: 378\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"87.\\n\\nThe user wants only the final integer. So reply \\\"37887\\\". No other text.\\n\\nThus final answer: 37887.\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"48b0f683a3fb6cfc2e060ff709c12a905c76d2e5bdddc709fb5b1cfc5aa600f7\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"37887\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":0,\"output_tokens\":259,\"cache_creation_input_tokens\":21,\"cache_read_input_tokens\":36,\"output_tokens_details\":{\"thinking_tokens\":256}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+5
-5
@@ -13,7 +13,7 @@
|
||||
"usage"
|
||||
],
|
||||
"name": "minimax-messages/m3-continues-a-tool-loop-with-adaptive-thinking",
|
||||
"recordedAt": "2026-09-07T16:53:25.185Z"
|
||||
"recordedAt": "2026-09-29T05:27:53.421Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -24,14 +24,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using get_weather before answering. After receiving the result, report the weather in one short sentence.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"auto\"},\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"adaptive\"}}"
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using get_weather before answering. After receiving the result, report the weather in one short sentence.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"auto\"},\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"adaptive\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"6a532037868639a6189e7c0b9e1c9aa2\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"The user wants me\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" to look up the\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" current weather in Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" using the get_\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"weather tool, then\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" report it\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" in one short sentence\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\". Let\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" me call the tool\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"f05cb5f4873950f23d194289c41d79b96ebd37c9411a45b306ed44f135a910d4\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_01a07cc9f2d47d83a6424ff3\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":239,\"output_tokens\":63,\"cache_read_input_tokens\":203,\"service_tier\":\"standard\",\"output_tokens_details\":{\"thinking_tokens\":33}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"6e8c4645c02dd05c2f08c22e5eba9a89\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"The user wants me\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" to look up the\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" current weather in Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" using the get_\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"weather tool, then\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" report the weather in\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" one short sentence after\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" receiving the result.\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Let\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" me call the tool\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"61f13458806572210eb8e1809b12f191297eea1a7f0104aa12f86595ef5eb1dc\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_01a0eba239b7718ba67e5aa3\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":239,\"output_tokens\":68,\"cache_read_input_tokens\":203,\"service_tier\":\"standard\",\"output_tokens_details\":{\"thinking_tokens\":38}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -42,14 +42,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using get_weather before answering. After receiving the result, report the weather in one short sentence.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"The user wants me to look up the current weather in Paris using the get_weather tool, then report it in one short sentence. Let me call the tool.\",\"signature\":\"f05cb5f4873950f23d194289c41d79b96ebd37c9411a45b306ed44f135a910d4\"},{\"type\":\"tool_use\",\"id\":\"call_01a07cc9f2d47d83a6424ff3\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_01a07cc9f2d47d83a6424ff3\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"none\"},\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"adaptive\"}}"
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using get_weather before answering. After receiving the result, report the weather in one short sentence.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"The user wants me to look up the current weather in Paris using the get_weather tool, then report the weather in one short sentence after receiving the result. Let me call the tool.\",\"signature\":\"61f13458806572210eb8e1809b12f191297eea1a7f0104aa12f86595ef5eb1dc\"},{\"type\":\"tool_use\",\"id\":\"call_01a0eba239b7718ba67e5aa3\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_01a0eba239b7718ba67e5aa3\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"none\"},\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"adaptive\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"e9e7ea89f63eca1cd17a037aef28eed1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"The weather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" in Paris is sunny\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" with\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" a\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" temperature of 18\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"°C.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":164,\"output_tokens\":16,\"cache_read_input_tokens\":128,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"0f6a842fc54eecab0d6df9ffebd8dd3b\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"The weather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" in Paris is sunny\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" with a\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" temperature of 18\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"°C. I'll\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" report this in one\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"short sentence.\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"fefe2c0e5f3f0a0edc24b21d37c8b7fb12ed0755bd1898f71acc2325f1b35958\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"It's\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" currently\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" sunny in\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" Paris with a temperature\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\" of 18°C\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\".\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":169,\"output_tokens\":38,\"cache_read_input_tokens\":128,\"service_tier\":\"standard\",\"output_tokens_details\":{\"thinking_tokens\":22}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+3
-3
@@ -11,7 +11,7 @@
|
||||
"usage"
|
||||
],
|
||||
"name": "minimax-messages/m3-generates-a-named-tool-call-with-default-thinking-off",
|
||||
"recordedAt": "2026-09-07T16:53:24.366Z"
|
||||
"recordedAt": "2026-09-29T05:27:51.660Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -22,14 +22,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use get_weather to look up the current weather in Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":512}"
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use get_weather to look up the current weather in Paris.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":512}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"a60a8adb2eff7545f809b9df2689f1b8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I'll\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" look\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" up the current weather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" in Paris for you\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\".\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_b0246853f3c4432ea455cc99\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":401,\"output_tokens\":39,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"5f4a7ce22c5a82497fedc65028aa4825\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I'll\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" look\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" up the current weather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" in Paris for you\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\".\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_01a0eba235fb7281a0f2ea2c\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":287,\"output_tokens\":39,\"cache_read_input_tokens\":128,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+3
-3
File diff suppressed because one or more lines are too long
Vendored
+3
-3
@@ -11,7 +11,7 @@
|
||||
"thinking-off"
|
||||
],
|
||||
"name": "minimax-messages/m3-streams-text-with-thinking-disabled",
|
||||
"recordedAt": "2026-09-07T16:53:16.028Z"
|
||||
"recordedAt": "2026-09-29T05:27:36.888Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -22,14 +22,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}]}],\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"disabled\"}}"
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"disabled\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"01664c047875a0f573c966467c8cccc1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"378\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"87\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":51,\"output_tokens\":3,\"cache_read_input_tokens\":128,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"444066751666f2071c999bfd0b4c16b0\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"378\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"87\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":179,\"output_tokens\":3,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+11
-4
File diff suppressed because one or more lines are too long
+11
-4
File diff suppressed because one or more lines are too long
Vendored
+11
-4
@@ -2,9 +2,16 @@
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "kimi-k3",
|
||||
"tags": ["prefix:moonshot-messages", "provider:moonshot", "protocol:messages", "tool", "tool-choice", "usage"],
|
||||
"tags": [
|
||||
"prefix:moonshot-messages",
|
||||
"provider:moonshot",
|
||||
"protocol:messages",
|
||||
"tool",
|
||||
"tool-choice",
|
||||
"usage"
|
||||
],
|
||||
"name": "moonshot-messages/k3-respects-tool-choice-required",
|
||||
"recordedAt": "2026-09-07T20:37:07.770Z"
|
||||
"recordedAt": "2026-09-29T05:28:21.798Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -15,14 +22,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"kimi-k3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use get_weather to look up the current weather in Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"any\"},\"stream\":true,\"max_tokens\":4096,\"output_config\":{\"effort\":\"low\"}}"
|
||||
"body": "{\"model\":\"kimi-k3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use get_weather to look up the current weather in Paris.\",\"cache_control\":{\"type\":\"ephemeral\"}}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"cache_control\":{\"type\":\"ephemeral\"}}],\"tool_choice\":{\"type\":\"any\"},\"stream\":true,\"max_tokens\":4096,\"output_config\":{\"effort\":\"low\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"chatcmpl-2f2e02780e8a031b1b1307171\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"kimi-k3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":223,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\",\"prompt_tokens\":223,\"cached_tokens\":0}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Need\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" use\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" get\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"_we\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"ather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" for\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Call\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" function\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Then\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" final\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" summarize\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" result\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Use\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" tool\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"get_weather_0\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"city\\\": \\\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":223,\"output_tokens\":69,\"output_tokens_details\":{\"thinking_tokens\":19},\"prompt_tokens\":223,\"completion_tokens\":69,\"total_tokens\":292,\"cached_tokens\":223}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"chatcmpl-c877bb3f-cb6b-9d0f-b91e-37849d7bff39\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"kimi-k3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":223,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"service_tier\":\"standard\",\"inference_geo\":\"not_available\",\"prompt_tokens\":223,\"cached_tokens\":0}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"The\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" user explicitly\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" asks to use\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" get_weather for\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Paris. Need call\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" the\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" tool. Do\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" that.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"get_weather_0_7e180ea4\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"city\\\":\\\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":223,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":73,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens_details\":{\"thinking_tokens\":20},\"prompt_tokens\":223,\"completion_tokens\":73,\"total_tokens\":296,\"cached_tokens\":0}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
Vendored
+10
-4
File diff suppressed because one or more lines are too long
Vendored
+11
-4
File diff suppressed because one or more lines are too long
Vendored
+11
-4
File diff suppressed because one or more lines are too long
Vendored
+11
-4
File diff suppressed because one or more lines are too long
Vendored
+11
-4
File diff suppressed because one or more lines are too long
+7
-7
File diff suppressed because one or more lines are too long
@@ -33,7 +33,12 @@ it.effect("Meta selects Messages and lowers native search alongside ordinary fun
|
||||
output_config: { effort: "low" },
|
||||
tools: [
|
||||
{ type: "web_search", name: "web_search", user_location: { type: "approximate", country: "US" } },
|
||||
{ name: "lookup", description: "Lookup", input_schema: { type: "object" } },
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Lookup",
|
||||
input_schema: { type: "object" },
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
})
|
||||
const entrypoint = yield* Effect.promise(() => import("@opencode/ai/providers/meta/messages"))
|
||||
|
||||
@@ -303,7 +303,8 @@ describe("Mistral Chat", () => {
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
chunk({ content: [{ type: "thinking", thinking: [], marker: "empty" }] }),
|
||||
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Consider" }] }] }),
|
||||
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "Con" }] }] }),
|
||||
chunk({ content: [{ type: "thinking", thinking: [{ type: "text", text: "sider" }], closed: true }] }),
|
||||
chunk({ content: [{ type: "text", text: "Answer" }] }),
|
||||
chunk({}, "stop"),
|
||||
),
|
||||
@@ -313,6 +314,11 @@ describe("Mistral Chat", () => {
|
||||
|
||||
expect(response.reasoning).toBe("Consider")
|
||||
expect(response.text).toBe("Answer")
|
||||
expect(response.events.find(LLMEvent.is.reasoningStart)?.providerMetadata).toBeUndefined()
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta).map((event) => [event.text, event.providerMetadata])).toEqual([
|
||||
["Con", undefined],
|
||||
["sider", undefined],
|
||||
])
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
@@ -321,8 +327,12 @@ describe("Mistral Chat", () => {
|
||||
mistral: {
|
||||
thinking: {
|
||||
type: "thinking",
|
||||
thinking: [{ type: "text", text: "Consider" }],
|
||||
thinking: [
|
||||
{ type: "text", text: "Con" },
|
||||
{ type: "text", text: "sider" },
|
||||
],
|
||||
marker: "empty",
|
||||
closed: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -337,8 +347,12 @@ describe("Mistral Chat", () => {
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: [{ type: "text", text: "Consider" }],
|
||||
thinking: [
|
||||
{ type: "text", text: "Con" },
|
||||
{ type: "text", text: "sider" },
|
||||
],
|
||||
marker: "empty",
|
||||
closed: true,
|
||||
},
|
||||
{ type: "text", text: "Answer" },
|
||||
],
|
||||
@@ -357,6 +371,8 @@ describe("Mistral Chat", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.events.find(LLMEvent.is.reasoningStart)?.providerMetadata).toBeUndefined()
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toEqual([])
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
|
||||
@@ -4996,6 +4996,42 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains the token-sharing HTTP 429 body for retry hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = JSON.stringify({ error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" } })
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(body, { status: 429, headers: { "content-type": "application/json" } }),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "RateLimit", http: { status: 429 }, body } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains the token-sharing response.failed event for retry hooks", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
type: "response.failed",
|
||||
response: {
|
||||
id: "resp_usage_limit",
|
||||
error: { code: "subscription_sharing_usage_limit_exceeded", message: "Rate limit exceeded" },
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error).toMatchObject({ reason: { _tag: "RateLimit" } })
|
||||
expect(error.reason.body).toContain("subscription_sharing_usage_limit_exceeded")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("surfaces response.failed code when no nested message is present", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogChatGPTUsageLimit } from "./chatgpt-usage-limit"
|
||||
|
||||
function UsageLimitStory() {
|
||||
const dialog = useDialog()
|
||||
const open = () => dialog.show(() => <DialogChatGPTUsageLimit />)
|
||||
onMount(open)
|
||||
|
||||
return (
|
||||
<Button variant="neutral" onClick={open}>
|
||||
Open ChatGPT usage limit
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/Dialogs/Connect Provider",
|
||||
id: "app-dialog-chatgpt-usage-limit",
|
||||
}
|
||||
|
||||
export const ChatGPTUsageLimit = { render: () => <UsageLimitStory /> }
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Dialog, DialogTitle } from "@opencode/ui/dialog"
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
|
||||
export function DialogChatGPTUsageLimit() {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
|
||||
return (
|
||||
<Dialog fit containerClass="!w-[min(calc(100vw_-_32px),390px)] !rounded-xl">
|
||||
<div class="flex w-full flex-col items-center px-8 pb-8 pt-9 text-center [font-family:var(--v2-font-family-sans)]">
|
||||
<ProviderIcon id="openai" class="!size-12 text-v2-icon-icon-base" aria-hidden="true" />
|
||||
<div class="mt-6 max-w-[270px] text-[20px] font-[530] leading-7 tracking-[-0.3px] text-v2-text-text-base">
|
||||
<DialogTitle>
|
||||
<bdi dir="auto">{language.t("provider.connect.chatgptUsageLimit.title")}</bdi>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<p class="mt-3 text-[13px] leading-5 text-v2-text-text-muted">
|
||||
<bdi dir="auto">{language.t("provider.connect.chatgptUsageLimit.description")}</bdi>
|
||||
</p>
|
||||
<Button
|
||||
variant="contrast"
|
||||
size="large"
|
||||
class="mt-8 w-full"
|
||||
autofocus
|
||||
onClick={() => {
|
||||
platform.openExternal("https://chatgpt.com/#settings/Usage")
|
||||
dialog.close()
|
||||
}}
|
||||
>
|
||||
{language.t("provider.connect.chatgptUsageLimit.manage")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
class="mt-2 w-full"
|
||||
onClick={() => dialog.close()}
|
||||
>
|
||||
{language.t("provider.connect.chatgptUsageLimit.close")}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { onMount } from "solid-js"
|
||||
import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome"
|
||||
|
||||
function WelcomeStory() {
|
||||
const dialog = useDialog()
|
||||
const open = () => dialog.show(() => <DialogChatGPTPlanWelcome />)
|
||||
onMount(open)
|
||||
|
||||
return (
|
||||
<Button variant="neutral" onClick={open}>
|
||||
Open ChatGPT plan welcome
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/Dialogs/Connect Provider",
|
||||
id: "app-dialog-chatgpt-welcome",
|
||||
}
|
||||
|
||||
export const ChatGPTPlanWelcome = { render: () => <WelcomeStory /> }
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Dialog, DialogTitle } from "@opencode/ui/dialog"
|
||||
import { ProviderIcon } from "@opencode/ui/provider-icon"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
|
||||
export function DialogChatGPTPlanWelcome() {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<Dialog fit containerClass="!w-[min(calc(100vw_-_32px),390px)] !rounded-xl">
|
||||
<div class="flex w-full flex-col items-center px-8 pb-8 pt-9 text-center [font-family:var(--v2-font-family-sans)]">
|
||||
<ProviderIcon id="openai" class="!size-12 text-v2-icon-icon-base" aria-hidden="true" />
|
||||
<div class="mt-6 max-w-[270px] text-[20px] font-[530] leading-7 tracking-[-0.3px] text-v2-text-text-base">
|
||||
<DialogTitle>
|
||||
<bdi dir="auto">{language.t("provider.connect.chatgptWelcome.title")}</bdi>
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<p class="mt-3 text-[13px] leading-5 text-v2-text-text-muted">
|
||||
<bdi dir="auto">{language.t("provider.connect.chatgptWelcome.description")}</bdi>
|
||||
</p>
|
||||
<ExternalLink
|
||||
href="https://chatgpt.com/#settings/Usage"
|
||||
dir="auto"
|
||||
class="mt-1 rounded-sm text-[13px] leading-5 text-v2-text-text-muted underline-offset-2 hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
|
||||
>
|
||||
{language.t("provider.connect.chatgptWelcome.usage")}
|
||||
</ExternalLink>
|
||||
<Button variant="contrast" size="large" class="mt-8 w-full" autofocus onClick={() => dialog.close()}>
|
||||
{language.t("provider.connect.chatgptWelcome.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -39,6 +39,9 @@ import { OpenCodeLogo } from "@/providers/opencode-logo"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { Schema } from "effect"
|
||||
import {
|
||||
CONSOLE_INTEGRATION,
|
||||
CONSOLE_PROVIDERS,
|
||||
@@ -48,6 +51,7 @@ import {
|
||||
type ProviderConnectMethod,
|
||||
} from "./controller"
|
||||
import { ConsoleAuthorization } from "./console"
|
||||
import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome"
|
||||
import { authServerName, RemoteAuthNotice } from "./remote"
|
||||
import "./models.css"
|
||||
|
||||
@@ -81,8 +85,15 @@ export const DialogConnectProvider: Component<{
|
||||
completed: false,
|
||||
modelProvider: undefined as { id: string; name: string } | undefined,
|
||||
authorization: false,
|
||||
chatgptWelcome: false,
|
||||
})
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const [welcome, setWelcome, , welcomeReady] = persisted(
|
||||
Persist.global("chatgpt-plan-welcome.v1"),
|
||||
Persistence.struct({ seen: Schema.Boolean }),
|
||||
{ seen: false },
|
||||
)
|
||||
const reset = controller.reset
|
||||
const back = { current: reset }
|
||||
const consoleSelected = () => CONSOLE_PROVIDERS.has(controller.selected() ?? "")
|
||||
@@ -112,7 +123,11 @@ export const DialogConnectProvider: Component<{
|
||||
setBack={(handler) => (back.current = handler)}
|
||||
selection={props.selection}
|
||||
onDone={props.onDone ? () => setState("completed", true) : undefined}
|
||||
onConnected={() => props.onConnected?.(provider)}
|
||||
onConnected={(methodID) => {
|
||||
props.onConnected?.(provider)
|
||||
if (provider === "openai" && methodID === "chatgpt-token-sharing")
|
||||
setState("chatgptWelcome", true)
|
||||
}}
|
||||
onFirstConnection={(provider) => setState("modelProvider", provider)}
|
||||
onAuthorization={(authorization) => setState("authorization", authorization)}
|
||||
/>
|
||||
@@ -136,9 +151,16 @@ export const DialogConnectProvider: Component<{
|
||||
: "!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]"
|
||||
}
|
||||
onCloseAutoFocus={(event) => {
|
||||
if (!state.completed || !props.onDone) return
|
||||
event.preventDefault()
|
||||
props.onDone()
|
||||
if (state.completed && props.onDone) {
|
||||
event.preventDefault()
|
||||
props.onDone()
|
||||
}
|
||||
if (!state.chatgptWelcome) return
|
||||
void Promise.resolve(welcomeReady.promise).then(() => {
|
||||
if (welcome.seen) return
|
||||
setWelcome("seen", true)
|
||||
void dialog.show(() => <DialogChatGPTPlanWelcome />)
|
||||
})
|
||||
}}
|
||||
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
||||
classList={{
|
||||
@@ -354,7 +376,7 @@ function ProviderConnection(props: {
|
||||
setBack: (handler: () => void) => void
|
||||
selection?: ModelSelection
|
||||
onDone?: () => void
|
||||
onConnected?: () => void
|
||||
onConnected?: (methodID?: string) => void
|
||||
onFirstConnection: (provider: { id: string; name: string }) => void
|
||||
onAuthorization: (authorization: boolean) => void
|
||||
}) {
|
||||
@@ -401,7 +423,8 @@ function ProviderConnection(props: {
|
||||
prepare: isConsole ? prepareConsoleCatalog : undefined,
|
||||
pollInterval: isConsole ? 500 : undefined,
|
||||
onComplete: () => {
|
||||
props.onConnected?.()
|
||||
const method = controller.currentMethod()
|
||||
props.onConnected?.(method?.type === "oauth" ? method.id : undefined)
|
||||
// The picker only lists the newest model per family by default, which hides most of
|
||||
// what a new connection just unlocked. Show everything the connected integration offers.
|
||||
global.models.show(
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { createSignal } from "solid-js"
|
||||
import type { ModelSelection } from "./selection"
|
||||
import { ModelSelectorPopoverView } from "./select-dialog"
|
||||
|
||||
const chatgpt = {
|
||||
id: "gpt-5.6-sol",
|
||||
providerID: "openai",
|
||||
api: { id: "gpt-5.6-sol", url: "https://api.openai.com/v1", npm: "@opencode/ai/providers/openai" },
|
||||
name: "GPT-5.6 Sol",
|
||||
family: "gpt",
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: true,
|
||||
attachment: true,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: true, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: true,
|
||||
},
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 400_000, output: 64_000 },
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2026-09-01",
|
||||
variants: {},
|
||||
provider: {
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
source: "custom",
|
||||
env: [],
|
||||
options: {},
|
||||
models: {},
|
||||
},
|
||||
latest: true,
|
||||
} satisfies NonNullable<ReturnType<ModelSelection["current"]>>
|
||||
|
||||
const models = [
|
||||
chatgpt,
|
||||
{
|
||||
...chatgpt,
|
||||
id: "gpt-5.6-terra",
|
||||
api: { ...chatgpt.api, id: "gpt-5.6-terra" },
|
||||
name: "GPT-5.6 Terra",
|
||||
latest: false,
|
||||
},
|
||||
]
|
||||
|
||||
function SelectorStory(props: { plan: boolean }) {
|
||||
const [current, setCurrent] = createSignal(models[0].id)
|
||||
return (
|
||||
<div class="flex min-h-[280px] items-end justify-center">
|
||||
<ModelSelectorPopoverView
|
||||
trigger={(trigger) => (
|
||||
<Button {...trigger} variant="ghost-muted">
|
||||
{models.find((model) => model.id === current())?.name}
|
||||
</Button>
|
||||
)}
|
||||
models={(search) => models.filter((model) => model.name.toLowerCase().includes(search.toLowerCase()))}
|
||||
groups={(items) => [{ category: "openai", items }]}
|
||||
current={`openai:${current()}`}
|
||||
chatgptPlan={props.plan}
|
||||
select={(item) => setCurrent(item.id)}
|
||||
onManage={() => undefined}
|
||||
onClose={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "App/Dialogs/Model Selector",
|
||||
id: "app-dialog-model-selector",
|
||||
}
|
||||
|
||||
export const ChatGPTPlan = { render: () => <SelectorStory plan /> }
|
||||
export const ApiKey = { render: () => <SelectorStory plan={false} /> }
|
||||
@@ -15,13 +15,16 @@ import { Menu } from "@opencode/ui/menu"
|
||||
import { TextInput } from "@opencode/ui/text-input"
|
||||
import { ModelTooltip } from "./tooltip"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { handleDocumentSearchKeydown } from "@/shell/commands/search-keydown"
|
||||
import { createMenuDismissController } from "@/shell/commands/menu-dismiss"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { matchesModelSearch } from "./search"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelSections } from "@/providers/models/provider-group"
|
||||
import { CONSOLE_GROUP_KEY, consoleModelGroup, ProviderModelIcon, ProviderModelSections } from "@/providers/models/provider-group"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const isFree = (provider: string, cost: { input: number } | undefined) =>
|
||||
@@ -218,11 +221,21 @@ export function ModelSelectorPopover(props: {
|
||||
onClose?: () => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const data = useData()
|
||||
const location = useWorkspaceLocation()
|
||||
const controller = createModelSelectorController({
|
||||
model: props.model,
|
||||
provider: () => props.provider,
|
||||
onSelect: () => props.onClose?.(),
|
||||
})
|
||||
const chatgptPlan = () => {
|
||||
if (!controller.current()?.startsWith("openai:")) return false
|
||||
const connection = data.location.integration
|
||||
.list(location().ref)
|
||||
?.find((integration) => integration.id === "openai")
|
||||
?.connections[0]
|
||||
return connection?.type === "credential" && connection.method === "oauth"
|
||||
}
|
||||
|
||||
return (
|
||||
<ModelSelectorPopoverView
|
||||
@@ -230,6 +243,7 @@ export function ModelSelectorPopover(props: {
|
||||
models={controller.models}
|
||||
groups={controller.groups}
|
||||
current={controller.current()}
|
||||
chatgptPlan={chatgptPlan()}
|
||||
select={controller.select}
|
||||
onManage={() => {
|
||||
void import("./manage").then((module) => {
|
||||
@@ -281,11 +295,12 @@ function createModelSelectorController(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function ModelSelectorPopoverView(props: {
|
||||
export function ModelSelectorPopoverView(props: {
|
||||
trigger: ModelSelectorTrigger
|
||||
models: (search: string) => ModelItem[]
|
||||
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||
current: string | undefined
|
||||
chatgptPlan?: boolean
|
||||
select: (item: ModelItem) => void
|
||||
onManage: () => void
|
||||
onClose: () => void
|
||||
@@ -368,7 +383,8 @@ function ModelSelectorPopoverView(props: {
|
||||
<Menu.Portal>
|
||||
<Menu.Content
|
||||
ref={(element: HTMLDivElement) => (contentRef = element)}
|
||||
class="w-[284px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
class="w-[284px] max-w-[calc(100vw-16px)] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 !p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none"
|
||||
classList={{ "!w-[320px]": props.chatgptPlan }}
|
||||
onPointerDownOutside={dismiss.preventTriggerRestore}
|
||||
onFocusOutside={dismiss.preventTriggerRestore}
|
||||
onCloseAutoFocus={dismiss.onCloseAutoFocus}
|
||||
@@ -505,6 +521,20 @@ function ModelSelectorPopoverView(props: {
|
||||
<span class="min-w-0 flex-1 truncate leading-5">{language.t("dialog.model.manage")}</span>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
<Show when={props.chatgptPlan}>
|
||||
<div class="h-px bg-v2-border-border-muted" />
|
||||
<div class="flex min-h-10 items-center gap-2 px-3 py-2 text-[13px] leading-5 text-v2-text-text-base">
|
||||
<ProviderModelIcon provider={{ id: "openai", name: "OpenAI" }} class="shrink-0" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("dialog.model.chatgptPlan")}</span>
|
||||
<ExternalLink
|
||||
href="https://chatgpt.com/#settings/Usage"
|
||||
class="flex shrink-0 items-center gap-1 rounded-sm text-v2-text-text-muted no-underline hover:text-v2-text-text-base focus-visible:outline focus-visible:outline-2"
|
||||
>
|
||||
{language.t("dialog.model.chatgptManageUsage")}
|
||||
<Icon name="arrow-up-right" size="small" />
|
||||
</ExternalLink>
|
||||
</div>
|
||||
</Show>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
|
||||
@@ -186,6 +186,8 @@ export const dict = {
|
||||
"dialog.model.empty": "No model results",
|
||||
"dialog.model.manage": "Manage models",
|
||||
"dialog.model.manage.description": "Customize which models appear in the model selector.",
|
||||
"dialog.model.chatgptPlan": "Using ChatGPT plan",
|
||||
"dialog.model.chatgptManageUsage": "Manage usage",
|
||||
"dialog.model.manage.provider.toggle": "Toggle all {{provider}} models",
|
||||
|
||||
"dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode",
|
||||
@@ -217,7 +219,15 @@ export const dict = {
|
||||
"provider.connect.models.title": "Connected to {{provider}}",
|
||||
"provider.connect.models.description": "Choose a model to start with. You can switch models anytime.",
|
||||
"provider.connect.models.available": "Available models",
|
||||
"provider.connect.chatgptWelcome.title": "You're using your ChatGPT plan",
|
||||
"provider.connect.chatgptWelcome.description": "Eligible requests in OpenCode use your ChatGPT plan.",
|
||||
"provider.connect.chatgptWelcome.usage": "Manage usage in ChatGPT settings",
|
||||
"provider.connect.chatgptWelcome.confirm": "Got it",
|
||||
"provider.connect.models.list": "Models available from {{provider}}",
|
||||
"provider.connect.chatgptUsageLimit.title": "ChatGPT usage limit reached",
|
||||
"provider.connect.chatgptUsageLimit.description": "Review your usage settings in ChatGPT.",
|
||||
"provider.connect.chatgptUsageLimit.manage": "Manage usage",
|
||||
"provider.connect.chatgptUsageLimit.close": "Close",
|
||||
"provider.connect.console.refreshFailed":
|
||||
"Your account is connected, but we couldn't load your models. Try again to refresh them.",
|
||||
"provider.connect.console.connected": "OpenCode connected",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { GoUpsellState } from "./usage-exceeded-dialogs"
|
||||
import { GoUpsellState, isChatGPTUsageLimit } from "./usage-exceeded-dialogs"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
const decode = Schema.decodeUnknownSync(
|
||||
@@ -38,3 +38,31 @@ describe("usage exceeded preferences", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("ChatGPT usage limit", () => {
|
||||
const message =
|
||||
"ChatGPT usage limit reached. Try again after your allowance resets; check ChatGPT Settings → Usage for details."
|
||||
|
||||
test("detects the mapped message regardless of failure type", () => {
|
||||
expect(
|
||||
isChatGPTUsageLimit({
|
||||
type: "provider.rate-limit",
|
||||
message,
|
||||
status: 429,
|
||||
}),
|
||||
).toBe(true)
|
||||
expect(isChatGPTUsageLimit({ type: "provider.unknown", message })).toBe(true)
|
||||
})
|
||||
|
||||
test("ignores other rate limits and similar messages", () => {
|
||||
expect(isChatGPTUsageLimit({ type: "provider.rate-limit", message: "Rate limit exceeded", status: 429 })).toBe(
|
||||
false,
|
||||
)
|
||||
expect(
|
||||
isChatGPTUsageLimit({
|
||||
type: "provider.rate-limit",
|
||||
message: "ChatGPT usage limit reached",
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import type { SessionStatus } from "@opencode/client/promise"
|
||||
import type { SessionStatus, SessionStepFailed } from "@opencode/client/promise"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { Schema } from "effect"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
@@ -14,6 +14,14 @@ const GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT = "go_upsell_account_rate_limit_
|
||||
const GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW = "go_upsell_account_rate_limit_dont_show"
|
||||
const GO_UPSELL_WINDOW = 86_400_000 // 24 hrs
|
||||
const GO_UPSELL_PROVIDERS = new Set(["opencode", "opencode-go"])
|
||||
const CHATGPT_USAGE_LIMIT_WINDOW = 86_400_000 // 24 hrs
|
||||
|
||||
export function isChatGPTUsageLimit(error: SessionStepFailed["data"]["error"]) {
|
||||
return (
|
||||
error.message ===
|
||||
"ChatGPT usage limit reached. Try again after your allowance resets; check ChatGPT Settings → Usage for details."
|
||||
)
|
||||
}
|
||||
|
||||
export const GoUpsellState = Persistence.struct({
|
||||
[GO_UPSELL_FREE_TIER_LAST_SEEN_AT]: Schema.NullOr(Schema.Finite),
|
||||
@@ -52,6 +60,25 @@ export function useUsageExceededDialogs() {
|
||||
[GO_UPSELL_ACCOUNT_RATE_LIMIT_LAST_SEEN_AT]: null,
|
||||
[GO_UPSELL_ACCOUNT_RATE_LIMIT_DONT_SHOW]: null,
|
||||
})
|
||||
const [chatgptUsageLimit, setChatGPTUsageLimit] = persisted(
|
||||
Persist.global("chatgpt-usage-limit"),
|
||||
Persistence.struct({ lastSeenAt: Schema.NullOr(Schema.Finite) }),
|
||||
{ lastSeenAt: null },
|
||||
)
|
||||
|
||||
onCleanup(
|
||||
sdk().event.on("session.step.failed", (evt) => {
|
||||
if (evt.data.sessionID !== params.id) return
|
||||
if (!isChatGPTUsageLimit(evt.data.error) || dialog.active) return
|
||||
if (chatgptUsageLimit.lastSeenAt && Date.now() - chatgptUsageLimit.lastSeenAt < CHATGPT_USAGE_LIMIT_WINDOW) return
|
||||
|
||||
void import("@/providers/connect/chatgpt-usage-limit").then((usage) => {
|
||||
if (dialog.active) return
|
||||
setChatGPTUsageLimit("lastSeenAt", Date.now())
|
||||
dialog.show(() => <usage.DialogChatGPTUsageLimit />)
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
onCleanup(
|
||||
sdk().event.on("session.status", (evt) => {
|
||||
|
||||
@@ -183,6 +183,26 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("export", {
|
||||
description: "print stored credentials, including secrets, as JSON",
|
||||
params: {
|
||||
...ServerParams,
|
||||
target: Argument.string("target").pipe(
|
||||
Argument.withDescription("Integration ID or name (exports every integration when omitted)"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("import", {
|
||||
description: "import credentials exported by auth export",
|
||||
params: {
|
||||
...ServerParams,
|
||||
file: Argument.string("file").pipe(
|
||||
Argument.withDescription("JSON file to import (reads stdin when omitted)"),
|
||||
Argument.optional,
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("switch", {
|
||||
description: "switch the active account for an integration",
|
||||
params: {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { createClient, loadIntegrations, request, resolveIntegration } from "./shared"
|
||||
import { errorMessage } from "../../../util/error"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.export,
|
||||
Effect.fn("cli.auth.export")(
|
||||
function* (input) {
|
||||
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
|
||||
const target = Option.getOrUndefined(input.target)
|
||||
const integrationID = target ? (yield* resolveIntegration(yield* loadIntegrations(client), target)).id : undefined
|
||||
const credentials = (yield* request((signal) => client.credential.list({ signal }))).filter(
|
||||
(credential) => !integrationID || credential.integrationID === integrationID,
|
||||
)
|
||||
if (process.stdout.isTTY)
|
||||
process.stderr.write(
|
||||
"Warning: the output contains secrets; redirect it to a file or pipe it to auth import" + EOL,
|
||||
)
|
||||
process.stdout.write(JSON.stringify(credentials, null, 2) + EOL)
|
||||
},
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
process.stderr.write(errorMessage(error) + EOL)
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
import { EOL } from "node:os"
|
||||
import { isConflictError, type CredentialCreateInput } from "@opencode/client"
|
||||
import { Credential } from "@opencode/schema/credential"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { createClient, request } from "./shared"
|
||||
import { errorMessage } from "../../../util/error"
|
||||
import { readStdin } from "../../../util/io"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.auth.commands.import,
|
||||
Effect.fn("cli.auth.import")(
|
||||
function* (input) {
|
||||
const file = Option.getOrUndefined(input.file)
|
||||
if (!file && process.stdin.isTTY)
|
||||
return yield* Effect.fail(new Error("Pipe auth export output into stdin or pass a file to import"))
|
||||
const text = yield* request(() => (file ? Bun.file(file).text() : readStdin()))
|
||||
const credentials = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Credential.Entry)))(text)
|
||||
const client = yield* createClient({ server: Option.getOrUndefined(input.server), standalone: input.standalone })
|
||||
const existing = yield* request((signal) => client.credential.list({ signal }))
|
||||
const ids = new Set(existing.map((credential) => credential.id))
|
||||
const integrations = new Set(existing.map((credential) => credential.integrationID))
|
||||
const results = yield* Effect.forEach(credentials, (credential) => {
|
||||
if (ids.has(credential.id)) return Effect.succeed(false)
|
||||
return request((signal) =>
|
||||
client.credential.create(
|
||||
{
|
||||
id: credential.id,
|
||||
integrationID: credential.integrationID,
|
||||
label: credential.label,
|
||||
// Stored metadata is JSON, but the credential schema types it as unknown while the generated client expects JSON.
|
||||
value: credential.value as CredentialCreateInput["value"],
|
||||
// Keep the destination's current selections; only integrations new to it adopt the exported selection.
|
||||
activate: credential.active && !integrations.has(credential.integrationID),
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
).pipe(
|
||||
Effect.as(true),
|
||||
Effect.catchIf(isConflictError, () => Effect.succeed(false)),
|
||||
)
|
||||
})
|
||||
const imported = results.filter(Boolean).length
|
||||
process.stderr.write(
|
||||
`Imported ${imported} ${imported === 1 ? "credential" : "credentials"}` +
|
||||
(results.length > imported ? `, skipped ${results.length - imported} already present` : "") +
|
||||
EOL,
|
||||
)
|
||||
},
|
||||
Effect.catch((error) =>
|
||||
Effect.sync(() => {
|
||||
process.stderr.write(errorMessage(error) + EOL)
|
||||
process.exitCode = 1
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -4,12 +4,12 @@ import { OpenCode } from "@opencode/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.agents,
|
||||
Effect.fn("cli.debug.agents")(function* () {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.agent.list({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(
|
||||
|
||||
@@ -4,13 +4,13 @@ import { OpenCode } from "@opencode/client"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { redactConfig } from "./redact"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.debug.commands.config,
|
||||
Effect.fn("cli.debug.config")(function* () {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const entries = yield* Effect.promise(() => client.config.get({ location: { directory: process.cwd() } }))
|
||||
process.stdout.write(JSON.stringify(redactConfig(entries), null, 2) + EOL)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OpenCode, type IntegrationInfo, type IntegrationOAuthMethod, type McpSe
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { selectIntegration, type IntegrationChoice } from "../../../ui/integration-picker"
|
||||
import { handlePromptErrors, prompt, requireInteractive } from "../../../ui/prompt"
|
||||
import { answerForm } from "../auth/form"
|
||||
@@ -21,7 +21,7 @@ export default Runtime.handler(
|
||||
const authenticate = Effect.fn("cli.mcp.auth.run")(function* (name?: string) {
|
||||
if (!name) yield* requireInteractive("Pass an MCP server name when running without an interactive terminal")
|
||||
intro("Authenticate an MCP server")
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const integrations = yield* loadIntegrations(client)
|
||||
const servers = yield* request((signal) => client.mcp.list({ location }, { signal }))
|
||||
|
||||
@@ -4,12 +4,12 @@ import { OpenCode, type McpServer } from "@opencode/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.list,
|
||||
Effect.fn("cli.mcp.list")(function* () {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.mcp.list({ location: { directory: process.cwd() } }))
|
||||
const servers = response.data.toSorted((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OpenCode } from "@opencode/client"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { resolveIntegration } from "./resolve"
|
||||
|
||||
const location = { directory: process.cwd() }
|
||||
@@ -12,7 +12,7 @@ const location = { directory: process.cwd() }
|
||||
export default Runtime.handler(
|
||||
Commands.commands.mcp.commands.logout,
|
||||
Effect.fn("cli.mcp.logout")(function* (input) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
|
||||
const integration = yield* resolveIntegration(client, input.name, location)
|
||||
|
||||
@@ -10,6 +10,10 @@ import { ServiceConfig } from "../../services/service-config"
|
||||
export default Runtime.handler(
|
||||
Commands.commands.pair,
|
||||
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
|
||||
if ((yield* ServiceConfig.read()).disabled === true)
|
||||
return yield* Effect.fail(
|
||||
new Error("Pairing requires the background service; run `opencode service unset disabled` first"),
|
||||
)
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const urls = Option.isSome(input.url)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { OpenCode, type PluginInfo } from "@opencode/client"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { Config } from "../../../config"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
|
||||
export interface Item {
|
||||
readonly runtime: "Server" | "TUI"
|
||||
@@ -16,7 +16,7 @@ export interface Item {
|
||||
}
|
||||
|
||||
export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: string) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const location = { directory: process.cwd() }
|
||||
const listed = yield* Effect.promise(() => client.plugin.list({ location }))
|
||||
|
||||
@@ -5,7 +5,7 @@ import { OpenCode, type PluginInfo } from "@opencode/client"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { ServerConnection } from "../../../services/server-connection"
|
||||
import { Config } from "../../../config"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
@@ -16,7 +16,7 @@ import { discoverPluginTargets, localPluginDirectories, localSource } from "@ope
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
Effect.fn("cli.plugin.list")(function* (input) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const { endpoint } = yield* ServerConnection.resolve()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
||||
const config = yield* Config.Service
|
||||
|
||||
@@ -31,6 +31,8 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
list: () => import("./commands/handlers/auth/list"),
|
||||
login: () => import("./commands/handlers/auth/login"),
|
||||
logout: () => import("./commands/handlers/auth/logout"),
|
||||
export: () => import("./commands/handlers/auth/export"),
|
||||
import: () => import("./commands/handlers/auth/import"),
|
||||
switch: () => import("./commands/handlers/auth/switch"),
|
||||
},
|
||||
debug: {
|
||||
|
||||
@@ -67,6 +67,9 @@ type FormRequest = Extract<V2Event, { type: "form.created" }>["data"]["form"]
|
||||
// attached client must not cancel input that may belong to another session.
|
||||
const GLOBAL_FORM_SESSION_ID = "global"
|
||||
|
||||
const PERMISSION_REJECTED_FEEDBACK =
|
||||
"This non-interactive run cannot ask the user for permission, so the request was rejected. Continue without this action."
|
||||
|
||||
export async function runNonInteractivePrompt(input: Input) {
|
||||
const controller = new AbortController()
|
||||
const stream = input.client.event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
|
||||
@@ -132,10 +135,34 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
}
|
||||
|
||||
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
|
||||
// Subagents run in child sessions; their asks and questions belong to this run too. Other
|
||||
// sessions on a shared server (e.g. the TUI's) must be left alone.
|
||||
const owned = new Map<string, Promise<boolean>>([[input.sessionID, Promise.resolve(true)]])
|
||||
const ownsSession = (sessionID: string): Promise<boolean> => {
|
||||
const known = owned.get(sessionID)
|
||||
if (known) return known
|
||||
const result =
|
||||
sessionID === GLOBAL_FORM_SESSION_ID
|
||||
? Promise.resolve(false)
|
||||
: input.client.session
|
||||
.get({ sessionID })
|
||||
.then((session) => (session.parentID ? ownsSession(session.parentID) : false))
|
||||
.catch(() => false)
|
||||
owned.set(sessionID, result)
|
||||
return result
|
||||
}
|
||||
|
||||
const replyPermission = async (request: {
|
||||
id: string
|
||||
sessionID: string
|
||||
action: string
|
||||
resources: ReadonlyArray<string>
|
||||
}) => {
|
||||
// Nobody can approve here. Outside V1 compatibility, reject with feedback so the tool fails
|
||||
// as ordinary model-visible output and the model continues without the action.
|
||||
const continuing = !input.auto && input.compatibility !== "v1"
|
||||
if (!input.auto) {
|
||||
permissionRejected = true
|
||||
if (input.compatibility !== "v1") process.exitCode = 1
|
||||
if (!continuing) permissionRejected = true
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL +
|
||||
@@ -144,12 +171,13 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
await input.client.permission
|
||||
.reply({
|
||||
sessionID: input.sessionID,
|
||||
sessionID: request.sessionID,
|
||||
requestID: request.id,
|
||||
decision: input.auto ? "once" : "reject",
|
||||
...(continuing ? { message: PERMISSION_REJECTED_FEEDBACK } : {}),
|
||||
})
|
||||
.catch(() => {})
|
||||
if (!input.auto) {
|
||||
if (!input.auto && !continuing) {
|
||||
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
|
||||
}
|
||||
}
|
||||
@@ -179,14 +207,14 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
const event = next.value
|
||||
|
||||
if (event.type === "permission.asked" && submitted && event.data.sessionID === input.sessionID) {
|
||||
if (event.type === "permission.asked" && submitted && (await ownsSession(event.data.sessionID))) {
|
||||
await replyPermission(event.data)
|
||||
continue
|
||||
}
|
||||
if (
|
||||
event.type === "form.created" &&
|
||||
submitted &&
|
||||
(event.data.form.sessionID === input.sessionID ||
|
||||
((await ownsSession(event.data.form.sessionID)) ||
|
||||
(!input.attached &&
|
||||
event.data.form.sessionID === GLOBAL_FORM_SESSION_ID &&
|
||||
sameLocation(event.location, input.location)))
|
||||
|
||||
@@ -18,7 +18,7 @@ export type Resolved = {
|
||||
readonly service?: ReturnType<typeof managedService>
|
||||
}
|
||||
|
||||
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args) {
|
||||
export const resolve = Effect.fn("cli.server-connection.resolve")(function* (args: Args = {}) {
|
||||
if (args.server !== undefined && args.standalone)
|
||||
return yield* Effect.fail(new Error("--server and --standalone cannot be combined"))
|
||||
if (args.server !== undefined) {
|
||||
@@ -38,7 +38,7 @@ export const resolve = Effect.fn("cli.server-connection.resolve")(function* (arg
|
||||
)
|
||||
return { endpoint } satisfies Resolved
|
||||
}
|
||||
if (args.standalone) {
|
||||
if (args.standalone || (yield* ServiceConfig.read()).disabled === true) {
|
||||
return { endpoint: yield* Standalone.start() } satisfies Resolved
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { selfCommand } from "../util/process"
|
||||
// registration file (by channel), which version, and how to spawn opencode.
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
disabled: Schema.optional(Schema.Boolean),
|
||||
hostname: Schema.optional(Schema.String),
|
||||
port: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1), Schema.isLessThanOrEqualTo(65_535))),
|
||||
password: Schema.optional(Schema.String),
|
||||
@@ -20,7 +21,7 @@ export const Info = Schema.Struct({
|
||||
})
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
const keys = ["hostname", "port", "password", "cors", "env"] as const
|
||||
const keys = ["disabled", "hostname", "port", "password", "cors", "env"] as const
|
||||
type Key = (typeof keys)[number]
|
||||
|
||||
const decodeInfo = Schema.decodeUnknownEffect(Schema.fromJsonString(Info))
|
||||
@@ -78,7 +79,15 @@ export const migrateConfig = Effect.fnUntraced(function* (legacy: string, file:
|
||||
})
|
||||
|
||||
function configKey(key: string): Key {
|
||||
if (key === "hostname" || key === "port" || key === "password" || key === "cors" || key === "env") return key
|
||||
if (
|
||||
key === "disabled" ||
|
||||
key === "hostname" ||
|
||||
key === "port" ||
|
||||
key === "password" ||
|
||||
key === "cors" ||
|
||||
key === "env"
|
||||
)
|
||||
return key
|
||||
throw new Error(`Unknown service config key: ${key}`)
|
||||
}
|
||||
|
||||
@@ -151,6 +160,9 @@ export const get = Effect.fn("cli.service-config.get")(function* (key?: string,
|
||||
const selected = configKey(key)
|
||||
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service get ${selected}`)
|
||||
switch (selected) {
|
||||
case "disabled": {
|
||||
return String((yield* read()).disabled ?? false)
|
||||
}
|
||||
case "hostname": {
|
||||
return (yield* read()).hostname ?? ""
|
||||
}
|
||||
@@ -177,6 +189,12 @@ export const set = Effect.fn("cli.service-config.set")(function* (key: string, v
|
||||
if (selected !== "env" && nestedValue !== undefined)
|
||||
throw new Error(`Usage: opencode service set ${selected} <value>`)
|
||||
switch (selected) {
|
||||
case "disabled": {
|
||||
if (value !== "true" && value !== "false") throw new Error("Disabled must be true or false")
|
||||
if (value === "true") yield* Service.stop(yield* options())
|
||||
yield* write({ ...(yield* read()), disabled: value === "true" })
|
||||
return
|
||||
}
|
||||
case "hostname": {
|
||||
yield* Service.stop(yield* options())
|
||||
yield* write({ ...(yield* read()), hostname: value })
|
||||
@@ -221,6 +239,11 @@ export const unset = Effect.fn("cli.service-config.unset")(function* (key: strin
|
||||
const selected = configKey(key)
|
||||
if (selected !== "env" && name !== undefined) throw new Error(`Usage: opencode service unset ${selected}`)
|
||||
switch (selected) {
|
||||
case "disabled": {
|
||||
const { disabled: _disabled, ...next } = yield* read()
|
||||
yield* write(next)
|
||||
return
|
||||
}
|
||||
case "hostname": {
|
||||
yield* Service.stop(yield* options())
|
||||
const { hostname: _hostname, ...next } = yield* read()
|
||||
|
||||
@@ -38,7 +38,7 @@ test("resolution groups Effect-native lifecycle operations only for the managed
|
||||
pid: process.pid,
|
||||
}),
|
||||
)
|
||||
const resolved = await runPromise(ServerConnection.resolve({}))
|
||||
const resolved = await runPromise(ServerConnection.resolve())
|
||||
|
||||
expect(resolved.endpoint.url).toBe(server.url.toString())
|
||||
expect(resolved.service).toBeDefined()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Service, type Info } from "@opencode/client/effect/service"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { OPENCODE_VERSION } from "../src/version"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, FileSystem, Schema } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
@@ -21,6 +21,24 @@ test("managed service ports are stable per installation channel", () => {
|
||||
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
|
||||
})
|
||||
|
||||
test("service disabled accepts only booleans without changing configuration on invalid input", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-disabled-config-"))
|
||||
const layer = Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, Global.Service | FileSystem.FileSystem>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(layer), Effect.provide(NodeFileSystem.layer)))
|
||||
try {
|
||||
expect(await run(ServiceConfig.get("disabled"))).toBe("false")
|
||||
await expect(run(ServiceConfig.set("disabled", "yes"))).rejects.toThrow("Disabled must be true or false")
|
||||
expect(await run(ServiceConfig.read())).toEqual({})
|
||||
await run(ServiceConfig.set("disabled", "true"))
|
||||
expect(await run(ServiceConfig.read())).toEqual({ disabled: true })
|
||||
await run(ServiceConfig.unset("disabled"))
|
||||
expect(await run(ServiceConfig.read())).toEqual({})
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("local channel stores service config with the local service filename", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
|
||||
try {
|
||||
|
||||
@@ -710,7 +710,12 @@ export type SessionLogOutput =
|
||||
| undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly response?: { readonly body: string } | undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -902,7 +907,12 @@ export type SessionLogOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly response?: { readonly body: string } | undefined
|
||||
}
|
||||
readonly finish?: "content-filter" | undefined
|
||||
readonly rawFinish?: string | undefined
|
||||
readonly providerState?: SessionMessage.ProviderState | undefined
|
||||
@@ -1112,7 +1122,12 @@ export type SessionLogOutput =
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly id: string
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly response?: { readonly body: string } | undefined
|
||||
}
|
||||
readonly content?:
|
||||
| readonly [
|
||||
(
|
||||
@@ -1157,7 +1172,12 @@ export type SessionLogOutput =
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly response?: { readonly body: string } | undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
@@ -1238,7 +1258,12 @@ export type SessionLogOutput =
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly response?: { readonly body: string } | undefined
|
||||
}
|
||||
readonly inputID?: SessionMessage.ID | undefined
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
readonly tokens?:
|
||||
@@ -1718,6 +1743,21 @@ export interface McpApi<E = never> {
|
||||
readonly resource: { readonly catalog: McpResourceCatalogOperation<E> }
|
||||
}
|
||||
|
||||
export type CredentialListOutput = ReadonlyArray<Credential.Entry>
|
||||
export type CredentialListOperation<E = never> = () => Effect.Effect<CredentialListOutput, E>
|
||||
|
||||
export type CredentialCreateInput = {
|
||||
readonly id?: Credential.ID | undefined
|
||||
readonly integrationID: Integration.ID
|
||||
readonly label?: string | undefined
|
||||
readonly value: Credential.Value
|
||||
readonly activate?: boolean | undefined
|
||||
}
|
||||
export type CredentialCreateOutput = Credential.Entry
|
||||
export type CredentialCreateOperation<E = never> = (
|
||||
input: CredentialCreateInput,
|
||||
) => Effect.Effect<CredentialCreateOutput, E>
|
||||
|
||||
export type CredentialUpdateInput = { readonly credentialID: Credential.ID; readonly label: string }
|
||||
export type CredentialUpdateOutput = void
|
||||
export type CredentialUpdateOperation<E = never> = (
|
||||
@@ -1737,6 +1777,8 @@ export type CredentialRemoveOperation<E = never> = (
|
||||
) => Effect.Effect<CredentialRemoveOutput, E>
|
||||
|
||||
export interface CredentialApi<E = never> {
|
||||
readonly list: CredentialListOperation<E>
|
||||
readonly create: CredentialCreateOperation<E>
|
||||
readonly update: CredentialUpdateOperation<E>
|
||||
readonly activate: CredentialActivateOperation<E>
|
||||
readonly remove: CredentialRemoveOperation<E>
|
||||
|
||||
@@ -153,6 +153,9 @@ import type {
|
||||
McpDisconnectOutput,
|
||||
McpResourceCatalogInput,
|
||||
McpResourceCatalogOutput,
|
||||
CredentialListOutput,
|
||||
CredentialCreateInput,
|
||||
CredentialCreateOutput,
|
||||
CredentialUpdateInput,
|
||||
CredentialUpdateOutput,
|
||||
CredentialActivateInput,
|
||||
@@ -1021,6 +1024,30 @@ const adaptGroupMcp = (raw: RawClient["server.mcp"]) => ({
|
||||
resource: { catalog: EndpointMcpResourceCatalog(raw) },
|
||||
})
|
||||
|
||||
const EndpointCredentialList = (raw: RawClient["server.credential"]) => () =>
|
||||
preserveEffect<CredentialListOutput>()(
|
||||
raw["credential.list"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointCredentialCreate = (raw: RawClient["server.credential"]) => (input: CredentialCreateInput) =>
|
||||
preserveEffect<CredentialCreateOutput>()(
|
||||
raw["credential.create"]({
|
||||
payload: {
|
||||
id: input["id"],
|
||||
integrationID: input["integrationID"],
|
||||
label: input["label"],
|
||||
value: input["value"],
|
||||
activate: input["activate"],
|
||||
},
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointCredentialUpdate = (raw: RawClient["server.credential"]) => (input: CredentialUpdateInput) =>
|
||||
preserveEffect<CredentialUpdateOutput>()(
|
||||
raw["credential.update"]({
|
||||
@@ -1042,6 +1069,8 @@ const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input
|
||||
)
|
||||
|
||||
const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
|
||||
list: EndpointCredentialList(raw),
|
||||
create: EndpointCredentialCreate(raw),
|
||||
update: EndpointCredentialUpdate(raw),
|
||||
activate: EndpointCredentialActivate(raw),
|
||||
remove: EndpointCredentialRemove(raw),
|
||||
|
||||
@@ -147,6 +147,9 @@ import type {
|
||||
McpDisconnectOutput,
|
||||
McpResourceCatalogInput,
|
||||
McpResourceCatalogOutput,
|
||||
CredentialListOutput,
|
||||
CredentialCreateInput,
|
||||
CredentialCreateOutput,
|
||||
CredentialUpdateInput,
|
||||
CredentialUpdateOutput,
|
||||
CredentialActivateInput,
|
||||
@@ -1392,6 +1395,29 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
},
|
||||
credential: {
|
||||
list: (requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: CredentialListOutput }>(
|
||||
{ method: "GET", path: `/api/credential`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
create: (input: CredentialCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: CredentialCreateOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/credential`,
|
||||
body: {
|
||||
id: input["id"],
|
||||
integrationID: input["integrationID"],
|
||||
label: input["label"],
|
||||
value: input["value"],
|
||||
activate: input["activate"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 409],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
update: (input: CredentialUpdateInput, requestOptions?: RequestOptions) =>
|
||||
request<CredentialUpdateOutput>(
|
||||
{
|
||||
|
||||
@@ -133,7 +133,7 @@ export type ToolTextContent = { type: "text"; text: string }
|
||||
|
||||
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
|
||||
|
||||
export type SessionStructuredError = { type: string; message: string; status?: number }
|
||||
export type SessionStructuredError = { type: string; message: string; status?: number; response?: { body: string } }
|
||||
|
||||
export type SessionMessageCompactionRunning = {
|
||||
type: "compaction"
|
||||
@@ -235,9 +235,7 @@ export type IntegrationCommandMethod = { id: string; type: "command"; label: str
|
||||
|
||||
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
|
||||
|
||||
export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string; method: "key" | "oauth" }
|
||||
|
||||
export type ConnectionEnvInfo = { type: "env"; name: string }
|
||||
export type ConnectionStatus = { status: "needs_auth"; message: string; url?: string }
|
||||
|
||||
export type IntegrationAttempt = {
|
||||
attemptID: string
|
||||
@@ -295,6 +293,15 @@ export type McpResourceTemplate = {
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
export type CredentialOAuth = {
|
||||
type: "oauth"
|
||||
methodID: string
|
||||
refresh: string
|
||||
access: string
|
||||
expires: number
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type ProjectVcs = string
|
||||
|
||||
export type ProjectIcon = { url?: string; override?: string; color?: string }
|
||||
@@ -1469,7 +1476,15 @@ export type ModelCost = {
|
||||
cache: { read: MoneyUSDPerMillionTokens; write: MoneyUSDPerMillionTokens }
|
||||
}
|
||||
|
||||
export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
|
||||
export type ConnectionCredentialInfo = {
|
||||
type: "credential"
|
||||
id: string
|
||||
label: string
|
||||
method: "key" | "oauth"
|
||||
status?: ConnectionStatus
|
||||
}
|
||||
|
||||
export type ConnectionEnvInfo = { type: "env"; name: string; status?: ConnectionStatus }
|
||||
|
||||
export type McpServer = {
|
||||
name: string
|
||||
@@ -1650,7 +1665,7 @@ export type FormMultiselectField1 = {
|
||||
default?: Array<string>
|
||||
}
|
||||
|
||||
export type FormAnswer1 = { [x: string]: FormValue1 }
|
||||
export type FormAnswer2 = { [x: string]: FormValue1 }
|
||||
|
||||
export type SessionStatusUpdated = {
|
||||
id: string
|
||||
@@ -1884,6 +1899,15 @@ export type FormField =
|
||||
|
||||
export type FormState = { status: "pending" } | { status: "answered"; answer: FormAnswer } | { status: "cancelled" }
|
||||
|
||||
export type CredentialKey = {
|
||||
type: "key"
|
||||
key: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
configuration?: FormAnswer
|
||||
}
|
||||
|
||||
export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
|
||||
|
||||
export type FormField1 =
|
||||
| FormStringField1
|
||||
| FormNumberField1
|
||||
@@ -1898,7 +1922,7 @@ export type FormReplied = {
|
||||
metadata?: { [x: string]: any }
|
||||
type: "form.replied"
|
||||
location?: LocationRef
|
||||
data: { id: string; sessionID: string; answer: FormAnswer1 }
|
||||
data: { id: string; sessionID: string; answer: FormAnswer2 }
|
||||
}
|
||||
|
||||
export type ReferenceInfo = {
|
||||
@@ -2217,6 +2241,8 @@ export type SessionMessageAssistantTool1 = {
|
||||
|
||||
export type FormFields = [FormField, ...Array<FormField>]
|
||||
|
||||
export type CredentialValue = CredentialOAuth | CredentialKey
|
||||
|
||||
export type FormFields2 = [FormField1, ...Array<FormField1>]
|
||||
|
||||
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
|
||||
@@ -2271,6 +2297,14 @@ export type IntegrationOAuthMethod = { id: string; type: "oauth"; label: string;
|
||||
|
||||
export type IntegrationKeyMethod = { type: "key"; label?: string; form?: FormFields }
|
||||
|
||||
export type CredentialEntry = {
|
||||
id: string
|
||||
integrationID: string
|
||||
label: string
|
||||
active: boolean
|
||||
value: CredentialValue
|
||||
}
|
||||
|
||||
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields2 }
|
||||
|
||||
export type SessionMessageInfo =
|
||||
@@ -3175,7 +3209,12 @@ export type SessionImportInput = {
|
||||
| {
|
||||
readonly status: "error"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly response?: { readonly body: string }
|
||||
}
|
||||
readonly content?: readonly [
|
||||
(
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
@@ -3212,11 +3251,21 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error?: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly response?: { readonly body: string }
|
||||
}
|
||||
readonly retry?: {
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly response?: { readonly body: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
| (
|
||||
@@ -3268,7 +3317,12 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly response?: { readonly body: string }
|
||||
}
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
@@ -3492,7 +3546,12 @@ export type SessionImportInput = {
|
||||
| {
|
||||
readonly status: "error"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly response?: { readonly body: string }
|
||||
}
|
||||
readonly content?: readonly [
|
||||
(
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
@@ -3529,11 +3588,21 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error?: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly response?: { readonly body: string }
|
||||
}
|
||||
readonly retry?: {
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly response?: { readonly body: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
| (
|
||||
@@ -3585,7 +3654,12 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly response?: { readonly body: string }
|
||||
}
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
@@ -3809,7 +3883,12 @@ export type SessionImportInput = {
|
||||
| {
|
||||
readonly status: "error"
|
||||
readonly input: { readonly [x: string]: JsonValue }
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly response?: { readonly body: string }
|
||||
}
|
||||
readonly content?: readonly [
|
||||
(
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
@@ -3846,11 +3925,21 @@ export type SessionImportInput = {
|
||||
readonly reasoning: number
|
||||
readonly cache: { readonly read: number; readonly write: number }
|
||||
}
|
||||
readonly error?: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error?: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly response?: { readonly body: string }
|
||||
}
|
||||
readonly retry?: {
|
||||
readonly attempt: number
|
||||
readonly at: number
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly response?: { readonly body: string }
|
||||
}
|
||||
}
|
||||
}
|
||||
| (
|
||||
@@ -3902,7 +3991,12 @@ export type SessionImportInput = {
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly error: {
|
||||
readonly type: string
|
||||
readonly message: string
|
||||
readonly status?: number
|
||||
readonly response?: { readonly body: string }
|
||||
}
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
@@ -5682,6 +5776,128 @@ export type McpResourceCatalogInput = {
|
||||
|
||||
export type McpResourceCatalogOutput = { location: LocationPublicRef; data: McpResourceCatalog }
|
||||
|
||||
export type CredentialListOutput = { data: Array<CredentialEntry> }["data"]
|
||||
|
||||
export type CredentialCreateInput = {
|
||||
readonly id?: {
|
||||
readonly id?: string
|
||||
readonly integrationID: string
|
||||
readonly label?: string
|
||||
readonly value:
|
||||
| {
|
||||
readonly type: "oauth"
|
||||
readonly methodID: string
|
||||
readonly refresh: string
|
||||
readonly access: string
|
||||
readonly expires: number
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
| {
|
||||
readonly type: "key"
|
||||
readonly key: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly configuration?: {
|
||||
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
|
||||
}
|
||||
}
|
||||
readonly activate?: boolean
|
||||
}["id"]
|
||||
readonly integrationID: {
|
||||
readonly id?: string
|
||||
readonly integrationID: string
|
||||
readonly label?: string
|
||||
readonly value:
|
||||
| {
|
||||
readonly type: "oauth"
|
||||
readonly methodID: string
|
||||
readonly refresh: string
|
||||
readonly access: string
|
||||
readonly expires: number
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
| {
|
||||
readonly type: "key"
|
||||
readonly key: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly configuration?: {
|
||||
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
|
||||
}
|
||||
}
|
||||
readonly activate?: boolean
|
||||
}["integrationID"]
|
||||
readonly label?: {
|
||||
readonly id?: string
|
||||
readonly integrationID: string
|
||||
readonly label?: string
|
||||
readonly value:
|
||||
| {
|
||||
readonly type: "oauth"
|
||||
readonly methodID: string
|
||||
readonly refresh: string
|
||||
readonly access: string
|
||||
readonly expires: number
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
| {
|
||||
readonly type: "key"
|
||||
readonly key: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly configuration?: {
|
||||
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
|
||||
}
|
||||
}
|
||||
readonly activate?: boolean
|
||||
}["label"]
|
||||
readonly value: {
|
||||
readonly id?: string
|
||||
readonly integrationID: string
|
||||
readonly label?: string
|
||||
readonly value:
|
||||
| {
|
||||
readonly type: "oauth"
|
||||
readonly methodID: string
|
||||
readonly refresh: string
|
||||
readonly access: string
|
||||
readonly expires: number
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
| {
|
||||
readonly type: "key"
|
||||
readonly key: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly configuration?: {
|
||||
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
|
||||
}
|
||||
}
|
||||
readonly activate?: boolean
|
||||
}["value"]
|
||||
readonly activate?: {
|
||||
readonly id?: string
|
||||
readonly integrationID: string
|
||||
readonly label?: string
|
||||
readonly value:
|
||||
| {
|
||||
readonly type: "oauth"
|
||||
readonly methodID: string
|
||||
readonly refresh: string
|
||||
readonly access: string
|
||||
readonly expires: number
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
}
|
||||
| {
|
||||
readonly type: "key"
|
||||
readonly key: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly configuration?: {
|
||||
readonly [x: string]: string | number | "Infinity" | "-Infinity" | "NaN" | boolean | ReadonlyArray<string>
|
||||
}
|
||||
}
|
||||
readonly activate?: boolean
|
||||
}["activate"]
|
||||
}
|
||||
|
||||
export type CredentialCreateOutput = { data: CredentialEntry }["data"]
|
||||
|
||||
export type CredentialUpdateInput = {
|
||||
readonly credentialID: { readonly credentialID: string }["credentialID"]
|
||||
readonly label: { readonly label: string }["label"]
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"ignore": "7.0.5",
|
||||
"immer": "11.1.4",
|
||||
"jose": "6.0.11",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
|
||||
@@ -38,11 +38,16 @@ export interface Interface {
|
||||
readonly list: (integrationID: Integration.ID) => Effect.Effect<Info[]>
|
||||
/** Returns one stored credential by ID. */
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
/** Creates a credential for an integration and returns the new record. */
|
||||
/**
|
||||
* Creates a credential for an integration and returns the new record. The new credential becomes the
|
||||
* integration's selection unless `activate` is false and the integration already has a credential.
|
||||
*/
|
||||
readonly create: (input: {
|
||||
readonly id?: ID
|
||||
readonly integrationID: Integration.ID
|
||||
readonly value: Value
|
||||
readonly label?: string
|
||||
readonly activate?: boolean
|
||||
}) => Effect.Effect<Info>
|
||||
/** Selects a stored credential for its integration. */
|
||||
readonly activate: (id: ID) => Effect.Effect<void>
|
||||
@@ -99,19 +104,30 @@ const layer = Layer.effect(
|
||||
}),
|
||||
create: Effect.fn("Credential.create")(function* (input) {
|
||||
const credential = new Info({
|
||||
id: ID.create(),
|
||||
id: input.id ?? ID.create(),
|
||||
integrationID: input.integrationID,
|
||||
label: input.label ?? "default",
|
||||
value: input.value,
|
||||
})
|
||||
yield* db
|
||||
const activated = yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* tx
|
||||
.update(CredentialTable)
|
||||
.set({ active: false })
|
||||
const current = yield* tx
|
||||
.select({ id: CredentialTable.id, active: CredentialTable.active })
|
||||
.from(CredentialTable)
|
||||
.where(eq(CredentialTable.integration_id, credential.integrationID))
|
||||
.run()
|
||||
.orderBy(desc(CredentialTable.active), desc(CredentialTable.time_created), desc(CredentialTable.id))
|
||||
.get()
|
||||
const activate = input.activate !== false || !current
|
||||
if (activate)
|
||||
yield* tx
|
||||
.update(CredentialTable)
|
||||
.set({ active: false })
|
||||
.where(eq(CredentialTable.integration_id, credential.integrationID))
|
||||
.run()
|
||||
// Legacy rows have no active flag and the newest one is selected, so pin it before inserting a newer row.
|
||||
if (!activate && current && !current.active)
|
||||
yield* tx.update(CredentialTable).set({ active: true }).where(eq(CredentialTable.id, current.id)).run()
|
||||
yield* tx
|
||||
.insert(CredentialTable)
|
||||
.values({
|
||||
@@ -119,9 +135,10 @@ const layer = Layer.effect(
|
||||
integration_id: credential.integrationID,
|
||||
label: credential.label,
|
||||
value: credential.value,
|
||||
active: true,
|
||||
active: activate,
|
||||
})
|
||||
.run()
|
||||
return activate
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
@@ -138,13 +155,15 @@ const layer = Layer.effect(
|
||||
credentialID: credential.id,
|
||||
integrationID: credential.integrationID,
|
||||
type: credential.value.type,
|
||||
active: activated,
|
||||
})
|
||||
yield* bus.publish(Event.Updated, {}, { global: true })
|
||||
yield* bus.publish(
|
||||
Event.Switched,
|
||||
{ integrationID: credential.integrationID, credentialID: credential.id },
|
||||
{ global: true },
|
||||
)
|
||||
if (activated)
|
||||
yield* bus.publish(
|
||||
Event.Switched,
|
||||
{ integrationID: credential.integrationID, credentialID: credential.id },
|
||||
{ global: true },
|
||||
)
|
||||
return credential
|
||||
}),
|
||||
activate: Effect.fn("Credential.activate")(function* (id) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#s
|
||||
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
|
||||
import type { SqlClient } from "effect/unstable/sql"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { closeSync, existsSync, openSync } from "node:fs"
|
||||
import { chmod } from "node:fs/promises"
|
||||
import { isAbsolute, join } from "path"
|
||||
import { DatabaseMigration } from "./migration.js"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
@@ -70,13 +72,31 @@ export function layer(options: Options = { path: ":memory:" }) {
|
||||
Layer.provide(sqliteLayer({ filename })),
|
||||
)
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
const global = yield* Global.Service
|
||||
return provide(join(global.data, filename))
|
||||
if (filename === ":memory:") return provide(filename)
|
||||
const file = isAbsolute(filename) ? filename : join((yield* Global.Service).data, filename)
|
||||
yield* Effect.promise(() => restrictToOwner(file))
|
||||
return provide(file)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// SQLite creates new sidecars with the database's mode, but does not tighten existing sidecars.
|
||||
// Windows relies on the user profile directory's inherited ACLs instead of POSIX modes.
|
||||
async function restrictToOwner(filename: string) {
|
||||
if (process.platform === "win32") return
|
||||
// Opening and closing an existing database can release another connection's POSIX locks.
|
||||
// Create missing files synchronously so another fiber cannot open one before it is restricted.
|
||||
if (!existsSync(filename)) closeSync(openSync(filename, "a", 0o600))
|
||||
await chmod(filename, 0o600)
|
||||
await Promise.all(
|
||||
[`${filename}-wal`, `${filename}-shm`].map((file) =>
|
||||
chmod(file, 0o600).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code !== "ENOENT") throw error
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// The database service over an injected SqlClient, for runtimes that receive
|
||||
// database storage instead of opening a filesystem path. Any client provided
|
||||
// here still goes through the pragma guards and migrations; Global is required
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Context,
|
||||
Duration,
|
||||
Effect,
|
||||
Equal,
|
||||
Exit,
|
||||
Fiber,
|
||||
Layer,
|
||||
@@ -188,6 +189,12 @@ export interface Interface extends State.Transformable<Editor> {
|
||||
) => Effect.Effect<void>
|
||||
/** Removes a stored credential connection. */
|
||||
readonly remove: (credentialID: Credential.ID) => Effect.Effect<void>
|
||||
/** Reports or clears a runtime problem with a connection; `get` and `list` project it onto the connection. */
|
||||
readonly status: (input: {
|
||||
readonly integrationID: ID
|
||||
readonly connection: IntegrationConnection.Info
|
||||
readonly status: IntegrationConnection.Status | undefined
|
||||
}) => Effect.Effect<void>
|
||||
}
|
||||
readonly oauth: {
|
||||
/** Starts a stateful OAuth attempt. */
|
||||
@@ -279,6 +286,10 @@ const layer = Layer.effect(
|
||||
const scope = yield* Scope.Scope
|
||||
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
|
||||
const commandAttempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, CommandAttemptEntry>())
|
||||
// Runtime-only: statuses describe the current process's view of a connection and are not persisted.
|
||||
const statuses = new Map<string, IntegrationConnection.Status>()
|
||||
const statusKey = (integrationID: ID, connection: IntegrationConnection.Info) =>
|
||||
`${integrationID}:${IntegrationConnection.key(connection)}`
|
||||
const state = State.create<Data, Editor>({
|
||||
name: "integration",
|
||||
initial: () => ({ integrations: new Map<ID, Entry>() }),
|
||||
@@ -377,7 +388,10 @@ const layer = Layer.effect(
|
||||
name: entry.ref.name,
|
||||
...(entry.ref.metadata === undefined ? {} : { metadata: entry.ref.metadata }),
|
||||
methods: entry.methods,
|
||||
connections,
|
||||
connections: connections.map((connection) => {
|
||||
const status = statuses.get(statusKey(entry.ref.id, connection))
|
||||
return status ? { ...connection, status } : connection
|
||||
}),
|
||||
})
|
||||
|
||||
const authorize = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
@@ -727,6 +741,13 @@ const layer = Layer.effect(
|
||||
credentials.update(credentialID, updates),
|
||||
),
|
||||
remove: Effect.fn("Integration.connection.remove")((credentialID) => credentials.remove(credentialID)),
|
||||
status: Effect.fn("Integration.connection.status")(function* (input) {
|
||||
const key = statusKey(input.integrationID, input.connection)
|
||||
if (Equal.equals(statuses.get(key), input.status)) return
|
||||
if (input.status) statuses.set(key, input.status)
|
||||
if (!input.status) statuses.delete(key)
|
||||
yield* bus.publish(Integration.Event.Updated, {})
|
||||
}),
|
||||
},
|
||||
oauth: {
|
||||
connect: connectOAuth,
|
||||
|
||||
@@ -11,6 +11,9 @@ export type EnvInfo = Connection.EnvInfo
|
||||
export const Info = Connection.Info
|
||||
export type Info = Connection.Info
|
||||
|
||||
export const Status = Connection.Status
|
||||
export type Status = Connection.Status
|
||||
|
||||
/** Identity of an access choice; labels and refreshed token values do not identify a new connection. */
|
||||
export function key(
|
||||
connection:
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -286,7 +286,11 @@ const layer = Layer.effect(
|
||||
requestID: item.request.id,
|
||||
reply: "reject",
|
||||
})
|
||||
yield* Deferred.fail(item.deferred, new DeclinedError())
|
||||
// Feedback applies to the whole batch, so parallel asks don't end the step.
|
||||
yield* Deferred.fail(
|
||||
item.deferred,
|
||||
input.message ? new CorrectedError({ feedback: input.message }) : new DeclinedError(),
|
||||
)
|
||||
pending.delete(id)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -345,6 +345,15 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
integration.connection.resolve(
|
||||
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
|
||||
),
|
||||
status: (input) =>
|
||||
integration.connection.status({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
connection:
|
||||
input.connection.type === "credential"
|
||||
? { ...input.connection, id: Credential.ID.make(input.connection.id) }
|
||||
: input.connection,
|
||||
status: input.status,
|
||||
}),
|
||||
},
|
||||
transform: (callback) =>
|
||||
integration.transform((editor) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AmazonBedrockPlugin } from "./provider/amazon-bedrock.js"
|
||||
import { AzurePlugin } from "./provider/azure.js"
|
||||
import { CerebrasPlugin } from "./provider/cerebras.js"
|
||||
import { ChatGPTPlugin } from "./provider/chatgpt.js"
|
||||
import { CloudflareAIGatewayPlugin } from "./provider/cloudflare-ai-gateway.js"
|
||||
import { CloudflareWorkersAIPlugin } from "./provider/cloudflare-workers-ai.js"
|
||||
import { CoherePlugin } from "./provider/cohere.js"
|
||||
@@ -35,6 +36,7 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
AmazonBedrockPlugin,
|
||||
AzurePlugin,
|
||||
CerebrasPlugin,
|
||||
ChatGPTPlugin,
|
||||
CloudflareAIGatewayPlugin,
|
||||
CloudflareWorkersAIPlugin,
|
||||
CoherePlugin,
|
||||
|
||||
@@ -0,0 +1,516 @@
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
|
||||
import type { Context } from "@opencode/plugin/effect/plugin"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Deferred, Duration, Effect, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import type { Server, ServerResponse } from "node:http"
|
||||
import { App } from "../../app.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { IntegrationConnection } from "../../integration/connection.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { OauthCallbackPage } from "../../oauth/page.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
// First-time sign-in registers a user-owned client; OpenAI returns its issued client ID on the callback.
|
||||
const registrationClientID = "dynamic_agent_client"
|
||||
const agentName = "OpenCode"
|
||||
const issuer = "https://auth.openai.com"
|
||||
const tokenURL = `${issuer}/api/accounts/oauth/token`
|
||||
const resource = "https://api.openai.com/v1"
|
||||
const tokenSharingScope = "chatgpt.tokens.use.direct"
|
||||
const providerID = Provider.ID.openai
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
const methodID = Integration.MethodID.make("chatgpt-token-sharing")
|
||||
const modelCacheKey = (clientID: string) => `models:${clientID}`
|
||||
const fallbackModels = new Set([
|
||||
"gpt-5.5",
|
||||
"gpt-5.5-fast",
|
||||
"gpt-5.6-luna",
|
||||
"gpt-5.6-luna-fast",
|
||||
"gpt-5.6-sol",
|
||||
"gpt-5.6-sol-fast",
|
||||
"gpt-5.6-terra",
|
||||
"gpt-5.6-terra-fast",
|
||||
"gpt-6-astra",
|
||||
"gpt-6-astra-fast",
|
||||
])
|
||||
const nonRetryableSharingCodes = [
|
||||
"subscription_sharing_usage_limit_exceeded",
|
||||
"subscription_sharing_user_not_eligible",
|
||||
"subscription_sharing_unsupported_capability",
|
||||
"subscription_sharing_route_not_supported",
|
||||
"subscription_sharing_invalid_user",
|
||||
"chatpass_v2_scope_not_authorized",
|
||||
"chatpass_v2_invalid_authorization_context",
|
||||
]
|
||||
type Pkce = {
|
||||
verifier: string
|
||||
challenge: string
|
||||
}
|
||||
|
||||
type TokenResponse = {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
id_token?: string
|
||||
expires_in?: number
|
||||
scope?: string
|
||||
}
|
||||
|
||||
const RemoteModel = Schema.Struct({
|
||||
slug: Schema.String,
|
||||
display_name: Schema.String,
|
||||
visibility: Schema.String,
|
||||
supported_in_api: Schema.Boolean,
|
||||
context_window: Schema.Int.check(Schema.isGreaterThan(1)),
|
||||
input_modalities: Schema.Array(Schema.String),
|
||||
supported_reasoning_levels: Schema.optional(Schema.Array(Schema.Struct({ effort: Schema.String }))),
|
||||
})
|
||||
type RemoteModel = typeof RemoteModel.Type
|
||||
const RemoteModels = Schema.Struct({ models: Schema.Array(RemoteModel) })
|
||||
const decodeCachedModels = Schema.decodeUnknownOption(Schema.Array(RemoteModel))
|
||||
|
||||
// Credential metadata is merged into provider settings; these keys are not OpenAI request options.
|
||||
const decodeMetadata = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
clientID: Schema.String,
|
||||
scopes: Schema.optional(Schema.Array(Schema.String)),
|
||||
models: Schema.optional(Schema.Array(RemoteModel)),
|
||||
}),
|
||||
)
|
||||
|
||||
const signIn = (app: App.Info, savedClientID: () => string | undefined, storage: Context["storage"]) =>
|
||||
({
|
||||
integrationID,
|
||||
method: {
|
||||
id: methodID,
|
||||
type: "oauth",
|
||||
label: "Sign in with ChatGPT",
|
||||
},
|
||||
authorize: () =>
|
||||
Effect.gen(function* () {
|
||||
const storedHostID = yield* storage.get("chatgpt-agent-host-id")
|
||||
const hostID = typeof storedHostID === "string" ? storedHostID : `urn:uuid:${crypto.randomUUID()}`
|
||||
if (typeof storedHostID !== "string") yield* storage.set("chatgpt-agent-host-id", hostID)
|
||||
const pkce = yield* Effect.promise(generatePKCE)
|
||||
const state = randomValue()
|
||||
const nonce = randomValue()
|
||||
const savedID = savedClientID()
|
||||
const received = yield* Deferred.make<{ code: string; clientID?: string; response: ServerResponse }, Error>()
|
||||
// Lazy so runtimes without a loopback listener (workerd) never evaluate node:http.
|
||||
const { createServer } = yield* Effect.promise(() => import("node:http"))
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1")
|
||||
if (url.pathname !== "/auth/callback") {
|
||||
response.writeHead(404).end("Not found")
|
||||
return
|
||||
}
|
||||
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error")
|
||||
const authorizationCode = url.searchParams.get("code")
|
||||
if (error) {
|
||||
Effect.runFork(Deferred.fail(received, new Error(error)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(error, { provider: "ChatGPT" }))
|
||||
return
|
||||
}
|
||||
if (!authorizationCode || url.searchParams.get("state") !== state) {
|
||||
const message = authorizationCode ? "Invalid OAuth state" : "Missing authorization code"
|
||||
Effect.runFork(Deferred.fail(received, new Error(message)))
|
||||
response
|
||||
.writeHead(400, { "Content-Type": "text/html" })
|
||||
.end(OauthCallbackPage.error(message, { provider: "ChatGPT" }))
|
||||
return
|
||||
}
|
||||
if (
|
||||
!Effect.runSync(
|
||||
Deferred.succeed(received, {
|
||||
code: authorizationCode,
|
||||
clientID: url.searchParams.get("client_id") ?? undefined,
|
||||
response,
|
||||
}),
|
||||
)
|
||||
)
|
||||
response.writeHead(409).end("OAuth callback already received")
|
||||
})
|
||||
const port = yield* listen(server)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => server.close()))
|
||||
const redirect = `http://127.0.0.1:${port}/auth/callback`
|
||||
return {
|
||||
mode: "auto" as const,
|
||||
url: authorizeURL(redirect, pkce, state, nonce, savedID, hostID),
|
||||
instructions: "Complete authorization in your browser. This window will close automatically.",
|
||||
callback: Effect.gen(function* () {
|
||||
const result = yield* Deferred.await(received)
|
||||
const respond = (error?: string) =>
|
||||
Effect.sync(() =>
|
||||
result.response
|
||||
.writeHead(error ? 400 : 200, { "Content-Type": "text/html" })
|
||||
.end(
|
||||
error
|
||||
? OauthCallbackPage.error(error, { provider: "ChatGPT" })
|
||||
: OauthCallbackPage.success({ provider: "ChatGPT" }),
|
||||
),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
// Reauthorization callbacks may omit the client ID; reuse the one this attempt started with.
|
||||
if (savedID && result.clientID && result.clientID !== savedID)
|
||||
return yield* Effect.fail(
|
||||
new Error("ChatGPT returned a different client than this connection. Connect again."),
|
||||
)
|
||||
const clientID = savedID ?? result.clientID
|
||||
if (!clientID)
|
||||
return yield* Effect.fail(new Error("ChatGPT sign-in did not return a client ID. Connect again."))
|
||||
const tokens = yield* exchange(result.code, clientID, redirect, pkce, app)
|
||||
if (!tokens.scope?.split(" ").includes(tokenSharingScope))
|
||||
return yield* Effect.fail(
|
||||
new Error(
|
||||
"ChatGPT sign-in finished without token sharing. Sign in again and allow token sharing, or connect OpenAI with an API key.",
|
||||
),
|
||||
)
|
||||
if (!tokens.id_token) return yield* Effect.fail(new Error("ChatGPT sign-in did not return an ID token."))
|
||||
yield* verifyIDToken(tokens.id_token, clientID, nonce)
|
||||
return credential(tokens, clientID)
|
||||
}).pipe(
|
||||
Effect.tap(() => respond()),
|
||||
Effect.tapError((error) => respond(error instanceof Error ? error.message : "ChatGPT sign-in failed")),
|
||||
Effect.onInterrupt(() => Effect.sync(() => result.response.destroy())),
|
||||
)
|
||||
}),
|
||||
}
|
||||
}),
|
||||
refresh: (value) => refresh(value, app),
|
||||
}) satisfies IntegrationOAuthMethodRegistration
|
||||
|
||||
function listen(server: Server) {
|
||||
return Effect.callback<number, Error>((resume) => {
|
||||
const onError = (error: Error) => resume(Effect.fail(error))
|
||||
server.once("error", onError)
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", onError)
|
||||
const address = server.address()
|
||||
if (!address || typeof address === "string") return resume(Effect.fail(new Error("Missing OAuth callback port")))
|
||||
resume(Effect.succeed(address.port))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const ChatGPTPlugin = define({
|
||||
id: "opencode.provider.chatgpt",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const bus = yield* Bus.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let chatgpt: Credential.OAuth | undefined
|
||||
let available: ReadonlyArray<RemoteModel> | undefined
|
||||
let source: Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
|
||||
|
||||
const load = Effect.fn("ChatGPTPlugin.load")(function* () {
|
||||
const previous = IntegrationConnection.key(source)
|
||||
const connection = yield* ctx.integration.connection.active(integrationID)
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
|
||||
: undefined
|
||||
chatgpt = credential?.type === "oauth" && credential.methodID === methodID ? credential : undefined
|
||||
source = chatgpt ? connection : undefined
|
||||
if (previous !== IntegrationConnection.key(source)) {
|
||||
const metadata = Option.getOrUndefined(decodeMetadata(chatgpt?.metadata))
|
||||
const stored = metadata ? yield* ctx.storage.get(modelCacheKey(metadata.clientID)) : undefined
|
||||
available = Option.getOrUndefined(decodeCachedModels(stored))
|
||||
}
|
||||
})
|
||||
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
editor.method.update(
|
||||
signIn(ctx.app, () => Option.getOrUndefined(decodeMetadata(chatgpt?.metadata))?.clientID, ctx.storage),
|
||||
)
|
||||
})
|
||||
yield* Effect.forEach(
|
||||
yield* credentials.list(Integration.ID.make("openai")),
|
||||
(entry) => {
|
||||
const value = entry.value
|
||||
if (value.type !== "oauth" || value.methodID !== Integration.MethodID.make("chatgpt-browser"))
|
||||
return Effect.void
|
||||
const metadata = Option.getOrUndefined(decodeMetadata(value.metadata))
|
||||
if (!metadata) return Effect.void
|
||||
return Effect.gen(function* () {
|
||||
const saved = yield* ctx.storage.get(modelCacheKey(metadata.clientID))
|
||||
if (!Option.isSome(decodeCachedModels(saved)) && metadata.models)
|
||||
yield* ctx.storage.set(modelCacheKey(metadata.clientID), metadata.models)
|
||||
const { models: _, ...rest } = value.metadata ?? {}
|
||||
yield* credentials.update(entry.id, {
|
||||
value: Credential.OAuth.make({ ...value, methodID, metadata: rest }),
|
||||
})
|
||||
})
|
||||
},
|
||||
{ discard: true },
|
||||
)
|
||||
yield* load()
|
||||
yield* ctx.session.hook(
|
||||
"retry",
|
||||
(event) =>
|
||||
Effect.sync(() => {
|
||||
if (!chatgpt || !nonRetryableSharingCodes.some((code) => event.error.response?.body.includes(code))) return
|
||||
event.decision = { retry: false }
|
||||
}),
|
||||
{ providerID },
|
||||
)
|
||||
yield* ctx.provider.transform((providers) => {
|
||||
const item = providers.get(providerID)
|
||||
if (!item) return
|
||||
if (!chatgpt || !source) return
|
||||
providers.update(providerID, (provider) => {
|
||||
provider.settings = Provider.mergeOverlay(provider.settings, {
|
||||
baseURL: resource,
|
||||
transport: "http",
|
||||
compaction: { type: "summary" },
|
||||
})
|
||||
})
|
||||
const updated = providers.get(providerID)
|
||||
if (!updated) return
|
||||
providers.add({
|
||||
info: updated.provider,
|
||||
models: available
|
||||
? deriveModels(available, Array.from(item.models.values()))
|
||||
: Array.from(item.models.values()).filter((model) => fallbackModels.has(model.id)),
|
||||
sourceConnection: source,
|
||||
})
|
||||
})
|
||||
yield* ctx.model.transform((models) => {
|
||||
for (const model of models.list(providerID)) {
|
||||
models.update(model.providerID, model.id, (draft) => {
|
||||
if (!chatgpt) return
|
||||
// Token sharing does not support native /responses/compact.
|
||||
draft.settings = { ...draft.settings, compaction: { type: "summary" } }
|
||||
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
if (available && !available.some((remote) => remote.slug === (draft.modelID ?? draft.id))) {
|
||||
draft.enabled = false
|
||||
return
|
||||
}
|
||||
draft.cost = []
|
||||
})
|
||||
}
|
||||
})
|
||||
const refreshModels = Effect.fn("ChatGPTPlugin.refreshModels")(function* () {
|
||||
const connection = source
|
||||
const credential =
|
||||
connection?.type === "credential"
|
||||
? (yield* credentials.get(Credential.ID.make(connection.id)))?.value
|
||||
: undefined
|
||||
if (credential?.type !== "oauth" || credential.methodID !== methodID || credential.expires <= Date.now() + 60_000)
|
||||
return
|
||||
const metadata = Option.getOrUndefined(decodeMetadata(credential.metadata))
|
||||
if (!metadata) return
|
||||
const models = yield* fetchModels(credential.access, ctx.app).pipe(
|
||||
Effect.timeout(15_000),
|
||||
Effect.catch(() => Effect.logWarning("failed to refresh ChatGPT models").pipe(Effect.as(undefined))),
|
||||
)
|
||||
if (!models) return
|
||||
yield* loading.withPermit(
|
||||
Effect.gen(function* () {
|
||||
if (
|
||||
IntegrationConnection.key(connection) !== IntegrationConnection.key(source) ||
|
||||
IntegrationConnection.key(connection) !==
|
||||
IntegrationConnection.key(yield* ctx.integration.connection.active(integrationID))
|
||||
)
|
||||
return
|
||||
if (JSON.stringify(models) === JSON.stringify(available)) return
|
||||
yield* ctx.storage.set(modelCacheKey(metadata.clientID), models)
|
||||
available = models
|
||||
yield* ctx.provider.reload()
|
||||
}),
|
||||
)
|
||||
})
|
||||
const reload = () =>
|
||||
loading
|
||||
.withPermit(load().pipe(Effect.andThen(ctx.provider.reload())))
|
||||
.pipe(Effect.andThen(refreshModels().pipe(Effect.forkScoped, Effect.asVoid)))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === integrationID),
|
||||
Stream.runForEach(reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refreshModels().pipe(Effect.forkScoped)
|
||||
yield* Effect.sleep(Duration.minutes(15)).pipe(Effect.andThen(refreshModels), Effect.forever, Effect.forkScoped)
|
||||
}),
|
||||
} satisfies PluginInternal.InternalPlugin)
|
||||
|
||||
function headers(app: App.Info) {
|
||||
return { "Content-Type": "application/x-www-form-urlencoded", "User-Agent": App.useragent(app) }
|
||||
}
|
||||
|
||||
function exchange(code: string, clientID: string, redirect: string, pkce: Pkce, app: App.Info) {
|
||||
return request<TokenResponse>(tokenURL, {
|
||||
method: "POST",
|
||||
headers: headers(app),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: clientID,
|
||||
code,
|
||||
code_verifier: pkce.verifier,
|
||||
redirect_uri: redirect,
|
||||
resource,
|
||||
}).toString(),
|
||||
})
|
||||
}
|
||||
|
||||
function refresh(value: Credential.OAuth, app: App.Info) {
|
||||
return Effect.gen(function* () {
|
||||
const metadata = Option.getOrUndefined(decodeMetadata(value.metadata))
|
||||
if (!metadata)
|
||||
return yield* Effect.fail(new Error("This ChatGPT connection has no registered client ID. Connect again."))
|
||||
const tokens = yield* request<TokenResponse>(tokenURL, {
|
||||
method: "POST",
|
||||
headers: headers(app),
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
client_id: metadata.clientID,
|
||||
refresh_token: value.refresh,
|
||||
resource,
|
||||
}).toString(),
|
||||
})
|
||||
return credential(tokens, metadata.clientID, metadata.scopes)
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchModels(token: string, app: App.Info, baseURL = resource) {
|
||||
return Effect.gen(function* () {
|
||||
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
|
||||
const response = yield* http.execute(
|
||||
HttpClientRequest.get(`${baseURL}/models`).pipe(
|
||||
HttpClientRequest.bearerToken(token),
|
||||
HttpClientRequest.setHeader("User-Agent", App.useragent(app)),
|
||||
),
|
||||
)
|
||||
const data = yield* HttpClientResponse.schemaBodyJson(RemoteModels)(response)
|
||||
const models = data.models.filter((model) => model.visibility === "list" && model.supported_in_api)
|
||||
if (!models.length) return yield* Effect.fail(new Error("No ChatGPT models are available for this account."))
|
||||
return models
|
||||
})
|
||||
}
|
||||
|
||||
export function deriveModels(remote: ReadonlyArray<RemoteModel>, existing: ReadonlyArray<Model.Info>) {
|
||||
const byID = new Map(remote.map((model) => [model.slug, model]))
|
||||
const known = new Set(existing.map((model) => model.id))
|
||||
return [
|
||||
...existing.flatMap((model) => {
|
||||
const found = byID.get(model.modelID)
|
||||
return found ? [deriveModel(found, model)] : []
|
||||
}),
|
||||
...remote
|
||||
.filter((model) => !known.has(Model.ID.make(model.slug)))
|
||||
.map((model) => deriveModel(model, Model.Info.default(Provider.ID.openai, Model.ID.make(model.slug)))),
|
||||
]
|
||||
}
|
||||
|
||||
function deriveModel(remote: RemoteModel, previous: Model.Info): Model.Info {
|
||||
return {
|
||||
...previous,
|
||||
name: previous.id === previous.modelID ? remote.display_name : previous.name,
|
||||
package: previous.package ?? "@opencode/ai/providers/openai",
|
||||
capabilities: {
|
||||
...previous.capabilities,
|
||||
input: remote.input_modalities.filter((modality) => modality === "text" || modality === "image"),
|
||||
},
|
||||
variants: [
|
||||
...previous.variants.filter((variant) => typeof variant.settings?.reasoningEffort !== "string"),
|
||||
...(remote.supported_reasoning_levels ?? []).map(
|
||||
({ effort }) =>
|
||||
previous.variants.find(
|
||||
(variant) => variant.id === effort && variant.settings?.reasoningEffort === effort,
|
||||
) ?? { id: Model.VariantID.make(effort), settings: { reasoningEffort: effort } },
|
||||
),
|
||||
],
|
||||
limit: { context: remote.context_window, output: previous.limit.output },
|
||||
}
|
||||
}
|
||||
|
||||
export function verifyIDToken(
|
||||
token: string,
|
||||
clientID: string,
|
||||
nonce: string,
|
||||
jwksURL = new URL(`${issuer}/.well-known/jwks.json`),
|
||||
) {
|
||||
return Effect.tryPromise({
|
||||
try: async () => {
|
||||
const { createRemoteJWKSet, jwtVerify } = await import("jose")
|
||||
const { payload } = await jwtVerify(token, createRemoteJWKSet(jwksURL), {
|
||||
issuer,
|
||||
audience: clientID,
|
||||
algorithms: ["RS256"],
|
||||
requiredClaims: ["exp", "nonce", "sub"],
|
||||
})
|
||||
if (typeof payload.sub !== "string" || !payload.sub.trim()) throw new Error("ID token subject is missing")
|
||||
if (payload.nonce !== nonce) throw new Error("ID token nonce does not match this sign-in attempt")
|
||||
},
|
||||
catch: (cause) => new Error("ChatGPT sign-in returned an invalid ID token.", { cause }),
|
||||
})
|
||||
}
|
||||
|
||||
function request<A>(url: string, init: RequestInit) {
|
||||
return Effect.tryPromise({
|
||||
try: async (signal) => {
|
||||
const response = await fetch(url, { ...init, signal })
|
||||
if (!response.ok) throw new Error(`Request failed: ${response.status}`)
|
||||
return response.json() as Promise<A>
|
||||
},
|
||||
catch: (cause) => cause,
|
||||
})
|
||||
}
|
||||
|
||||
function credential(tokens: TokenResponse, clientID: string, scopes?: ReadonlyArray<string>) {
|
||||
return Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID,
|
||||
refresh: tokens.refresh_token,
|
||||
access: tokens.access_token,
|
||||
expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
|
||||
metadata: { clientID, scopes: tokens.scope?.split(" ").filter(Boolean) ?? scopes ?? [] },
|
||||
})
|
||||
}
|
||||
|
||||
async function generatePKCE(): Promise<Pkce> {
|
||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
const verifier = Array.from(crypto.getRandomValues(new Uint8Array(43)), (byte) => chars[byte % chars.length]).join("")
|
||||
const challenge = base64UrlEncode(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))
|
||||
return { verifier, challenge }
|
||||
}
|
||||
|
||||
function randomValue() {
|
||||
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer)
|
||||
}
|
||||
|
||||
function base64UrlEncode(buffer: ArrayBuffer) {
|
||||
return Buffer.from(buffer).toString("base64url")
|
||||
}
|
||||
|
||||
function authorizeURL(
|
||||
redirect: string,
|
||||
pkce: Pkce,
|
||||
state: string,
|
||||
nonce: string,
|
||||
savedID: string | undefined,
|
||||
hostID: string,
|
||||
) {
|
||||
return `${issuer}/api/accounts/authorize?${new URLSearchParams({
|
||||
client_id: savedID ?? registrationClientID,
|
||||
...(savedID ? {} : { agent_name_hint: agentName }),
|
||||
ext_agent_host_id: hostID,
|
||||
// Enable only for user-requested consent retries after OpenAI confirms deployment;
|
||||
// ordinary sign-ins must not force reconsent.
|
||||
// force_reconsent: "true",
|
||||
response_type: "code",
|
||||
redirect_uri: redirect,
|
||||
scope: `openid profile email offline_access resource.invoke ${tokenSharingScope}`,
|
||||
resource,
|
||||
state,
|
||||
nonce,
|
||||
code_challenge_method: "S256",
|
||||
code_challenge: pkce.challenge,
|
||||
})}`
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Duration, Effect, Equal, Schema, Semaphore, Stream } from "effect"
|
||||
import type { Scope } from "effect"
|
||||
import { Duration, Effect, Equal, Option, Schema, Scope, Semaphore, Stream } from "effect"
|
||||
import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionHttpResponse } from "@opencode/plugin/effect/session"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { App } from "../../app.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
@@ -55,6 +55,34 @@ const TokenPending = Schema.Struct({ error: Schema.String })
|
||||
const DeviceToken = Schema.Union([Token, TokenPending])
|
||||
const User = Schema.Struct({ id: Schema.String, email: Schema.String })
|
||||
const Org = Schema.Struct({ id: Schema.String, name: Schema.String })
|
||||
// Console's 403 when the organization enforces SSO and the user's SSO proof is missing or expired.
|
||||
const SsoRequired = Schema.Struct({
|
||||
_tag: Schema.Literal("SsoRequired"),
|
||||
orgId: Schema.String,
|
||||
connectionId: Schema.String,
|
||||
})
|
||||
const InvalidGrant = Schema.Struct({ error: Schema.Literal("invalid_grant") })
|
||||
// Console's 401 when the session behind the access token was revoked or expired.
|
||||
const Unauthorized = Schema.Struct({ _tag: Schema.Literal("Unauthorized") })
|
||||
const signedOutMessage = "Reconnect OpenCode Console to continue"
|
||||
const ssoMessage = (organization: string | undefined) =>
|
||||
`Sign in with SSO again to use ${organization ?? "your OpenCode Console organization"}`
|
||||
|
||||
class SsoRequiredError extends Schema.TaggedError<SsoRequiredError>()("OpencodeConsole.SsoRequired", {
|
||||
organization: Schema.optional(Schema.String),
|
||||
url: Schema.String,
|
||||
}) {
|
||||
override get message() {
|
||||
return `${ssoMessage(this.organization)}.`
|
||||
}
|
||||
}
|
||||
|
||||
// The Console session or refresh token was revoked or expired; only a new device login recovers.
|
||||
class SignedOutError extends Schema.TaggedError<SignedOutError>()("OpencodeConsole.SignedOut", {}) {
|
||||
override get message() {
|
||||
return `${signedOutMessage}.`
|
||||
}
|
||||
}
|
||||
|
||||
function oauth(http: HttpClient.HttpClient) {
|
||||
return {
|
||||
@@ -100,11 +128,17 @@ function oauth(http: HttpClient.HttpClient) {
|
||||
}),
|
||||
refresh: (credential) =>
|
||||
Effect.gen(function* () {
|
||||
const token = yield* post(
|
||||
http,
|
||||
`${serverUrl(credential)}/auth/device/token`,
|
||||
{ grant_type: "refresh_token", refresh_token: credential.refresh, client_id: clientID },
|
||||
Token,
|
||||
const response = yield* send(http, `${serverUrl(credential)}/auth/device/token`, {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: credential.refresh,
|
||||
client_id: clientID,
|
||||
})
|
||||
if (response.status === 400) {
|
||||
const body = yield* HttpClientResponse.schemaBodyJson(InvalidGrant)(response).pipe(Effect.option)
|
||||
if (Option.isSome(body)) return yield* new SignedOutError()
|
||||
}
|
||||
const token = yield* HttpClientResponse.filterStatusOk(response).pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(Token)),
|
||||
)
|
||||
// Persist rotated tokens without depending on discovery requests.
|
||||
return {
|
||||
@@ -138,6 +172,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
// and whether it evaluates the policies it is being sent.
|
||||
const http = HttpClient.mapRequest(client, HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app)))
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
|
||||
let snapshot: {
|
||||
@@ -149,10 +184,20 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
| { servers: NonNullable<typeof RemoteResponse.Type.mcp>["servers"]; headers: Record<string, string> }
|
||||
| undefined
|
||||
} = { config: undefined, connection: undefined, organization: undefined, mcp: undefined }
|
||||
// Status last reported for the active connection, so inference failures can name the fix.
|
||||
let reported: IntegrationConnection.Status | undefined
|
||||
let explainers: Effect.Success<ReturnType<typeof ctx.session.hook>>[] = []
|
||||
|
||||
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("opencode")
|
||||
if (!connection) return { config: undefined, connection, organization: undefined, mcp: undefined }
|
||||
if (!connection) {
|
||||
reported = undefined
|
||||
return { config: undefined, connection, organization: undefined, mcp: undefined }
|
||||
}
|
||||
const status = (status: IntegrationConnection.Status | undefined) => {
|
||||
reported = status
|
||||
return ctx.integration.connection.status({ integrationID: "opencode", connection, status })
|
||||
}
|
||||
return yield* ctx.integration.connection.resolve(connection).pipe(
|
||||
Effect.flatMap((credential) => {
|
||||
if (!credential)
|
||||
@@ -161,21 +206,24 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
Effect.map((config) => ({
|
||||
config,
|
||||
connection,
|
||||
organization: typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined,
|
||||
organization: organizationName(credential),
|
||||
mcp: config?.mcp && { servers: config.mcp.servers, headers: credentialHeaders(credential) },
|
||||
})),
|
||||
)
|
||||
}),
|
||||
Effect.tap(() => status(undefined)),
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(
|
||||
Effect.gen(function* () {
|
||||
// Only well-formed Console errors change the connection status; anything else may be transient.
|
||||
const next = connectionStatus(cause)
|
||||
if (next) yield* status(next)
|
||||
yield* Effect.logWarning("failed to load OpenCode provider config", { cause })
|
||||
// A load that fails for the connection already in place keeps its last config: dropping it
|
||||
// would lift organization policy while personal credentials keep working.
|
||||
Effect.as(
|
||||
IntegrationConnection.key(connection) === IntegrationConnection.key(snapshot.connection)
|
||||
? { config: snapshot.config, connection, organization: snapshot.organization, mcp: snapshot.mcp }
|
||||
: { config: undefined, connection, organization: undefined, mcp: undefined },
|
||||
),
|
||||
),
|
||||
return IntegrationConnection.key(connection) === IntegrationConnection.key(snapshot.connection)
|
||||
? { config: snapshot.config, connection, organization: snapshot.organization, mcp: snapshot.mcp }
|
||||
: { config: undefined, connection, organization: undefined, mcp: undefined }
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -367,12 +415,47 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
|
||||
snapshot = next
|
||||
yield* publish(next)
|
||||
yield* guard(next)
|
||||
yield* Effect.all([ctx.provider.reload(), ctx.websearch.reload(), ctx.mcp.reload()], {
|
||||
concurrency: 3,
|
||||
discard: true,
|
||||
})
|
||||
})
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
|
||||
// Annotated: `apply` re-registers the hooks that call `check`, which would otherwise make inference circular.
|
||||
const check = (): Effect.Effect<void> =>
|
||||
loading.withPermit(
|
||||
load().pipe(Effect.flatMap((next) => (Equal.equals(snapshot, next) ? Effect.void : apply(next)))),
|
||||
)
|
||||
|
||||
// Console inference rejects a missing SSO sign-in with a generic 401/403. Re-check the Console config, which
|
||||
// reports the reason, and replace the response with instructions when the connection needs the user.
|
||||
const explain = (event: SessionHttpResponse) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.response.status !== 401 && event.response.status !== 403) return
|
||||
yield* check()
|
||||
const status = reported
|
||||
if (!status) return
|
||||
void event.response.body?.cancel()
|
||||
event.response = Response.json(
|
||||
{
|
||||
error: {
|
||||
type: "authentication_error",
|
||||
message: `${status.message}.`,
|
||||
},
|
||||
},
|
||||
{ status: event.response.status },
|
||||
)
|
||||
})
|
||||
// Hooks are scoped per provider so requests to unrelated providers skip the HTTP hook path.
|
||||
const guard = Effect.fn("OpencodePlugin.guard")(function* (next: typeof snapshot) {
|
||||
yield* Effect.forEach(explainers, (registration) => registration.dispose, { discard: true })
|
||||
explainers = yield* Effect.forEach(
|
||||
next.connection ? new Set([Provider.ID.opencode, ...Object.keys(next.config?.providers ?? {})]) : [],
|
||||
(providerID) => ctx.session.hook("http.response", explain, { providerID }).pipe(Scope.provide(scope)),
|
||||
)
|
||||
})
|
||||
yield* guard(snapshot)
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("opencode")),
|
||||
Stream.runForEach(refresh),
|
||||
@@ -382,11 +465,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
// Console config can change independently of local credential activity, so re-fetch
|
||||
// periodically and only rebuild the catalog and search providers when the snapshot differs.
|
||||
yield* Effect.sleep(Duration.minutes(1)).pipe(
|
||||
Effect.andThen(
|
||||
loading.withPermit(
|
||||
load().pipe(Effect.flatMap((next) => (Equal.equals(snapshot, next) ? Effect.void : apply(next)))),
|
||||
),
|
||||
),
|
||||
Effect.andThen(check()),
|
||||
Effect.forever,
|
||||
Effect.forkScoped,
|
||||
)
|
||||
@@ -402,15 +481,58 @@ function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
),
|
||||
)
|
||||
.pipe(
|
||||
Effect.flatMap((response) => {
|
||||
if (response.status === 404) return Effect.undefined
|
||||
return HttpClientResponse.filterStatusOk(response).pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
|
||||
)
|
||||
}),
|
||||
Effect.flatMap((response) =>
|
||||
Effect.gen(function* () {
|
||||
if (response.status === 404) return undefined
|
||||
if (response.status === 401) {
|
||||
const body = yield* HttpClientResponse.schemaBodyJson(Unauthorized)(response).pipe(Effect.option)
|
||||
if (Option.isSome(body)) return yield* new SignedOutError()
|
||||
}
|
||||
if (response.status === 403) {
|
||||
const body = yield* HttpClientResponse.schemaBodyJson(SsoRequired)(response).pipe(Effect.option)
|
||||
if (Option.isSome(body))
|
||||
return yield* new SsoRequiredError({
|
||||
organization: organizationName(value),
|
||||
url: yield* ssoUrl(serverUrl(value), body.value.connectionId),
|
||||
})
|
||||
}
|
||||
return yield* HttpClientResponse.filterStatusOk(response).pipe(
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function ssoUrl(server: string, connectionID: string) {
|
||||
return normalizeServer(server).pipe(
|
||||
Effect.map((base) => {
|
||||
// Console only redirects to paths under its own base path after SSO.
|
||||
const redirectTo = `${new URL(base).pathname.replace(/\/+$/, "")}/`
|
||||
return `${base}/auth/sso/${encodeURIComponent(connectionID)}/start?redirectTo=${encodeURIComponent(redirectTo)}`
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function connectionStatus(cause: unknown): IntegrationConnection.Status | undefined {
|
||||
if (cause instanceof SsoRequiredError)
|
||||
return {
|
||||
status: "needs_auth",
|
||||
message: ssoMessage(cause.organization),
|
||||
url: cause.url,
|
||||
}
|
||||
if (
|
||||
cause instanceof SignedOutError ||
|
||||
(cause instanceof Integration.AuthorizationError && cause.cause instanceof SignedOutError)
|
||||
)
|
||||
return { status: "needs_auth", message: signedOutMessage }
|
||||
return undefined
|
||||
}
|
||||
|
||||
function organizationName(credential: Credential.Value) {
|
||||
return typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined
|
||||
}
|
||||
|
||||
function credentialHeaders(value: Credential.Value): Record<string, string> {
|
||||
const orgID = value.metadata?.orgID
|
||||
return {
|
||||
@@ -514,11 +636,16 @@ function post<S extends Schema.Top>(
|
||||
schema: S,
|
||||
statusOk = true,
|
||||
) {
|
||||
return HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean])))(body),
|
||||
Effect.flatMap((request) => http.execute(request)),
|
||||
return send(http, url, body).pipe(
|
||||
Effect.flatMap((response) => (statusOk ? HttpClientResponse.filterStatusOk(response) : Effect.succeed(response))),
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(schema)),
|
||||
)
|
||||
}
|
||||
|
||||
function send(http: HttpClient.HttpClient, url: string, body: Record<string, string | boolean>) {
|
||||
return HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.schemaBodyJson(Schema.Record(Schema.String, Schema.Union([Schema.String, Schema.Boolean])))(body),
|
||||
Effect.flatMap((request) => http.execute(request)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,24 @@ import { AgentNotFoundError, StepFailedError } from "./error.js"
|
||||
import { ModelResolver } from "../model-resolver.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
|
||||
const tokenSharingMessages = {
|
||||
subscription_sharing_user_not_eligible:
|
||||
"ChatGPT token sharing isn't available for this account. Connect with an API key or choose another provider.",
|
||||
subscription_sharing_usage_limit_exceeded:
|
||||
"ChatGPT usage limit reached. Try again after your allowance resets; check ChatGPT Settings → Usage for details.",
|
||||
subscription_sharing_usage_unavailable: "ChatGPT usage can't be checked right now. Try again later.",
|
||||
subscription_sharing_unsupported_capability:
|
||||
"This request uses a feature ChatGPT token sharing doesn't support. Remove the unsupported feature and try again.",
|
||||
subscription_sharing_route_not_supported:
|
||||
"ChatGPT token sharing doesn't support this API route. Check the configured endpoint and HTTP method.",
|
||||
subscription_sharing_invalid_user: "This ChatGPT connection is no longer valid. Reconnect to ChatGPT.",
|
||||
subscription_sharing_user_unavailable: "Your ChatGPT account is temporarily unavailable. Try again later.",
|
||||
chatpass_v2_scope_not_authorized:
|
||||
"This ChatGPT connection isn't authorized for this request. Reconnect to ChatGPT or choose another connection.",
|
||||
chatpass_v2_invalid_authorization_context:
|
||||
"This ChatGPT connection isn't authorized for this request. Reconnect to ChatGPT or choose another connection.",
|
||||
}
|
||||
|
||||
export function toSessionError(cause: unknown): SessionError.Error {
|
||||
if (cause instanceof AIError) {
|
||||
switch (cause.reason._tag) {
|
||||
@@ -41,6 +59,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
}
|
||||
}
|
||||
if (cause instanceof Permission.BlockedError) return { type: "permission.rejected", message: cause.message }
|
||||
if (cause instanceof Permission.CorrectedError) return { type: "permission.rejected", message: cause.feedback }
|
||||
if (cause instanceof ToolFailure || cause instanceof Tool.Error) {
|
||||
if (cause.error === undefined) return { type: "tool.execution", message: cause.message }
|
||||
// The canonical error is the sole model-visible representation, so a cause
|
||||
@@ -68,5 +87,10 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
|
||||
function providerError(type: string, reason: AIError["reason"]): SessionError.Error {
|
||||
const status = reason.http?.status
|
||||
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
|
||||
return {
|
||||
type,
|
||||
message: Object.entries(tokenSharingMessages).find(([code]) => reason.body?.includes(code))?.[1] ?? reason.message,
|
||||
...(status === undefined ? {} : { status }),
|
||||
...(reason.body === undefined ? {} : { response: { body: reason.body } }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +160,25 @@ const nvidiaChat: Protocol = (model, support) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Workers AI ignores or rejects `reasoning_effort: "none"` on most models. Kimi's chat template reads
|
||||
// `thinking`; the others read `enable_thinking`, so both are sent.
|
||||
const workersAITemplate = (thinking: boolean): Overlay => ({
|
||||
body: { chat_template_kwargs: { enable_thinking: thinking, thinking } },
|
||||
})
|
||||
|
||||
const workersAIChat: Protocol = (_, support) => {
|
||||
switch (support.type) {
|
||||
case "effort":
|
||||
return efforts(support.values ?? EFFORTS, (effort) =>
|
||||
effort === "none" ? workersAITemplate(false) : { settings: { reasoningEffort: effort } },
|
||||
)
|
||||
case "toggle":
|
||||
return toggle(workersAITemplate(false), workersAITemplate(true))
|
||||
case "budget_tokens":
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const basetenTemplate = (enable_thinking: boolean): Overlay => ({
|
||||
body: { chat_template_args: { enable_thinking } },
|
||||
})
|
||||
@@ -552,7 +571,7 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
|
||||
"@opencode/ai/providers/alibaba/chat": alibabaChat,
|
||||
"@opencode/ai/providers/baseten": basetenChat,
|
||||
"@opencode/ai/providers/cerebras": openaiChat,
|
||||
"@opencode/ai/providers/cloudflare-workers-ai": openaiChat,
|
||||
"@opencode/ai/providers/cloudflare-workers-ai": workersAIChat,
|
||||
"@opencode/ai/providers/deepinfra": deepinfraChat,
|
||||
"@opencode/ai/providers/deepseek": deepseekChat,
|
||||
"@opencode/ai/providers/fireworks": openaiChat,
|
||||
|
||||
@@ -199,4 +199,49 @@ describe("Credential", () => {
|
||||
expect((yield* credentials.list(otherIntegrationID)).at(-1)).toEqual(otherOlder)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates credentials with a requested ID without taking over the current selection", () =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const bus = yield* Bus.Service
|
||||
const database = yield* Database.Service
|
||||
const integrationID = Integration.ID.make("openai")
|
||||
const events = new Array<Event.Payload>()
|
||||
yield* bus.listen((event) => Effect.sync(() => events.push(event)))
|
||||
|
||||
const first = yield* credentials.create({
|
||||
id: Credential.ID.make("cred_0000imported"),
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "first" }),
|
||||
activate: false,
|
||||
})
|
||||
expect(first.id).toBe(Credential.ID.make("cred_0000imported"))
|
||||
expect((yield* credentials.list(integrationID)).at(-1)).toEqual(first)
|
||||
|
||||
const legacy = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "legacy" }),
|
||||
})
|
||||
yield* database.db
|
||||
.update(CredentialTable)
|
||||
.set({ active: null })
|
||||
.where(eq(CredentialTable.integration_id, integrationID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const imported = yield* credentials.create({
|
||||
integrationID,
|
||||
value: Credential.Key.make({ type: "key", key: "imported" }),
|
||||
activate: false,
|
||||
})
|
||||
|
||||
expect(yield* credentials.list(integrationID)).toEqual([first, imported, legacy])
|
||||
expect(events.map((event) => ({ type: event.type, data: event.data }))).toEqual([
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: first.id } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
{ type: Credential.Event.Switched.type, data: { integrationID, credentialID: legacy.id } },
|
||||
{ type: Credential.Event.Updated.type, data: {} },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { chmod, stat, writeFile } from "node:fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
|
||||
// Read while the connection is open: SQLite may remove the sidecars on close.
|
||||
const openModes = (filename: string) =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* Layer.build(Database.layer({ path: filename }))
|
||||
return yield* Effect.promise(() =>
|
||||
Promise.all(["", "-wal", "-shm"].map(async (suffix) => (await stat(filename + suffix)).mode & 0o777)),
|
||||
)
|
||||
}).pipe(Effect.scoped, Effect.provideService(Global.Service, Global.make({ data: path.dirname(filename) }))),
|
||||
)
|
||||
|
||||
describe.skipIf(process.platform === "win32")("Database file permissions", () => {
|
||||
test("creates the database and sidecars readable only by the owner", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
expect(await openModes(path.join(tmp.path, "opencode.db"))).toEqual([0o600, 0o600, 0o600])
|
||||
})
|
||||
|
||||
test("tightens an existing database and sidecars", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filename = path.join(tmp.path, "opencode.db")
|
||||
await Promise.all(
|
||||
["", "-wal", "-shm"].map(async (suffix) => {
|
||||
await writeFile(filename + suffix, "")
|
||||
await chmod(filename + suffix, 0o644)
|
||||
}),
|
||||
)
|
||||
|
||||
expect(await openModes(filename)).toEqual([0o600, 0o600, 0o600])
|
||||
})
|
||||
})
|
||||
@@ -35,6 +35,7 @@ const integrations = Layer.mock(Integration.Service, {
|
||||
activate: () => Effect.die("unused"),
|
||||
update: () => Effect.die("unused"),
|
||||
remove: () => Effect.die("unused"),
|
||||
status: () => Effect.die("unused"),
|
||||
},
|
||||
oauth: {
|
||||
connect: () => Effect.die("unused"),
|
||||
|
||||
@@ -333,6 +333,7 @@ function resourceMcpLayer(
|
||||
activate: unusedIntegration,
|
||||
update: unusedIntegration,
|
||||
remove: unusedIntegration,
|
||||
status: unusedIntegration,
|
||||
},
|
||||
oauth: {
|
||||
connect: unusedIntegration,
|
||||
|
||||
@@ -375,6 +375,7 @@ describe("ModelResolver", () => {
|
||||
activate: () => Effect.die("unused"),
|
||||
update: () => Effect.die("unused"),
|
||||
remove: () => Effect.die("unused"),
|
||||
status: () => Effect.die("unused"),
|
||||
},
|
||||
oauth: {
|
||||
connect: () => Effect.die("unused"),
|
||||
|
||||
@@ -95,6 +95,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
connection: {
|
||||
active: () => Effect.die("unused integration.connection.active"),
|
||||
resolve: () => Effect.die("unused integration.connection.resolve"),
|
||||
status: () => Effect.die("unused integration.connection.status"),
|
||||
},
|
||||
},
|
||||
mcp: overrides.mcp ?? {
|
||||
@@ -313,6 +314,15 @@ export function integrationHost(integration: Integration.Interface): Plugin.Cont
|
||||
integration.connection.resolve(
|
||||
connection.type === "credential" ? { ...connection, id: Credential.ID.make(connection.id) } : connection,
|
||||
),
|
||||
status: (input) =>
|
||||
integration.connection.status({
|
||||
integrationID: Integration.ID.make(input.integrationID),
|
||||
connection:
|
||||
input.connection.type === "credential"
|
||||
? { ...input.connection, id: Credential.ID.make(input.connection.id) }
|
||||
: input.connection,
|
||||
status: input.status,
|
||||
}),
|
||||
},
|
||||
transform: (callback) =>
|
||||
integration.transform((editor) =>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ import { Money } from "@opencode/schema/money"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { App } from "@opencode/core/app"
|
||||
import { Config } from "@opencode/core/config"
|
||||
import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy"
|
||||
@@ -16,9 +17,11 @@ import { Mcp } from "@opencode/core/mcp/index"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { ModelResolver } from "@opencode/core/model-resolver"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { WebSearch } from "@opencode/core/websearch"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { emptyMcp } from "../fixture/mcp"
|
||||
@@ -900,6 +903,266 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("reports Console SSO sign-in, keeps the last config, and clears once Console accepts again", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { sso: false }
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => {
|
||||
if (state.sso)
|
||||
return Response.json(
|
||||
{ _tag: "SsoRequired", orgId: "org_acme", connectionId: "ssoconn/1" },
|
||||
{ status: 403 },
|
||||
)
|
||||
return Response.json({
|
||||
providers: {},
|
||||
experimental: { policies: [{ action: "permission", resource: "shell:sudo *", effect: "deny" }] },
|
||||
})
|
||||
},
|
||||
})
|
||||
return { server, state }
|
||||
}),
|
||||
({ server, state }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
const status = () =>
|
||||
integrations
|
||||
.get(Integration.ID.make("opencode"))
|
||||
.pipe(Effect.map((integration) => integration?.connections[0]?.status))
|
||||
const sudo: ConfigPolicy.Info = { action: "permission", resource: "shell:sudo *", effect: "deny" }
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
metadata: { server: `${server.url.origin}/console`, orgID: "org_acme", orgName: "Acme" },
|
||||
}),
|
||||
})
|
||||
yield* addPlugin()
|
||||
yield* drain
|
||||
expect(yield* status()).toBeUndefined()
|
||||
|
||||
state.sso = true
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
const url = `${server.url.origin}/console/auth/sso/ssoconn%2F1/start?redirectTo=%2Fconsole%2F`
|
||||
expect(yield* status()).toEqual({ status: "needs_auth", message: "Sign in with SSO again to use Acme", url })
|
||||
expect(managed.current()).toEqual({ statements: [sudo], organization: "Acme" })
|
||||
|
||||
state.sso = false
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(yield* status()).toBeUndefined()
|
||||
expect(managed.current()).toEqual({ statements: [sudo], organization: "Acme" })
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("explains rejected Console inference with the sign-in the connection needs", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { sso: false }
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () => {
|
||||
if (state.sso)
|
||||
return Response.json(
|
||||
{ _tag: "SsoRequired", orgId: "org_acme", connectionId: "ssoconn_1" },
|
||||
{ status: 403 },
|
||||
)
|
||||
return Response.json({ providers: { remote: { canonical: "openai" } } })
|
||||
},
|
||||
})
|
||||
return { server, state }
|
||||
}),
|
||||
({ server, state }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
metadata: { server: server.url.origin, orgID: "org_acme", orgName: "Acme" },
|
||||
}),
|
||||
})
|
||||
yield* addPlugin()
|
||||
const rejected = (providerID: Provider.ID) =>
|
||||
hooks
|
||||
.trigger("session", "http.response", {
|
||||
sessionID: Session.ID.make("ses_console"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID, id: Model.ID.make("model") }),
|
||||
kind: "primary",
|
||||
request: new Request("https://inference.example/v1/responses"),
|
||||
response: Response.json({ error: { message: "Workspace access denied" } }, { status: 403 }),
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap((event) =>
|
||||
Effect.promise(async () => ({ status: event.response.status, body: await event.response.json() })),
|
||||
),
|
||||
)
|
||||
const denied = { status: 403, body: { error: { message: "Workspace access denied" } } }
|
||||
|
||||
expect(yield* rejected(Provider.ID.make("remote"))).toEqual(denied)
|
||||
|
||||
state.sso = true
|
||||
const explained = {
|
||||
status: 403,
|
||||
body: {
|
||||
error: {
|
||||
type: "authentication_error",
|
||||
message: "Sign in with SSO again to use Acme.",
|
||||
},
|
||||
},
|
||||
}
|
||||
expect(yield* rejected(Provider.ID.make("remote"))).toEqual(explained)
|
||||
expect(yield* rejected(Provider.ID.opencode)).toEqual(explained)
|
||||
expect(yield* rejected(Provider.ID.anthropic)).toEqual(denied)
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("ignores 403 responses that are not a Console SSO error", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state: { body: Response | undefined } = { body: undefined }
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: () =>
|
||||
state.body ??
|
||||
Response.json({
|
||||
providers: {},
|
||||
experimental: { policies: [{ action: "permission", resource: "shell:sudo *", effect: "deny" }] },
|
||||
}),
|
||||
})
|
||||
return { server, state }
|
||||
}),
|
||||
({ server, state }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const managed = yield* ManagedPolicy.Service
|
||||
const sudo: ConfigPolicy.Info = { action: "permission", resource: "shell:sudo *", effect: "deny" }
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({ type: "key", key: "secret", metadata: { server: server.url.origin } }),
|
||||
})
|
||||
yield* addPlugin()
|
||||
yield* drain
|
||||
|
||||
for (const body of [
|
||||
() => new Response("<html>Forbidden</html>", { status: 403, headers: { "content-type": "text/html" } }),
|
||||
() => Response.json({ _tag: "Forbidden", orgId: "org", connectionId: "ssoconn" }, { status: 403 }),
|
||||
]) {
|
||||
state.body = body()
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect((yield* integrations.get(Integration.ID.make("opencode")))?.connections[0]?.status).toBeUndefined()
|
||||
expect(managed.current().statements).toEqual([sudo])
|
||||
}
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("reports a rejected Console refresh token as signed out without removing the credential", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
if (new URL(request.url).pathname === "/auth/device/token")
|
||||
return Response.json(
|
||||
{ error: "invalid_grant", error_description: "refresh token revoked" },
|
||||
{ status: 400 },
|
||||
)
|
||||
return Response.json({ providers: {} })
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const credential = yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "expired",
|
||||
refresh: "revoked",
|
||||
expires: 0,
|
||||
metadata: { server: server.url.origin, orgID: "org_acme", orgName: "Acme" },
|
||||
}),
|
||||
})
|
||||
yield* addPlugin()
|
||||
yield* drain
|
||||
|
||||
expect((yield* integrations.get(Integration.ID.make("opencode")))?.connections[0]?.status).toEqual({
|
||||
status: "needs_auth",
|
||||
message: "Reconnect OpenCode Console to continue",
|
||||
})
|
||||
expect((yield* credentials.get(credential.id))?.value).toMatchObject({ refresh: "revoked" })
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("reports a Console session rejected before its token expires as signed out", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { revoked: true, refreshes: 0 }
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) => {
|
||||
if (new URL(request.url).pathname === "/auth/device/token") state.refreshes++
|
||||
if (state.revoked)
|
||||
return Response.json({ _tag: "Unauthorized", message: "Invalid or expired session" }, { status: 401 })
|
||||
return Response.json({ providers: {} })
|
||||
},
|
||||
})
|
||||
return { server, state }
|
||||
}),
|
||||
({ server, state }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const credential = yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.OAuth.make({
|
||||
type: "oauth",
|
||||
methodID: Integration.MethodID.make("device"),
|
||||
access: "revoked",
|
||||
refresh: "refresh",
|
||||
expires: Number.MAX_SAFE_INTEGER,
|
||||
metadata: { server: server.url.origin, orgID: "org_acme", orgName: "Acme" },
|
||||
}),
|
||||
})
|
||||
const status = () =>
|
||||
integrations.get(Integration.ID.make("opencode")).pipe(Effect.map((item) => item?.connections[0]?.status))
|
||||
yield* addPlugin()
|
||||
yield* drain
|
||||
|
||||
expect(yield* status()).toEqual({ status: "needs_auth", message: "Reconnect OpenCode Console to continue" })
|
||||
expect((yield* credentials.get(credential.id))?.value).toMatchObject({ access: "revoked" })
|
||||
|
||||
state.revoked = false
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(yield* status()).toBeUndefined()
|
||||
expect(state.refreshes).toBe(0)
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("loads and executes hosted web search from the connected OpenCode server", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
|
||||
@@ -110,6 +110,7 @@ describe("toSessionError", () => {
|
||||
type: "provider.invalid-request",
|
||||
message: "too large",
|
||||
status: 413,
|
||||
response: { body: '{"error":"context limit"}' },
|
||||
})
|
||||
expect(
|
||||
toSessionError(
|
||||
@@ -127,6 +128,38 @@ describe("toSessionError", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves provider response body without HTTP status", () => {
|
||||
expect(toSessionError(llm(new RateLimitError({ message: "Slow down", body: '{"error":"rate limit"}' })))).toEqual({
|
||||
type: "provider.rate-limit",
|
||||
message: "Slow down",
|
||||
response: { body: '{"error":"rate limit"}' },
|
||||
})
|
||||
})
|
||||
|
||||
test("maps token-sharing failures while preserving HTTP and stream bodies", () => {
|
||||
const cases = [
|
||||
["subscription_sharing_user_not_eligible", "token sharing isn't available for this account"],
|
||||
["subscription_sharing_usage_limit_exceeded", "ChatGPT usage limit reached"],
|
||||
["subscription_sharing_usage_unavailable", "Try again later"],
|
||||
["subscription_sharing_unsupported_capability", "Remove the unsupported feature"],
|
||||
["subscription_sharing_route_not_supported", "Check the configured endpoint"],
|
||||
["subscription_sharing_invalid_user", "Reconnect to ChatGPT"],
|
||||
["subscription_sharing_user_unavailable", "Try again later"],
|
||||
["chatpass_v2_scope_not_authorized", "isn't authorized for this request"],
|
||||
["chatpass_v2_invalid_authorization_context", "isn't authorized for this request"],
|
||||
] as const
|
||||
for (const [code, guidance] of cases) {
|
||||
for (const body of [
|
||||
JSON.stringify({ error: { code, message: "Request failed" } }),
|
||||
JSON.stringify({ type: "response.failed", response: { error: { code, message: "Request failed" } } }),
|
||||
]) {
|
||||
const error = toSessionError(llm(new UnknownProviderError({ message: "Request failed", body })))
|
||||
expect(error.message).toContain(guidance)
|
||||
expect(error.response).toEqual({ body })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves unresolved provider endpoint errors", () => {
|
||||
const error = new ModelResolver.UnresolvedProviderVariablesError({
|
||||
providerID: Provider.ID.make("cloudflare-workers-ai"),
|
||||
|
||||
@@ -264,6 +264,37 @@ test("caps Alibaba thinking budget variants at 64k", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("spells Workers AI thinking controls through the chat template", () => {
|
||||
const pkg = "@opencode/ai/providers/cloudflare-workers-ai"
|
||||
const off = { body: { chat_template_kwargs: { enable_thinking: false, thinking: false } } }
|
||||
const on = { body: { chat_template_kwargs: { enable_thinking: true, thinking: true } } }
|
||||
expect(
|
||||
resolve(model(pkg, "@cf/deepseek-ai/deepseek-v4-flash-0731"), [
|
||||
{ type: "effort", values: ["none", "low", "high", "max"] },
|
||||
]),
|
||||
).toEqual([
|
||||
{ id: "none", ...off },
|
||||
{ id: "low", settings: { reasoningEffort: "low" } },
|
||||
{ id: "high", settings: { reasoningEffort: "high" } },
|
||||
{ id: "max", settings: { reasoningEffort: "max" } },
|
||||
])
|
||||
expect(resolve(model(pkg, "@cf/zai-org/glm-4.7-flash"), [{ type: "toggle" }])).toEqual([
|
||||
{ id: "none", ...off },
|
||||
{ id: "thinking", ...on },
|
||||
])
|
||||
expect(
|
||||
resolve(model(pkg, "@cf/qwen/qwen3.8-27b"), [
|
||||
{ type: "toggle" },
|
||||
{ type: "effort", values: ["low", "medium", "xhigh"] },
|
||||
]),
|
||||
).toEqual([
|
||||
{ id: "none", ...off },
|
||||
{ id: "low", settings: { reasoningEffort: "low" } },
|
||||
{ id: "medium", settings: { reasoningEffort: "medium" } },
|
||||
{ id: "xhigh", settings: { reasoningEffort: "xhigh" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("spells Chat Completions variants for hosting providers", () => {
|
||||
expect(
|
||||
resolve(model("@opencode/ai/providers/openai-compatible", "deepseek-ai/deepseek-v4-pro", undefined, "nvidia"), [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ConnectionInfo } from "@opencode/client"
|
||||
import type { IntegrationApi } from "@opencode/client/effect/api"
|
||||
import { Connection } from "@opencode/schema/connection"
|
||||
import { Credential } from "@opencode/schema/credential"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
import type { Effect, Scope } from "effect"
|
||||
@@ -92,5 +93,11 @@ export interface IntegrationDomain extends Omit<IntegrationApi<unknown>, "wellkn
|
||||
readonly connection: {
|
||||
readonly active: (integrationID: string) => Effect.Effect<ConnectionInfo | undefined>
|
||||
readonly resolve: (connection: ConnectionInfo) => Effect.Effect<Credential.Value | undefined, unknown>
|
||||
/** Reports a problem with a connection, such as a required sign-in; `undefined` clears it. */
|
||||
readonly status: (input: {
|
||||
readonly integrationID: string
|
||||
readonly connection: ConnectionInfo
|
||||
readonly status: Connection.Status | undefined
|
||||
}) => Effect.Effect<void>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +427,7 @@ export function fromPromise(plugin: Plugin) {
|
||||
connection: {
|
||||
active: (id) => Effect.runPromiseWith(runtime)(host.integration.connection.active(id)),
|
||||
resolve: (connection) => Effect.runPromiseWith(runtime)(host.integration.connection.resolve(connection)),
|
||||
status: (input) => run(host.integration.connection.status(input)),
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ConnectionInfo } from "@opencode/client"
|
||||
import type { IntegrationApi } from "@opencode/client/promise/api"
|
||||
import { Connection } from "@opencode/schema/connection"
|
||||
import { Credential } from "@opencode/schema/credential"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
import type { Transform } from "./registration.js"
|
||||
@@ -87,5 +88,11 @@ export interface IntegrationDomain extends Omit<IntegrationApi, "wellknown"> {
|
||||
readonly connection: {
|
||||
readonly active: (integrationID: string) => Promise<ConnectionInfo | undefined>
|
||||
readonly resolve: (connection: ConnectionInfo) => Promise<Credential.Value | undefined>
|
||||
/** Reports a problem with a connection, such as a required sign-in; `undefined` clears it. */
|
||||
readonly status: (input: {
|
||||
readonly integrationID: string
|
||||
readonly connection: ConnectionInfo
|
||||
readonly status: Connection.Status | undefined
|
||||
}) => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,34 @@
|
||||
import { Credential } from "@opencode/schema/credential"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { ConflictError } from "../errors.js"
|
||||
|
||||
export const CredentialGroup = HttpApiGroup.make("server.credential")
|
||||
.add(
|
||||
HttpApiEndpoint.get("credential.list", "/api/credential", {
|
||||
success: Schema.Struct({ data: Schema.Array(Credential.Entry) }),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "credential.list",
|
||||
summary: "List credentials",
|
||||
description: "List every stored integration credential, including its secret value.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("credential.create", "/api/credential", {
|
||||
payload: Credential.CreateInput,
|
||||
success: Schema.Struct({ data: Credential.Entry }),
|
||||
error: ConflictError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "credential.create",
|
||||
summary: "Create credential",
|
||||
description:
|
||||
"Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.patch("credential.update", "/api/credential/:credentialID", {
|
||||
params: { credentialID: Credential.ID },
|
||||
|
||||
@@ -2,6 +2,18 @@ export * as Connection from "./connection.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Credential } from "./credential.js"
|
||||
import { optional } from "./schema.js"
|
||||
|
||||
/**
|
||||
* Runtime problem an integration reported for a connection. `needs_auth` asks the user to sign in again:
|
||||
* at `url` when set (the credential itself stays valid), otherwise by reconnecting the integration.
|
||||
*/
|
||||
export interface Status extends Schema.Schema.Type<typeof Status> {}
|
||||
export const Status = Schema.Struct({
|
||||
status: Schema.Literals(["needs_auth"]),
|
||||
message: Schema.String,
|
||||
url: optional(Schema.String),
|
||||
}).annotate({ identifier: "Connection.Status" })
|
||||
|
||||
export interface CredentialInfo extends Schema.Schema.Type<typeof CredentialInfo> {}
|
||||
export const CredentialInfo = Schema.Struct({
|
||||
@@ -10,12 +22,14 @@ export const CredentialInfo = Schema.Struct({
|
||||
label: Schema.String,
|
||||
/** How the credential was obtained: a stored key or an OAuth grant. */
|
||||
method: Schema.Literals(["key", "oauth"]),
|
||||
status: optional(Status),
|
||||
}).annotate({ identifier: "Connection.CredentialInfo" })
|
||||
|
||||
export interface EnvInfo extends Schema.Schema.Type<typeof EnvInfo> {}
|
||||
export const EnvInfo = Schema.Struct({
|
||||
type: Schema.Literal("env"),
|
||||
name: Schema.String,
|
||||
status: optional(Status),
|
||||
}).annotate({ identifier: "Connection.EnvInfo" })
|
||||
|
||||
export const Info = Schema.Union([CredentialInfo, EnvInfo])
|
||||
|
||||
@@ -50,3 +50,21 @@ export const Value = Schema.Union([OAuth, Key])
|
||||
.pipe(Schema.toTaggedUnion("type"))
|
||||
.annotate({ identifier: "Credential.Value" })
|
||||
export type Value = Schema.Schema.Type<typeof Value>
|
||||
|
||||
export interface Entry extends Schema.Schema.Type<typeof Entry> {}
|
||||
export const Entry = Schema.Struct({
|
||||
id: ID,
|
||||
integrationID: IntegrationID,
|
||||
label: Schema.String,
|
||||
active: Schema.Boolean,
|
||||
value: Value,
|
||||
}).annotate({ identifier: "Credential.Entry" })
|
||||
|
||||
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
|
||||
export const CreateInput = Schema.Struct({
|
||||
id: optional(ID),
|
||||
integrationID: IntegrationID,
|
||||
label: optional(Schema.String),
|
||||
value: Value,
|
||||
activate: optional(Schema.Boolean),
|
||||
}).annotate({ identifier: "Credential.CreateInput" })
|
||||
|
||||
@@ -8,4 +8,5 @@ export const Error = Schema.Struct({
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
|
||||
response: Schema.Struct({ body: Schema.String }).pipe(optional),
|
||||
}).annotate({ identifier: "Session.StructuredError" })
|
||||
|
||||
@@ -11,6 +11,7 @@ describe("SessionError", () => {
|
||||
test("round trips current and future error types through JSON", () => {
|
||||
const values: SessionError.Error[] = [
|
||||
{ type: "provider.rate-limit", message: "Slow down" },
|
||||
{ type: "provider.rate-limit", message: "Slow down", status: 429, response: { body: '{"error":{}}' } },
|
||||
{ type: "provider.auth", message: "Authentication failed" },
|
||||
{ type: "provider.future-condition", message: "A future provider failure" },
|
||||
{ type: "unknown", message: "Unexpected" },
|
||||
@@ -36,6 +37,9 @@ describe("SessionError", () => {
|
||||
test("rejects missing envelope fields", () => {
|
||||
expect(() => Schema.decodeUnknownSync(SessionError.Error)({ type: "provider.auth" })).toThrow()
|
||||
expect(() => Schema.decodeUnknownSync(SessionError.Error)({ message: "Missing type" })).toThrow()
|
||||
expect(() =>
|
||||
Schema.decodeUnknownSync(SessionError.Error)({ type: "provider.auth", message: "Failed", response: {} }),
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user