mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-28 11:24:43 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be6d424b67 |
@@ -24,10 +24,6 @@ on:
|
||||
description: "Override version (optional)"
|
||||
required: false
|
||||
type: string
|
||||
release_notes:
|
||||
description: "Reviewed V2 release notes for the Discord announcement (optional)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'v2' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
|
||||
|
||||
@@ -657,19 +653,3 @@ jobs:
|
||||
OPENCODE_DESKTOP_DIST: /tmp/desktop
|
||||
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
notify-discord-v2:
|
||||
needs:
|
||||
- version
|
||||
- publish
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2' && needs.version.outputs.release && needs.publish.result == 'success'
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
# Unlike dev, V2 publishes a tag rather than a GitHub Release event.
|
||||
- name: Announce V2 release in Discord
|
||||
uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
release_name: OpenCode V2 ${{ needs.version.outputs.tag }}
|
||||
release_body: ${{ inputs.release_notes }}
|
||||
release_html_url: https://github.com/${{ github.repository }}/tree/${{ needs.version.outputs.tag }}
|
||||
|
||||
@@ -129,9 +129,8 @@ VercelAIGateway.configure().experimental.evaluation("typesafe-ai/jev")
|
||||
|
||||
OpenRouter reads `OPENROUTER_API_KEY`. Vercel reads `AI_GATEWAY_API_KEY`, then `VERCEL_OIDC_TOKEN`.
|
||||
The common API uses `boolean`; System One routes lower it to native `noul`.
|
||||
Choice and score answers include `confidence` when the provider returns it, such as
|
||||
`response.answers.department.confidence`. Score legends remain available in provider metadata, and
|
||||
the provider's rounded probabilities are returned unchanged.
|
||||
Choice and score confidence plus score legends remain available in provider metadata, and the
|
||||
provider's rounded probabilities are returned unchanged.
|
||||
|
||||
## Alibaba Cloud Model Studio
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
// whole policy pass for these — emitting hints would be harmless but pointless.
|
||||
const RESPECTS_INLINE_HINTS = new Set([
|
||||
"anthropic-messages",
|
||||
"anthropic-compatible-messages",
|
||||
"google-vertex-messages",
|
||||
"bedrock-converse",
|
||||
"openrouter",
|
||||
|
||||
@@ -63,7 +63,6 @@ export const ChoiceAnswer = Schema.Struct({
|
||||
type: Schema.Literal("choice"),
|
||||
choice: Schema.String,
|
||||
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
|
||||
confidence: Schema.optional(Probability),
|
||||
})
|
||||
export type ChoiceAnswer = Schema.Schema.Type<typeof ChoiceAnswer>
|
||||
|
||||
@@ -71,7 +70,6 @@ export const ScoreAnswer = Schema.Struct({
|
||||
type: Schema.Literal("score"),
|
||||
score: Schema.Number,
|
||||
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
|
||||
confidence: Schema.optional(Probability),
|
||||
})
|
||||
export type ScoreAnswer = Schema.Schema.Type<typeof ScoreAnswer>
|
||||
|
||||
@@ -94,7 +92,6 @@ export type AnswerFor<Question extends EvaluationQuestion> = Question extends {
|
||||
readonly type: "choice"
|
||||
readonly choice: Extract<keyof Criteria, string>
|
||||
readonly probabilities?: Readonly<Record<Extract<keyof Criteria, string>, number>>
|
||||
readonly confidence?: number
|
||||
}
|
||||
: Question extends { readonly type: "score" }
|
||||
? ScoreAnswer
|
||||
|
||||
@@ -142,37 +142,32 @@ export const model = <Options extends EvaluationOptions = EvaluationOptions>(cfg
|
||||
Effect.mapError((cause) => fail("System One returned an invalid response", cause, text)),
|
||||
)
|
||||
|
||||
const confidence: Record<string, number> = {}
|
||||
const legend: Record<string, Record<string, Schema.Json>> = {}
|
||||
const answers = Object.fromEntries(
|
||||
Object.entries(data.answers).map(([id, answer]): [string, EvaluationAnswer] => {
|
||||
if (answer.type === "noul") return [id, { type: "boolean", probability: answer.noul }]
|
||||
if (answer.type === "choice") {
|
||||
if (answer.confidence !== undefined) confidence[id] = answer.confidence
|
||||
return [
|
||||
id,
|
||||
{
|
||||
type: "choice",
|
||||
choice: answer.choice,
|
||||
probabilities: answer.probabilities,
|
||||
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
|
||||
},
|
||||
]
|
||||
}
|
||||
if (answer.confidence !== undefined) confidence[id] = answer.confidence
|
||||
if (answer.legend !== undefined) legend[id] = answer.legend
|
||||
return [
|
||||
id,
|
||||
{
|
||||
type: "score",
|
||||
score: answer.score,
|
||||
probabilities: answer.probabilities,
|
||||
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
|
||||
},
|
||||
]
|
||||
return [id, { type: "score", score: answer.score, probabilities: answer.probabilities }]
|
||||
}),
|
||||
)
|
||||
const meta = {
|
||||
...(data.id === undefined ? {} : { responseId: data.id }),
|
||||
...(data.provider === undefined ? {} : { provider: data.provider }),
|
||||
...data.provider_metadata?.[cfg.providerMetadataKey],
|
||||
...(Object.keys(confidence).length === 0 ? {} : { confidence }),
|
||||
...(Object.keys(legend).length === 0 ? {} : { legend }),
|
||||
}
|
||||
return new EvaluationResponse({
|
||||
|
||||
@@ -64,14 +64,6 @@ const OpenAIChatTool = Schema.Struct({
|
||||
})
|
||||
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
|
||||
|
||||
// Gemini's OpenAI-compatible surface carries thought signatures in tool call
|
||||
// `extra_content` and rejects replayed parallel calls without them:
|
||||
// https://ai.google.dev/gemini-api/docs/thinking#signatures
|
||||
const ExtraContent = Schema.Struct({
|
||||
google: Schema.Struct({ thought_signature: Schema.String }),
|
||||
})
|
||||
const decodeExtraContent = (value: unknown) => Option.getOrUndefined(Schema.decodeUnknownOption(ExtraContent)(value))
|
||||
|
||||
const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||
id: Schema.String,
|
||||
type: Schema.tag("function"),
|
||||
@@ -79,7 +71,6 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
|
||||
name: Schema.String,
|
||||
arguments: Schema.String,
|
||||
}),
|
||||
extra_content: Schema.optional(ExtraContent),
|
||||
})
|
||||
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
|
||||
|
||||
@@ -121,6 +112,12 @@ const decodeReasoningDetail = Schema.decodeUnknownOption(ReasoningDetail)
|
||||
const knownReasoningDetails = (details: ReadonlyArray<unknown>) =>
|
||||
details.flatMap((detail) => Option.toArray(decodeReasoningDetail(detail)))
|
||||
|
||||
// Intentionally omit Gemini's provider-specific `extra_content.google.thought_signature`
|
||||
// extension until direct Google OpenAI-compatible routing is supported here:
|
||||
// https://github.com/vercel/ai/issues/11590
|
||||
// https://github.com/vercel/ai/pull/11745
|
||||
// https://ai.google.dev/gemini-api/docs/thought-signatures#openai
|
||||
|
||||
const OpenAIChatUserContent = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("text"),
|
||||
@@ -245,7 +242,6 @@ const OpenAIChatToolCallDelta = Schema.Struct({
|
||||
index: optionalNull(Schema.Number),
|
||||
id: optionalNull(Schema.String),
|
||||
function: optionalNull(OpenAIChatToolCallDeltaFunction),
|
||||
extra_content: optionalNull(Schema.Unknown),
|
||||
})
|
||||
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
|
||||
|
||||
@@ -298,7 +294,6 @@ interface PendingToolDelta {
|
||||
readonly id?: string
|
||||
readonly name?: string
|
||||
readonly input: string
|
||||
readonly extraContent?: Schema.Schema.Type<typeof ExtraContent>
|
||||
}
|
||||
|
||||
export interface ParserState {
|
||||
@@ -352,17 +347,13 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
tool: (name) => ({ type: "function" as const, function: { name } }),
|
||||
})
|
||||
|
||||
const lowerToolCall = (
|
||||
part: ToolCallPart,
|
||||
options: LoweringOptions & { readonly providerMetadataKey: string },
|
||||
): OpenAIChatAssistantToolCall => ({
|
||||
const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChatAssistantToolCall => ({
|
||||
id: options.toolCallID?.(part.id) ?? part.id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: part.name,
|
||||
arguments: ProviderShared.encodeJson(part.input === undefined ? {} : part.input),
|
||||
},
|
||||
extra_content: decodeExtraContent(part.providerMetadata?.[options.providerMetadataKey]?.extraContent),
|
||||
})
|
||||
|
||||
const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart) {
|
||||
@@ -730,9 +721,7 @@ const detectSupportsStore = (provider: string, baseURL: string | undefined): boo
|
||||
p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
|
||||
const isAntLing = p === "ant-ling" || url.includes("api.ant-ling.com")
|
||||
const isOpencode = p === "opencode" || url.includes("opencode.ai")
|
||||
const isGemini = url.includes("generativelanguage.googleapis.com")
|
||||
const isNonStandard =
|
||||
isGemini ||
|
||||
isNvidia ||
|
||||
isCerebras ||
|
||||
isXai ||
|
||||
@@ -1125,13 +1114,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
|
||||
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
|
||||
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
|
||||
const extraContent = pending?.extraContent ?? decodeExtraContent(tool.extra_content)
|
||||
latestToolIndex = index
|
||||
nextToolIndex = Math.max(nextToolIndex, index + 1)
|
||||
if (!current && (!id || !name)) {
|
||||
pendingTools = {
|
||||
...pendingTools,
|
||||
[index]: { id: id || undefined, name: name || undefined, input: text, extraContent },
|
||||
[index]: { id: id || undefined, name: name || undefined, input: text },
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -1143,12 +1131,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
ADAPTER,
|
||||
tools,
|
||||
index,
|
||||
{
|
||||
id: id || undefined,
|
||||
name: name || undefined,
|
||||
text,
|
||||
providerMetadata: extraContent && { [state.providerMetadataKey]: { extraContent } },
|
||||
},
|
||||
{ id: id || undefined, name: name || undefined, text },
|
||||
"OpenAI Chat tool call delta is missing id or name",
|
||||
)
|
||||
if (ToolStream.isError(result))
|
||||
|
||||
@@ -147,12 +147,7 @@ export const appendOrStart = <K extends StreamKey>(
|
||||
route: string,
|
||||
tools: State<K>,
|
||||
key: K,
|
||||
delta: {
|
||||
readonly id?: string
|
||||
readonly name?: string
|
||||
readonly text: string
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
},
|
||||
delta: { readonly id?: string; readonly name?: string; readonly text: string },
|
||||
missingToolMessage: string,
|
||||
): AppendOutcome<K> | AIError => {
|
||||
const current = tools[key]
|
||||
@@ -166,7 +161,7 @@ export const appendOrStart = <K extends StreamKey>(
|
||||
namespace: current?.namespace,
|
||||
input: `${current?.input ?? ""}${delta.text}`,
|
||||
providerExecuted: current?.providerExecuted,
|
||||
providerMetadata: current?.providerMetadata ?? delta.providerMetadata,
|
||||
providerMetadata: current?.providerMetadata,
|
||||
}
|
||||
if (current && delta.text.length === 0 && current.id === id && current.name === name)
|
||||
return { tools, tool: current, events: [] }
|
||||
|
||||
@@ -109,8 +109,7 @@ const QUOTA_CODES = new Set([
|
||||
"freeusagelimiterror",
|
||||
"creditlimitexceeded",
|
||||
])
|
||||
// Google reports an invalid API key as HTTP 400 INVALID_ARGUMENT with this `details[].reason`.
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error", "api_key_invalid"])
|
||||
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
"internal_error",
|
||||
@@ -260,10 +259,6 @@ function providerCodes(value: unknown) {
|
||||
error?.type,
|
||||
error?.status,
|
||||
error?.error_type,
|
||||
// Google `google.rpc.ErrorInfo` details carry the specific reason.
|
||||
...(Array.isArray(error?.details)
|
||||
? error.details.map((detail) => (isRecord(detail) ? detail.reason : undefined))
|
||||
: []),
|
||||
inner?.code,
|
||||
metadata?.error_type,
|
||||
responseError?.code,
|
||||
|
||||
@@ -28,8 +28,7 @@ export type Settings = ProviderPackage.Settings &
|
||||
readonly provider?: string
|
||||
}
|
||||
|
||||
const compatibleRoute = AnthropicMessages.route.with({ id: "anthropic-compatible-messages", provider: id })
|
||||
export const routes = [compatibleRoute]
|
||||
export const routes = [AnthropicMessages.route]
|
||||
|
||||
const auth = (input: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in input && input.auth) return input.auth
|
||||
@@ -44,7 +43,7 @@ export const configure = (input: Config) => {
|
||||
message: "Anthropic-compatible providers require a baseURL",
|
||||
})
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
||||
const route = (provider === "anthropic" ? AnthropicMessages.route : compatibleRoute).with({
|
||||
const route = AnthropicMessages.route.with({
|
||||
...rest,
|
||||
provider,
|
||||
endpoint: { baseURL },
|
||||
|
||||
@@ -35,13 +35,13 @@ const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
compact: { endpoint: XAIResponses.compact },
|
||||
id: "xai-responses",
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: XAIResponses.protocol,
|
||||
endpoint: Endpoint.path("/responses", { baseURL }),
|
||||
transport: OpenResponsesChannel.transport({
|
||||
id: "xai-responses",
|
||||
id: "openai-responses",
|
||||
name: "xAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
// xAI continues a chain only from stored responses: with `store: false` (the route default) `previous_response_id`
|
||||
@@ -53,7 +53,7 @@ const responsesRoute = Route.make({
|
||||
})
|
||||
|
||||
const chatRoute = Route.make({
|
||||
id: "xai-chat",
|
||||
id: "openai-compatible-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
protocol: OpenAIChat.protocol,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message } from "../src/index.js"
|
||||
import { Auth } from "../src/route.js"
|
||||
import { compileRequest } from "../src/route/client.js"
|
||||
import { AmazonBedrock, AnthropicCompatible, GoogleVertexMessages } from "../src/providers.js"
|
||||
import { AmazonBedrock, GoogleVertexMessages } 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"
|
||||
@@ -107,26 +107,6 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' emits Anthropic cache markers on Anthropic-compatible routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: AnthropicCompatible.configure({ apiKey: "test", baseURL: "https://messages.example.test/v1" }).model(
|
||||
"compatible",
|
||||
),
|
||||
system: "You are concise.",
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.route).toBe("anthropic-compatible-messages")
|
||||
expect(prepared.body).toMatchObject({
|
||||
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' is a no-op on OpenAI (implicit caching protocol)", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -39,18 +39,17 @@ describe("experimental Evaluation", () => {
|
||||
type: "choice",
|
||||
choice: "billing",
|
||||
probabilities: { billing: 0.9, technical: 0.1 },
|
||||
confidence: 0.8,
|
||||
})
|
||||
expect(response.answers.urgency).toEqual({
|
||||
type: "score",
|
||||
score: 1.2,
|
||||
probabilities: { "0": 0, "1": 0.8, "2": 0.2 },
|
||||
confidence: 0.6,
|
||||
})
|
||||
expect(response.answers.refund).toEqual({ type: "boolean", probability: 0.97 })
|
||||
expect(response.usage?.totalTokens).toBe(36)
|
||||
expect(response.providerMetadata).toEqual({
|
||||
typesafe: {
|
||||
confidence: { department: 0.8, urgency: 0.6 },
|
||||
legend: { urgency: { "0": "Can wait", "1": "Needs attention", "2": "Blocking" } },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -26,10 +26,8 @@ const request = Evaluation.request({
|
||||
const result = EvaluationClient.evaluate(request)
|
||||
type Result = Success<typeof result>
|
||||
type Choice = Assert<Equal<Result["answers"]["topic"]["choice"], "billing" | "support">>
|
||||
type Confidence = Assert<Equal<Result["answers"]["topic"]["confidence"], number | undefined>>
|
||||
type ClientRequirements = Assert<Equal<Requirements<typeof result>, Service>>
|
||||
void (true satisfies Choice)
|
||||
void (true satisfies Confidence)
|
||||
void (true satisfies ClientRequirements)
|
||||
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -154,8 +154,8 @@ describe("public exports", () => {
|
||||
expect(XAI.model).toBeFunction()
|
||||
expect(XAI.provider.responses).toBe(XAI.responses)
|
||||
expect(XAI.provider.chat).toBe(XAI.chat)
|
||||
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("xai-responses")
|
||||
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("xai-chat")
|
||||
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
|
||||
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat")
|
||||
expect(OpenAI.configure({ apiKey: "fixture" }).image("gpt-image-2").route.id).toBe("openai-images")
|
||||
expect(OpenAI.provider.image).toBe(OpenAI.image)
|
||||
expect(Google.configure({ apiKey: "fixture" }).image("imagen-4.0-generate-001").route.id).toBe("google-images")
|
||||
|
||||
Vendored
-54
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "gemini-3.8-flash",
|
||||
"tags": [
|
||||
"prefix:openai-compatible-chat",
|
||||
"provider:google",
|
||||
"protocol:openai-chat",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"continuation"
|
||||
],
|
||||
"name": "gemini-parallel-tool-signatures",
|
||||
"recordedAt": "2026-09-28T03:12:05.083Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3.8-flash\",\"messages\":[{\"role\":\"system\",\"content\":\"Call get_weather for every requested city in parallel, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris and in Tokyo?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"extra_content\":{\"google\":{\"thought_signature\":\"ErYCCrMCAWkUfRNGh+/Zbc8YUSzk1yfWAfROcjA4HyF1x69jz6167w8zd4n6kZQQ5FDeBZ5HZMEbEkQ4ENOpzsQL8roCR6wONkhXpiduWrTD6XwbP8KGNkf6D1tX/JlBh7G5Cl+0rdjiSOl/mdY1lcjbkfyCRFs5T8odNWMG7WD3rCrXJDFQ/5QfOl+tqVTceKGz2yyXBhOhvsQDU33ulR9tHQJo/Fmx3HNyDdwvmyKUXm+kgqHsYZnkdv6y6xZwy9zBXGUO4QwxelMw3Rrc24Mp2rNbEDZS3YEeP72Jn/hIP5a8XWOx6+zop/4/CjYxxSfN/tJRY5d48NAKRNFzUN1Az8SvAs/N5X2I3/5ZMaDnQWW/BVdS9fm2KFYJ2baqtBiRRnJxMq4ELArkKjGZzHpd97XG8YjV6g==\"}},\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"name\":\"get_weather\"},\"id\":\"call_723181\",\"type\":\"function\"}]},\"index\":0}],\"created\":1790565123,\"id\":\"A9u5aoufBp3rz7IPke_3oAo\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":16,\"prompt_tokens\":74,\"total_tokens\":132}}\n\ndata: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Tokyo\\\"}\",\"name\":\"get_weather\"},\"id\":\"call_723184\",\"type\":\"function\"}]},\"index\":0}],\"created\":1790565123,\"id\":\"A9u5aoufBp3rz7IPke_3oAo\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":32,\"prompt_tokens\":74,\"total_tokens\":148}}\n\ndata: {\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"index\":0}],\"created\":1790565124,\"id\":\"A9u5aoufBp3rz7IPke_3oAo\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":32,\"prompt_tokens\":74,\"total_tokens\":148}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gemini-3.8-flash\",\"messages\":[{\"role\":\"system\",\"content\":\"Call get_weather for every requested city in parallel, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris and in Tokyo?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_723181\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"ErYCCrMCAWkUfRNGh+/Zbc8YUSzk1yfWAfROcjA4HyF1x69jz6167w8zd4n6kZQQ5FDeBZ5HZMEbEkQ4ENOpzsQL8roCR6wONkhXpiduWrTD6XwbP8KGNkf6D1tX/JlBh7G5Cl+0rdjiSOl/mdY1lcjbkfyCRFs5T8odNWMG7WD3rCrXJDFQ/5QfOl+tqVTceKGz2yyXBhOhvsQDU33ulR9tHQJo/Fmx3HNyDdwvmyKUXm+kgqHsYZnkdv6y6xZwy9zBXGUO4QwxelMw3Rrc24Mp2rNbEDZS3YEeP72Jn/hIP5a8XWOx6+zop/4/CjYxxSfN/tJRY5d48NAKRNFzUN1Az8SvAs/N5X2I3/5ZMaDnQWW/BVdS9fm2KFYJ2baqtBiRRnJxMq4ELArkKjGZzHpd97XG8YjV6g==\"}}},{\"id\":\"call_723184\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_723181\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"},{\"role\":\"tool\",\"tool_call_id\":\"call_723184\",\"content\":\"{\\\"temperature\\\":0,\\\"condition\\\":\\\"unknown\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"It is currently sunny and 22°C in Paris,\",\"role\":\"assistant\"},\"index\":0}],\"created\":1790565125,\"id\":\"BNu5auWeB-2fz7IPxY6l4QY\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":13,\"prompt_tokens\":150,\"total_tokens\":226}}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" while Tokyo is 0°C.\",\"role\":\"assistant\"},\"index\":0}],\"created\":1790565125,\"id\":\"BNu5auWeB-2fz7IPxY6l4QY\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":21,\"prompt_tokens\":150,\"total_tokens\":234}}\n\ndata: {\"choices\":[{\"delta\":{\"extra_content\":{\"google\":{\"thought_signature\":\"EpcDCpQDAWkUfRM325TAUtfFiOeQIEWn/TsCU9oi2js4VPHFeeLfAu+2k8PJm0fN/OaF0y4ovau7S9QIAsuOPI2w2aIyQ2kMGj1XvUyRTvz30DOZgtq1km6W6YGzZyyTCNSeBcpwtJtziHZVVWq9xEI/HHB8Ta1Ot215xnFyDL7iUGEwgGu45/mInpk+SOCYBy9biDddpDxcDi14BGoleArY9XEFAzYLxXssl7HMWjpfee5095im7gD125Nripq1Jf3nGY/2TxqjgQAdJpQybwct63p74O1szGHQxrkBt7AwphDgbOWtLpUP/QJBRdl8qhrozqRe611NQ6V5lMSwpO7OhQ/IDRtWMwOyrrKblZfmMnnPl2/9xDfZRsYnfmWq+7PeAptJl1cDRlMBKhj5iRn31xvN43EiuvWwWPsSndiWvxrMvVBd89TR4u0+z0pYCYZcsFkYKFlPA7pZsdnh6CON24AA5WUkO4dQNoqYdik6aO5hE9jLHG5FHTdr0W69qgPNozbxnO0ptRcOtkGpB9xYUVoTxcSXXZE=\"}},\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"index\":0}],\"created\":1790565125,\"id\":\"BNu5auWeB-2fz7IPxY6l4QY\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":21,\"prompt_tokens\":192,\"total_tokens\":276}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -355,32 +355,6 @@ describe("provider error rawBody classification", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("classifies Google invalid API keys as authentication failures", () => {
|
||||
const rawBody = JSON.stringify({
|
||||
error: {
|
||||
code: 400,
|
||||
message: "API key not valid. Please pass a valid API key.",
|
||||
status: "INVALID_ARGUMENT",
|
||||
details: [
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
|
||||
reason: "API_KEY_INVALID",
|
||||
domain: "googleapis.com",
|
||||
},
|
||||
{
|
||||
"@type": "type.googleapis.com/google.rpc.LocalizedMessage",
|
||||
locale: "en-US",
|
||||
message: "API key not valid. Please pass a valid API key.",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(
|
||||
classifyProviderFailure({ message: "API key not valid. Please pass a valid API key.", status: 400, rawBody })
|
||||
._tag,
|
||||
).toBe("Authentication")
|
||||
})
|
||||
|
||||
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
|
||||
const reason = classifyProviderFailure({
|
||||
message: "Request failed",
|
||||
|
||||
@@ -300,9 +300,7 @@ describe("provider package entrypoints", () => {
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
expect(selected.route.id).toBe("anthropic-compatible-messages")
|
||||
expect(selected.route.protocol).toBe("anthropic-messages")
|
||||
expect(selected.route.providerMetadataKey).toBe("example")
|
||||
expect(selected.route.id).toBe("anthropic-messages")
|
||||
expect(selected.route.endpoint).toMatchObject({
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
})
|
||||
@@ -321,7 +319,6 @@ describe("provider package entrypoints", () => {
|
||||
thinking: { type: "adaptive" },
|
||||
})
|
||||
|
||||
expect(selected.route.id).toBe("anthropic-messages")
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
|
||||
})
|
||||
|
||||
|
||||
@@ -92,6 +92,5 @@ const assertEvaluation = <Options extends EvaluationOptions>(
|
||||
expect(response.answers.refund.probability).toBeGreaterThan(0.5)
|
||||
expect(response.usage?.inputTokens).toBeGreaterThan(0)
|
||||
expect(response.usage?.outputTokens).toBeGreaterThan(0)
|
||||
expect(response.answers.department.confidence).toBeGreaterThan(0)
|
||||
expect(response.answers.urgency.confidence).toBeGreaterThan(0)
|
||||
expect(response.providerMetadata?.[metadataKey]?.confidence).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, LLMRequest, Message, ToolRuntime, toDefinitions } from "../../src/index.js"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
import { weatherRuntimeTool, weatherToolName } from "../recorded-scenarios.js"
|
||||
|
||||
const model = OpenAICompatible.configure({
|
||||
provider: "google",
|
||||
baseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
|
||||
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture",
|
||||
}).model("gemini-3.8-flash")
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "openai-compatible-chat",
|
||||
provider: "google",
|
||||
protocol: "openai-chat",
|
||||
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
|
||||
tags: ["tool", "tool-loop", "continuation"],
|
||||
metadata: { model: model.id },
|
||||
})
|
||||
|
||||
describe("Gemini OpenAI-compatible Chat recorded", () => {
|
||||
recorded.effect.with(
|
||||
"replays thought signatures through a parallel tool loop",
|
||||
{ cassette: "openai-compatible-chat/gemini-parallel-tool-signatures" },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tools = { [weatherToolName]: weatherRuntimeTool }
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "Call get_weather for every requested city in parallel, then answer in one short sentence.",
|
||||
prompt: "What is the weather in Paris and in Tokyo?",
|
||||
tools: toDefinitions(tools),
|
||||
cache: "none",
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
const calls = first.events.filter(LLMEvent.is.toolCall)
|
||||
expect(calls.map((call) => call.input)).toEqual([{ city: "Paris" }, { city: "Tokyo" }])
|
||||
const extraContent = calls[0]?.providerMetadata?.google?.extraContent
|
||||
expect(extraContent).toEqual({ google: { thought_signature: expect.any(String) } })
|
||||
|
||||
const results = yield* Effect.forEach(calls, (call) => ToolRuntime.dispatch(tools, call))
|
||||
const continuation = LLMRequest.update(request, {
|
||||
messages: [
|
||||
...request.messages,
|
||||
first.message,
|
||||
...calls.map((call, index) =>
|
||||
Message.tool({ id: call.id, name: call.name, result: results[index]!.result }),
|
||||
),
|
||||
],
|
||||
})
|
||||
const prepared = yield* compileRequest(continuation)
|
||||
const assistant = prepared.body.messages.find((message) => message.role === "assistant")
|
||||
expect(assistant?.role === "assistant" ? assistant.tool_calls?.[0]?.extra_content : undefined).toEqual(
|
||||
extraContent,
|
||||
)
|
||||
|
||||
const second = yield* LLMClient.generate(continuation)
|
||||
expect(second.events.filter(LLMEvent.is.toolCall)).toHaveLength(0)
|
||||
expect(second.text).toMatch(/Paris/)
|
||||
expect(second.text).toMatch(/Tokyo/)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
@@ -472,45 +472,6 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays Gemini thought signatures as tool call extra content", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Weather in Paris and Tokyo?"),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { city: "Paris" },
|
||||
providerMetadata: { openai: { extraContent: { google: { thought_signature: "sig_1" } } } },
|
||||
}),
|
||||
ToolCallPart.make({ id: "call_2", name: "lookup", input: { city: "Tokyo" } }),
|
||||
]),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
|
||||
Message.tool({ id: "call_2", name: "lookup", result: "Rainy" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const assistant = prepared.body.messages[1]
|
||||
expect(assistant?.role === "assistant" ? assistant.tool_calls : undefined).toEqual([
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "lookup", arguments: encodeJson({ city: "Paris" }) },
|
||||
extra_content: { google: { thought_signature: "sig_1" } },
|
||||
},
|
||||
{
|
||||
id: "call_2",
|
||||
type: "function",
|
||||
function: { name: "lookup", arguments: encodeJson({ city: "Tokyo" }) },
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("limits OpenAI and Azure Chat tool call IDs to 40 characters", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = `call_${"a".repeat(48)}`
|
||||
@@ -1844,78 +1805,6 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves Gemini thought signatures on streamed parallel tool calls", () =>
|
||||
Effect.gen(function* () {
|
||||
// Gemini's OpenAI-compatible endpoint omits `index`, streams each call whole,
|
||||
// and signs only the first call of a parallel batch.
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{
|
||||
extra_content: { google: { thought_signature: "sig_1" } },
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "lookup", arguments: '{"city":"Paris"}' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
deltaChunk({
|
||||
role: "assistant",
|
||||
tool_calls: [{ id: "call_2", type: "function", function: { name: "lookup", arguments: '{"city":"Tokyo"}' } }],
|
||||
}),
|
||||
deltaChunk({}, "stop"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { city: "Paris" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: { openai: { extraContent: { google: { thought_signature: "sig_1" } } } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_2",
|
||||
name: "lookup",
|
||||
input: { city: "Tokyo" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps extra content that arrives before the tool identity", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [
|
||||
{ index: 0, extra_content: { google: { thought_signature: "sig_1" } }, function: { arguments: "{" } },
|
||||
],
|
||||
}),
|
||||
deltaChunk({ tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "}" } }] }),
|
||||
deltaChunk({}, "tool_calls"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.filter(LLMEvent.is.toolCall).map((event) => event.providerMetadata)).toEqual([
|
||||
{ openai: { extraContent: { google: { thought_signature: "sig_1" } } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not finalize streamed tool calls when content is filtered", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -56,9 +56,7 @@ describe("xAI Responses route", () => {
|
||||
expect(XAIResponses.protocol.body).not.toBe(OpenAIResponses.protocol.body)
|
||||
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
|
||||
expect(prepared.route).toBe("xai-responses")
|
||||
expect(prepared.protocol).toBe("xai-responses")
|
||||
expect(prepared.model.route.providerMetadataKey).toBe("xai")
|
||||
expect(prepared.body.store).toBe(false)
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
}),
|
||||
@@ -300,14 +298,3 @@ describe("xAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("names the xAI Chat route separately from its OpenAI Chat protocol", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: XAI.configure({ apiKey: "test" }).chat("grok-4.6"), prompt: "Hello" }),
|
||||
)
|
||||
expect(prepared.route).toBe("xai-chat")
|
||||
expect(prepared.protocol).toBe("openai-chat")
|
||||
expect(prepared.model.route.providerMetadataKey).toBe("xai")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -51,30 +51,6 @@ for (const width of [1400, 390]) {
|
||||
})
|
||||
}
|
||||
|
||||
for (const width of [1400, 390]) {
|
||||
test(`keeps Working instead of Used 1 Thought while reasoning is the only activity at ${width}px`, async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([reasoningPart("prt_only_thought", "Still considering the answer.")], { completed: false }),
|
||||
],
|
||||
settings: { timelineDetail: timelinePresets[2].value },
|
||||
viewport: { width, height: 900 },
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(working).toBeVisible()
|
||||
await expect(group).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Used 1 Thought" })).toHaveCount(0)
|
||||
|
||||
if (width === 390) return
|
||||
await timeline.send(partUpdated(toolPart("prt_after_thought", "read", "running", { filePath: "package.json" })))
|
||||
await expect(group).toBeVisible()
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", `${assistantID}:reasoning:0,prt_after_thought`)
|
||||
await expect(working).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
for (const name of ["shell", "patch", "subagent"] as const) {
|
||||
test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
|
||||
@@ -51,7 +51,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
),
|
||||
session: Flag.string("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
@@ -328,7 +328,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
),
|
||||
session: Flag.string("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
fork: Flag.boolean("fork").pipe(
|
||||
@@ -368,7 +368,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
),
|
||||
session: Flag.string("session").pipe(
|
||||
Flag.withAlias("s"),
|
||||
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
fork: Flag.boolean("fork").pipe(
|
||||
|
||||
@@ -11,10 +11,6 @@ import { UpdatePreflight } from "../../services/update-preflight"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
|
||||
import { Env } from "../../env"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { findSession } from "../../session-target"
|
||||
import { errorMessage } from "../../util/error"
|
||||
|
||||
export default Runtime.handler(Commands, (input) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -50,15 +46,6 @@ export default Runtime.handler(Commands, (input) =>
|
||||
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
|
||||
),
|
||||
)
|
||||
const session = Option.getOrUndefined(input.session)
|
||||
// A missing --session ID becomes the ID of the session the first prompt creates.
|
||||
const sessionExists =
|
||||
session !== undefined &&
|
||||
(yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
findSession(OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }), session),
|
||||
catch: (cause) => new Error(errorMessage(cause)),
|
||||
})) !== undefined
|
||||
const updater = yield* Updater.Service
|
||||
let installing: string | undefined
|
||||
const updateListeners = new Set<(version: string) => void>()
|
||||
@@ -94,8 +81,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
},
|
||||
args: {
|
||||
continue: input.continue,
|
||||
sessionID: sessionExists ? session : undefined,
|
||||
newSessionID: sessionExists ? undefined : session,
|
||||
sessionID: Option.getOrUndefined(input.session),
|
||||
prompt: Option.getOrUndefined(input.prompt),
|
||||
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
|
||||
},
|
||||
|
||||
@@ -135,7 +135,6 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
|
||||
if (!input.auto) {
|
||||
permissionRejected = true
|
||||
if (input.compatibility !== "v1") process.exitCode = 1
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL +
|
||||
@@ -164,7 +163,6 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (!formAlreadySettled(error)) throw error
|
||||
}
|
||||
formCancelled = true
|
||||
if (input.compatibility !== "v1") process.exitCode = 1
|
||||
}
|
||||
|
||||
const consume = async () => {
|
||||
@@ -495,8 +493,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (event.type === "session.execution.interrupted") {
|
||||
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) return
|
||||
if (event.data.reason === "user" && interrupted) process.exitCode = 130
|
||||
// A declined tool call ends the step with an interruption; it was already reported above.
|
||||
if (event.data.reason !== "user" && !emittedError && !permissionRejected && !formCancelled) {
|
||||
if (event.data.reason !== "user" && !emittedError) {
|
||||
emittedError = true
|
||||
process.exitCode = 1
|
||||
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
|
||||
@@ -623,9 +620,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
UI.error(item.state.error.message)
|
||||
}
|
||||
|
||||
// A declined tool call ends its step with an interrupted-step error that is
|
||||
// only a consequence of our own rejection; it was already reported above.
|
||||
if (message.error && !emittedError && !permissionRejected && !formCancelled) {
|
||||
if (message.error && !emittedError) {
|
||||
emittedError = true
|
||||
process.exitCode = 1
|
||||
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
|
||||
|
||||
@@ -63,7 +63,6 @@ export async function resolveSessionTarget(input: {
|
||||
(await input.client.session
|
||||
.create(
|
||||
{
|
||||
id: input.session,
|
||||
agent: prepared.agent,
|
||||
model: prepared.model,
|
||||
location: { directory: location.directory },
|
||||
@@ -102,11 +101,14 @@ async function selectSession(input: {
|
||||
fork?: boolean
|
||||
signal?: AbortSignal
|
||||
}) {
|
||||
const explicit = input.session ? await findSession(input.client, input.session, input.signal) : undefined
|
||||
if (input.session && !explicit) {
|
||||
if (input.fork) throw new Error("Session not found")
|
||||
return { session: undefined }
|
||||
}
|
||||
const explicit = input.session
|
||||
? await input.client.session.get({ sessionID: input.session }, ...requestOptions(input.signal)).catch((error) => {
|
||||
if (error && typeof error === "object" && "_tag" in error && error._tag === "SessionNotFoundError")
|
||||
return undefined
|
||||
throw error
|
||||
})
|
||||
: undefined
|
||||
if (input.session && !explicit) throw new Error("Session not found")
|
||||
if (explicit)
|
||||
return {
|
||||
session: input.fork
|
||||
@@ -131,13 +133,6 @@ async function selectSession(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export function findSession(client: OpenCodeClient, sessionID: string, signal?: AbortSignal) {
|
||||
return client.session.get({ sessionID }, ...requestOptions(signal)).catch((error) => {
|
||||
if (error && typeof error === "object" && "_tag" in error && error._tag === "SessionNotFoundError") return undefined
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
async function latestSession(
|
||||
client: OpenCodeClient,
|
||||
location: LocationGetOutput,
|
||||
|
||||
@@ -309,7 +309,6 @@ async function capture(input: Parameters<typeof run>[0]) {
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
process.exitCode = 0
|
||||
})
|
||||
|
||||
describe("runNonInteractivePrompt", () => {
|
||||
@@ -434,7 +433,6 @@ describe("runNonInteractivePrompt", () => {
|
||||
expect(sdk.form.list).toHaveBeenCalledWith({
|
||||
location: { directory: "/work tree" },
|
||||
})
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
test("attach mode cancels only session-owned forms", async () => {
|
||||
@@ -450,7 +448,6 @@ describe("runNonInteractivePrompt", () => {
|
||||
{ sessionID: "global", formID: "frm_pending_global" },
|
||||
expect.anything(),
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
test("V1 JSON output flushes step_start before an unrelated step failure", async () => {
|
||||
|
||||
@@ -42,28 +42,6 @@ describe("session target resolver", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("creates a missing explicit Session with its ID", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
spyOn(client.session, "get").mockRejectedValue({ _tag: "SessionNotFoundError" })
|
||||
spyOn(client.location, "get").mockResolvedValue(location("/project"))
|
||||
const create = spyOn(client.session, "create").mockResolvedValue(session("ses_chosen", "/project"))
|
||||
|
||||
const target = await resolveSessionTarget({ client, session: "ses_chosen", prepare })
|
||||
expect(create).toHaveBeenCalledWith(expect.objectContaining({ id: "ses_chosen" }))
|
||||
expect(target).toMatchObject({ session: { id: "ses_chosen" }, resume: false })
|
||||
})
|
||||
|
||||
test("does not create a missing explicit Session to fork", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
spyOn(client.session, "get").mockRejectedValue({ _tag: "SessionNotFoundError" })
|
||||
const create = spyOn(client.session, "create")
|
||||
|
||||
await expect(resolveSessionTarget({ client, session: "ses_chosen", fork: true, prepare })).rejects.toThrow(
|
||||
"Session not found",
|
||||
)
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("paginates to continue the exact directory", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
spyOn(client.location, "get").mockResolvedValue(location("/project"))
|
||||
|
||||
@@ -1174,7 +1174,7 @@ export type SessionLogOutput =
|
||||
| undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly reason: "auto" | "manual" | "overflow"
|
||||
readonly recent: string
|
||||
readonly inputID?: SessionMessage.ID | undefined
|
||||
}
|
||||
@@ -1193,7 +1193,7 @@ export type SessionLogOutput =
|
||||
| undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly reason: "auto" | "manual" | "overflow"
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly providerState?: SessionMessage.ProviderState | undefined
|
||||
readonly providerContext?:
|
||||
@@ -1237,7 +1237,7 @@ export type SessionLogOutput =
|
||||
| undefined
|
||||
readonly data: {
|
||||
readonly sessionID: Session.ID
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly reason: "auto" | "manual" | "overflow"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly inputID?: SessionMessage.ID | undefined
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
|
||||
@@ -141,7 +141,7 @@ export type SessionMessageCompactionRunning = {
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
status: "running"
|
||||
reason: "auto" | "manual"
|
||||
reason: "auto" | "manual" | "overflow"
|
||||
summary: string
|
||||
recent: string
|
||||
}
|
||||
@@ -534,7 +534,7 @@ export type SessionMessageCompactionFailed = {
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
status: "failed"
|
||||
reason: "auto" | "manual"
|
||||
reason: "auto" | "manual" | "overflow"
|
||||
error: SessionStructuredError
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
@@ -788,7 +788,7 @@ export type SessionCompactionStarted = {
|
||||
type: "session.compaction.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string }
|
||||
data: { sessionID: string; reason: "auto" | "manual" | "overflow"; recent: string; inputID?: string }
|
||||
}
|
||||
|
||||
export type SessionCompactionFailed = {
|
||||
@@ -800,7 +800,7 @@ export type SessionCompactionFailed = {
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "auto" | "manual"
|
||||
reason: "auto" | "manual" | "overflow"
|
||||
error: SessionStructuredError
|
||||
inputID?: string
|
||||
cost?: MoneyUSD
|
||||
@@ -1776,7 +1776,7 @@ export type SessionMessageCompactionCompleted = {
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
status: "completed"
|
||||
reason: "auto" | "manual"
|
||||
reason: "auto" | "manual" | "overflow"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState
|
||||
summary: string
|
||||
@@ -1795,7 +1795,7 @@ export type SessionCompactionEnded = {
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "auto" | "manual"
|
||||
reason: "auto" | "manual" | "overflow"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState1
|
||||
providerContext?: SessionProviderContext
|
||||
@@ -3226,7 +3226,7 @@ export type SessionImportInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "running"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly reason: "auto" | "manual" | "overflow"
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
}
|
||||
@@ -3236,7 +3236,7 @@ export type SessionImportInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "completed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly reason: "auto" | "manual" | "overflow"
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
@@ -3267,7 +3267,7 @@ export type SessionImportInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly reason: "auto" | "manual" | "overflow"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
@@ -3543,7 +3543,7 @@ export type SessionImportInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "running"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly reason: "auto" | "manual" | "overflow"
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
}
|
||||
@@ -3553,7 +3553,7 @@ export type SessionImportInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "completed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly reason: "auto" | "manual" | "overflow"
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
@@ -3584,7 +3584,7 @@ export type SessionImportInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly reason: "auto" | "manual" | "overflow"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
@@ -3860,7 +3860,7 @@ export type SessionImportInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "running"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly reason: "auto" | "manual" | "overflow"
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
}
|
||||
@@ -3870,7 +3870,7 @@ export type SessionImportInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "completed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly reason: "auto" | "manual" | "overflow"
|
||||
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
@@ -3901,7 +3901,7 @@ export type SessionImportInput = {
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly status: "failed"
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly reason: "auto" | "manual" | "overflow"
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * as Generate from "./generate.js"
|
||||
|
||||
import { LLM, LLMClient, AIError } from "@opencode/ai"
|
||||
import { SessionID } from "@opencode/schema/session-id"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { llmClient } from "./effect/app-node-platform.js"
|
||||
@@ -61,24 +60,15 @@ export const layer = Layer.effect(
|
||||
? `Model unavailable: ${input.model.providerID}/${input.model.id}`
|
||||
: "No model specified and no supported model is available",
|
||||
})
|
||||
const response = yield* llm
|
||||
.generate(
|
||||
LLM.request({
|
||||
model: resolved.model,
|
||||
prompt: input.prompt,
|
||||
// Gateways require session attribution even for a stateless call; no Session is stored.
|
||||
http: { headers: { "x-opencode-session": SessionID.create() } },
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error: AIError) =>
|
||||
new UnavailableError({
|
||||
message: error.message,
|
||||
service: resolved.ref.providerID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe(
|
||||
Effect.mapError(
|
||||
(error: AIError) =>
|
||||
new UnavailableError({
|
||||
message: error.message,
|
||||
service: resolved.ref.providerID,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return response.text
|
||||
})
|
||||
|
||||
|
||||
@@ -2,12 +2,13 @@ export * as InstructionBuiltIns from "./builtins.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Context, DateTime, Effect, Layer, Schema } from "effect"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Location } from "../location.js"
|
||||
import { Instructions } from "./index.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly load: () => Effect.Effect<Instructions.List>
|
||||
readonly load: (sessionID: Session.ID) => Effect.Effect<Instructions.List>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/InstructionBuiltIns") {}
|
||||
@@ -18,24 +19,16 @@ const layer = Layer.effect(
|
||||
const global = yield* Global.Service
|
||||
const location = yield* Location.Service
|
||||
return Service.of({
|
||||
load: () =>
|
||||
load: (sessionID) =>
|
||||
Effect.succeed(
|
||||
Instructions.combine([
|
||||
Instructions.make({
|
||||
key: Instructions.Key.make("core/date"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
|
||||
render: {
|
||||
initial: (date) => `Today's date: ${date}`,
|
||||
changed: (_previous, date) => `Today's date is now: ${date}`,
|
||||
},
|
||||
}),
|
||||
Instructions.make({
|
||||
key: Instructions.Key.make("core/environment"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
read: Effect.sync(() =>
|
||||
[
|
||||
"<env>",
|
||||
` Current conversation session ID: ${sessionID}`,
|
||||
` Working directory: ${location.directory}`,
|
||||
` Workspace root folder: ${location.project.directory}`,
|
||||
` Is directory a git repo: ${location.vcs?.type === "git" ? "yes" : "no"}`,
|
||||
@@ -51,6 +44,15 @@ const layer = Layer.effect(
|
||||
["The environment you are running in is now:", environment].join("\n"),
|
||||
},
|
||||
}),
|
||||
Instructions.make({
|
||||
key: Instructions.Key.make("core/date"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
read: DateTime.nowAsDate.pipe(Effect.map((date) => date.toDateString())),
|
||||
render: {
|
||||
initial: (date) => `Today's date: ${date}`,
|
||||
changed: (_previous, date) => `Today's date is now: ${date}`,
|
||||
},
|
||||
}),
|
||||
]),
|
||||
),
|
||||
})
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,62 +1,55 @@
|
||||
import { Effect } from "effect"
|
||||
import { isArrayNonEmpty } from "effect/Array"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { iife } from "../../util/iife.js"
|
||||
import { configuredSettings } from "./configured.js"
|
||||
|
||||
const providerID = Provider.ID.make("cloudflare-ai-gateway")
|
||||
|
||||
const accountIdField = Form.StringField.make({
|
||||
type: "string",
|
||||
key: "accountId",
|
||||
title: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
required: true,
|
||||
})
|
||||
|
||||
const gatewayIdField = Form.StringField.make({
|
||||
type: "string",
|
||||
key: "gatewayId",
|
||||
title: "Enter your Cloudflare AI Gateway ID",
|
||||
placeholder: "e.g. my-gateway",
|
||||
required: true,
|
||||
})
|
||||
|
||||
export const CloudflareAIGatewayPlugin = define({
|
||||
id: "opencode.provider.cloudflare.ai.gateway",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
const gatewayId = process.env.CLOUDFLARE_GATEWAY_ID
|
||||
const configured = yield* configuredSettings(providerID)
|
||||
const fields =
|
||||
typeof configured?.baseURL === "string"
|
||||
? []
|
||||
: [
|
||||
...(accountId || typeof configured?.accountId === "string" ? [] : [accountIdField]),
|
||||
...(gatewayId || typeof configured?.gatewayId === "string" ? [] : [gatewayIdField]),
|
||||
]
|
||||
const form = iife(() => {
|
||||
if (typeof configured?.baseURL === "string") return
|
||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || stringOption(configured ?? {}, "accountId")
|
||||
const gatewayId =
|
||||
process.env.CLOUDFLARE_GATEWAY_ID ||
|
||||
stringOption(configured ?? {}, "gatewayId") ||
|
||||
stringOption(configured ?? {}, "gateway")
|
||||
if (accountId && gatewayId) return
|
||||
const accountIdForm = Form.StringField.make({
|
||||
type: "string",
|
||||
key: "accountId",
|
||||
title: "Enter your Cloudflare Account ID",
|
||||
placeholder: "e.g. 1234567890abcdef1234567890abcdef",
|
||||
required: true,
|
||||
})
|
||||
const gatewayIdForm = Form.StringField.make({
|
||||
type: "string",
|
||||
key: "gatewayId",
|
||||
title: "Enter your Cloudflare AI Gateway ID",
|
||||
placeholder: "e.g. my-gateway",
|
||||
required: true,
|
||||
})
|
||||
if (accountId) return Form.Fields.make([gatewayIdForm])
|
||||
if (gatewayId) return Form.Fields.make([accountIdForm])
|
||||
return Form.Fields.make([accountIdForm, gatewayIdForm])
|
||||
})
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
editor.method.update({
|
||||
integrationID: providerID,
|
||||
method: {
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
form: isArrayNonEmpty(fields) ? Form.Fields.make(fields) : undefined,
|
||||
form,
|
||||
},
|
||||
})
|
||||
})
|
||||
yield* ctx.provider.transform((evt) => {
|
||||
const item = evt.get(providerID)
|
||||
if (!item || (!accountId && !gatewayId)) return
|
||||
evt.update(item.provider.id, (provider) => {
|
||||
if (typeof provider.settings?.baseURL === "string") return
|
||||
provider.settings = {
|
||||
...(accountId ? { accountId } : {}),
|
||||
...(gatewayId ? { gatewayId } : {}),
|
||||
...provider.settings,
|
||||
}
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
function stringOption(options: Record<string, unknown>, key: string) {
|
||||
return typeof options[key] === "string" ? options[key] : undefined
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export const CloudflareWorkersAIPlugin = define({
|
||||
})
|
||||
|
||||
function resolveAccountId(options: Record<string, unknown>) {
|
||||
return stringOption(options, "accountId") ?? process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
return process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
|
||||
}
|
||||
|
||||
function workersEndpoint(accountId: string) {
|
||||
|
||||
@@ -11,7 +11,6 @@ import { Model } from "../../model.js"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { SessionAffinity } from "../../session/affinity.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
const clientID = "Ov23li8tweQw6odWQebz"
|
||||
@@ -273,7 +272,7 @@ export const GithubCopilotPlugin = define({
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
const interaction = interactionType(evt.kind, session?.parentID !== undefined)
|
||||
evt.headers["X-Interaction-Type"] = interaction
|
||||
evt.headers["X-Interaction-Id"] = session ? SessionAffinity.get(session) : evt.sessionID
|
||||
evt.headers["X-Interaction-Id"] = evt.sessionID
|
||||
if (interaction !== "conversation-agent") evt.headers["x-initiator"] = "agent"
|
||||
}),
|
||||
{ providerID: Provider.ID.githubCopilot },
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Bus } from "../../bus.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { OauthCallbackPage } from "../../oauth/page.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { SessionAffinity } from "../../session/affinity.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
|
||||
const clientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
@@ -300,16 +299,12 @@ export const OpenAIPlugin = define({
|
||||
yield* ctx.session.hook(
|
||||
"model.request",
|
||||
(evt) =>
|
||||
Effect.gen(function* () {
|
||||
Effect.sync(() => {
|
||||
if (!chatgpt) return
|
||||
if (evt.baseURL && URL.canParse(evt.baseURL) && new URL(evt.baseURL).origin === "https://api.openai.com")
|
||||
evt.baseURL = codexBaseURL
|
||||
const session = yield* ctx.session
|
||||
.get({ sessionID: evt.sessionID })
|
||||
.pipe(Effect.orElseSucceed(() => undefined))
|
||||
evt.headers.originator = "opencode"
|
||||
// ChatGPT routes its prompt cache on this header, so children share the parent's.
|
||||
evt.headers["session-id"] = session ? SessionAffinity.get(session) : evt.sessionID
|
||||
evt.headers["session-id"] = evt.sessionID
|
||||
}),
|
||||
{ providerID: Provider.ID.openai },
|
||||
)
|
||||
|
||||
@@ -145,9 +145,8 @@ function parts(input: string) {
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
// cachePath makes each `:`-separated host part a directory.
|
||||
function safeHost(input: string) {
|
||||
return Boolean(input) && !input.startsWith("-") && input.split(":").every(safeSegment)
|
||||
return Boolean(input) && !input.startsWith("-") && !/[\s/\\]/.test(input)
|
||||
}
|
||||
|
||||
function safeSegment(input: string) {
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
export * as SessionAffinity from "./affinity.js"
|
||||
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
|
||||
// TODO: Should the `model.request` hook expose affinity so plugins stop deriving it from `ctx.session.get`?
|
||||
/** The Session ID that groups model requests for provider cache routing: children share the parent's, forks the fork source's. */
|
||||
export const get = (session: Pick<SessionSchema.Info, "id" | "parentID" | "fork">) =>
|
||||
session.parentID ?? session.fork?.sessionID ?? session.id
|
||||
@@ -376,7 +376,11 @@ export const layer = Layer.effect(
|
||||
const context = trigger.context
|
||||
// The runner opened the manual compaction's message when it delivered the `/compact` item.
|
||||
if (trigger.reason !== "manual") {
|
||||
yield* bus.publish(SessionEvent.Compaction.Started, { sessionID: context.session.id, reason: "auto", recent })
|
||||
yield* bus.publish(SessionEvent.Compaction.Started, {
|
||||
sessionID: context.session.id,
|
||||
reason: trigger.reason,
|
||||
recent,
|
||||
})
|
||||
}
|
||||
if (prepared.event.result) return yield* fromHook(context, prepared.event.result, recent)
|
||||
|
||||
@@ -614,13 +618,12 @@ export const layer = Layer.effect(
|
||||
): Effect.fn.Return<Outcome> {
|
||||
const context = trigger.context
|
||||
const sessionID = context.session.id
|
||||
const reason = trigger.reason === "manual" ? "manual" : "auto"
|
||||
const usage = spent.get(sessionID)
|
||||
|
||||
if ("error" in outcome) {
|
||||
yield* bus.publish(SessionEvent.Compaction.Failed, {
|
||||
sessionID,
|
||||
reason,
|
||||
reason: trigger.reason,
|
||||
inputID: trigger.reason === "manual" ? trigger.inputID : undefined,
|
||||
error: outcome.error,
|
||||
...usage,
|
||||
@@ -632,7 +635,7 @@ export const layer = Layer.effect(
|
||||
SessionEvent.Compaction.Ended,
|
||||
{
|
||||
sessionID,
|
||||
reason,
|
||||
reason: trigger.reason,
|
||||
model: context.model.ref,
|
||||
providerState: outcome.providerState,
|
||||
providerContext: outcome.providerContext,
|
||||
|
||||
@@ -132,7 +132,7 @@ const layer = Layer.effect(
|
||||
const loaded = yield* Effect.all(
|
||||
{
|
||||
tools: registry.snapshot(permissions),
|
||||
builtins: builtins.load(),
|
||||
builtins: builtins.load(sessionID),
|
||||
discovery: discovery.load(),
|
||||
skills: skillInstructions.load(permissions),
|
||||
references: referenceInstructions.load(),
|
||||
@@ -144,16 +144,13 @@ const layer = Layer.effect(
|
||||
return {
|
||||
session,
|
||||
agent: { ...agent, info: agent.info },
|
||||
// Ordered from most to least shared across sessions so the baseline stays a reusable
|
||||
// prompt-cache prefix: user-level catalog and guidance, then project instructions, then
|
||||
// the date and environment, which vary by day and directory.
|
||||
instructions: Instructions.combine([
|
||||
CodeModeInstructions.make(loaded.tools.codeModeCatalog),
|
||||
loaded.mcp,
|
||||
loaded.references,
|
||||
loaded.skills,
|
||||
loaded.discovery,
|
||||
loaded.builtins,
|
||||
CodeModeInstructions.make(loaded.tools.codeModeCatalog),
|
||||
loaded.discovery,
|
||||
loaded.skills,
|
||||
loaded.references,
|
||||
loaded.mcp,
|
||||
loaded.entries,
|
||||
]),
|
||||
tools: loaded.tools,
|
||||
|
||||
@@ -31,7 +31,6 @@ import { Permission } from "../permission.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { QuestionTool } from "../tool/plugin/question.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { SessionAffinity } from "./affinity.js"
|
||||
import { SessionModelTransport } from "./model-transport.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
@@ -47,8 +46,6 @@ const IMAGE_REMOVED =
|
||||
const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields))
|
||||
// Used when the catalog has no output limit for the model.
|
||||
const OUTPUT_TOKEN_FALLBACK = 32_000
|
||||
// No reply needs more, however much the model allows.
|
||||
const OUTPUT_TOKEN_MAX = 256_000
|
||||
// A summary never needs more, and a request asking for more cannot be shrunk to fit a window the catalog overstates.
|
||||
const SUMMARY_OUTPUT_MAX = 32_000
|
||||
// Prompt text is estimated at about 4 characters per token, which can run low on dense text such as code.
|
||||
@@ -90,7 +87,7 @@ const outputLimit = (
|
||||
kind: "primary" | "compaction",
|
||||
inputTokens?: Input["inputTokens"],
|
||||
) => {
|
||||
const model = Math.min(limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK, OUTPUT_TOKEN_MAX)
|
||||
const model = limit.output > 0 ? limit.output : OUTPUT_TOKEN_FALLBACK
|
||||
const requested = kind === "compaction" ? Math.min(model, SUMMARY_OUTPUT_MAX) : model
|
||||
if (inputTokens === undefined || limit.context <= 0) return requested
|
||||
const room = limit.context - inputTokens.measured - Math.ceil(inputTokens.estimated * (1 + ESTIMATE_ERROR))
|
||||
@@ -271,17 +268,17 @@ export const layer = Layer.effect(
|
||||
const entries = Object.entries(shaped.options)
|
||||
const generation = Object.fromEntries(entries.filter(([k]) => GENERATION_KEYS.has(k))) as GenerationOptionsFields
|
||||
const providerOptions = Object.fromEntries(entries.filter(([k]) => !GENERATION_KEYS.has(k)))
|
||||
const affinity = SessionAffinity.get(session)
|
||||
const affinity = session.parentID ?? session.fork?.sessionID ?? session.id
|
||||
const base = LLM.request({
|
||||
model: model.model,
|
||||
http: {
|
||||
headers: {
|
||||
"x-session-affinity": affinity,
|
||||
"X-Session-Id": affinity,
|
||||
"x-session-affinity": session.id,
|
||||
"X-Session-Id": session.id,
|
||||
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
|
||||
"User-Agent": App.useragent(app),
|
||||
"x-opencode-project": session.projectID,
|
||||
"x-opencode-session": affinity,
|
||||
"x-opencode-session": session.id,
|
||||
"x-opencode-client": app.name,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -207,10 +207,6 @@ export const Plugin = {
|
||||
},
|
||||
(invocation) =>
|
||||
Effect.gen(function* () {
|
||||
invocation.env.AGENT = "1"
|
||||
invocation.env.OPENCODE = "1"
|
||||
invocation.env.AI_AGENT ||= "opencode"
|
||||
invocation.env.OPENCODE_SESSION_ID = context.sessionID
|
||||
finalTimeout = yield* prepare(invocation, context)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -88,11 +88,6 @@ const responsesEffort = (effort: string): Overlay => ({
|
||||
settings: { reasoningEffort: effort, reasoningSummary: "auto", include: ENCRYPTED_REASONING },
|
||||
})
|
||||
|
||||
const xaiResponses: Protocol = (_, support) => {
|
||||
if (support.type !== "effort") return []
|
||||
return efforts(support.values ?? EFFORTS, responsesEffort)
|
||||
}
|
||||
|
||||
const cloudflareAIGateway: Protocol = (model, support) => {
|
||||
const id = modelID(model)
|
||||
if (id.startsWith("openai/")) return openaiResponses(model, support)
|
||||
@@ -562,7 +557,7 @@ const PROTOCOLS: Readonly<Record<string, Protocol>> = {
|
||||
"@opencode/ai/providers/mistral": openaiChat,
|
||||
"@opencode/ai/providers/moonshot/chat": moonshotChat,
|
||||
"@opencode/ai/providers/togetherai": openaiChat,
|
||||
"@opencode/ai/providers/xai": xaiResponses,
|
||||
"@opencode/ai/providers/xai": openaiChat,
|
||||
"@opencode/ai/providers/zai/chat": zaiChat,
|
||||
"@opencode/ai/providers/zai-coding-plan/chat": zaiChat,
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LanguageModel, LLMClient } from "@opencode/ai"
|
||||
import { RequestExecutor } from "@opencode/ai/route"
|
||||
import { LanguageModel } from "@opencode/ai"
|
||||
import { OpenAIChat } from "@opencode/ai/protocols"
|
||||
import { TestLLM } from "@opencode/ai/testing"
|
||||
import { AISDK } from "@opencode/core/aisdk"
|
||||
@@ -11,7 +10,6 @@ import { ID, Info, Model, Ref } from "@opencode/core/model"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const selected = Info.make({
|
||||
@@ -91,56 +89,3 @@ resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(Layer.empty).effect("attributes each stateless completion without creating a stored session", () =>
|
||||
Effect.gen(function* () {
|
||||
const sessions: string[] = []
|
||||
const http = Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.sync(() => {
|
||||
const session = request.headers["x-opencode-session"]
|
||||
if (!session)
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
Response.json(
|
||||
{
|
||||
error: { type: "MissingSessionID", message: "Session ID is required" },
|
||||
},
|
||||
{ status: 400 },
|
||||
),
|
||||
)
|
||||
sessions.push(session)
|
||||
return HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response(
|
||||
`data: ${JSON.stringify({
|
||||
id: "completion",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "gemini",
|
||||
choices: [{ index: 0, delta: { content: "OK" }, finish_reason: "stop" }],
|
||||
})}\n\ndata: [DONE]\n\n`,
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const native = LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(http))))
|
||||
yield* Effect.gen(function* () {
|
||||
const generate = yield* Generate.Service
|
||||
for (let index = 0; index < 2; index++) {
|
||||
expect(
|
||||
yield* generate.text({
|
||||
prompt: "Return exactly OK",
|
||||
model: Ref.make({ providerID: selected.providerID, id: selected.id }),
|
||||
}),
|
||||
).toBe("OK")
|
||||
}
|
||||
}).pipe(Effect.provide(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, native)))))
|
||||
expect(sessions).toHaveLength(2)
|
||||
expect(sessions[0]).toStartWith("ses_")
|
||||
expect(sessions[1]).not.toBe(sessions[0])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { InstructionBuiltIns } from "@opencode/core/instructions/builtins"
|
||||
import { SessionSchema } from "@opencode/core/session/schema"
|
||||
import { location } from "../fixture/location"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { readInitial, readUpdate } from "../lib/instructions"
|
||||
@@ -15,6 +16,7 @@ import { readInitial, readUpdate } from "../lib/instructions"
|
||||
const directory = AbsolutePath.make(FSUtil.resolve("/repo/packages/core"))
|
||||
const projectDirectory = AbsolutePath.make(FSUtil.resolve("/repo"))
|
||||
const timestamp = Date.parse("2026-06-03T12:00:00.000Z")
|
||||
const sessionID = SessionSchema.ID.make("ses_builtin_test")
|
||||
const temporary = os.tmpdir()
|
||||
const localDate = (time: number) => new Date(time).toDateString()
|
||||
const locationLayer = Layer.succeed(
|
||||
@@ -38,20 +40,21 @@ describe("InstructionBuiltIns", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* InstructionBuiltIns.Service
|
||||
const initialized = yield* readInitial(yield* context.load())
|
||||
const initialized = yield* readInitial(yield* context.load(sessionID))
|
||||
|
||||
expect(initialized.text).toBe(
|
||||
[
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
"",
|
||||
"Here is some useful information about the environment you are running in:",
|
||||
"<env>",
|
||||
` Current conversation session ID: ${sessionID}`,
|
||||
` Working directory: ${directory}`,
|
||||
` Workspace root folder: ${projectDirectory}`,
|
||||
" Is directory a git repo: yes",
|
||||
` Platform: ${process.platform}`,
|
||||
` Prefer ${temporary} over generic system temporary directories such as /tmp; it is pre-created and approved for external access.`,
|
||||
"</env>",
|
||||
"",
|
||||
`Today's date: ${localDate(timestamp)}`,
|
||||
].join("\n"),
|
||||
)
|
||||
}),
|
||||
@@ -61,10 +64,10 @@ describe("InstructionBuiltIns", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* InstructionBuiltIns.Service
|
||||
const initialized = yield* readInitial(yield* context.load())
|
||||
const initialized = yield* readInitial(yield* context.load(sessionID))
|
||||
|
||||
yield* TestClock.setTime(timestamp + 24 * 60 * 60 * 1000)
|
||||
const refreshed = yield* readUpdate(yield* context.load(), initialized)
|
||||
const refreshed = yield* readUpdate(yield* context.load(sessionID), initialized)
|
||||
|
||||
expect(refreshed.text).toBe(`Today's date is now: ${localDate(timestamp + 24 * 60 * 60 * 1000)}`)
|
||||
}),
|
||||
@@ -74,10 +77,10 @@ describe("InstructionBuiltIns", () => {
|
||||
Effect.gen(function* () {
|
||||
yield* TestClock.setTime(timestamp)
|
||||
const context = yield* InstructionBuiltIns.Service
|
||||
const initialized = yield* readInitial(yield* context.load())
|
||||
const initialized = yield* readInitial(yield* context.load(sessionID))
|
||||
|
||||
yield* TestClock.setTime(timestamp + 60 * 60 * 1000)
|
||||
expect((yield* readUpdate(yield* context.load(), initialized)).changed).toBe(false)
|
||||
expect((yield* readUpdate(yield* context.load(sessionID), initialized)).changed).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Credential } from "@opencode/core/credential"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { Compatibility, ID, Info, Model, VariantID } from "@opencode/core/model"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { Variant } from "@opencode/core/variant"
|
||||
import { ModelResolver } from "@opencode/core/model-resolver"
|
||||
import { AISDK } from "@opencode/core/aisdk"
|
||||
import { Npm } from "@opencode/util/npm"
|
||||
@@ -581,29 +580,6 @@ describe("ModelResolver", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sends xAI Responses effort variants with reasoning summaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const base = model("@opencode/ai/providers/xai", { providerID: Provider.ID.make("xai"), modelID: "grok-4.6" })
|
||||
const catalog = Info.make({
|
||||
...base,
|
||||
variants: Variant.resolve(base, [{ type: "effort", values: ["low", "high"] }]),
|
||||
})
|
||||
const resolved = yield* ModelResolver.resolveModel(
|
||||
catalog,
|
||||
VariantID.make("high"),
|
||||
Credential.Key.make({ type: "key", key: "secret" }),
|
||||
)
|
||||
const prepared = yield* compileRequest(LLM.request({ model: resolved, prompt: "Hello" }))
|
||||
|
||||
expect(resolved.route.id).toBe("xai-responses")
|
||||
expect(prepared.body).toMatchObject({
|
||||
reasoning: { effort: "high", summary: "auto" },
|
||||
include: ["reasoning.encrypted_content"],
|
||||
})
|
||||
expect(prepared.body).not.toHaveProperty("reasoning_effort")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("overlays selected OpenAI-compatible variant bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = model(Provider.aisdk("@ai-sdk/openai-compatible"), {
|
||||
@@ -1306,7 +1282,7 @@ describe("ModelResolver", () => {
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
expect(String(mistral.provider)).toBe("test-provider")
|
||||
expect(xai.route.id).toBe("xai-responses")
|
||||
expect(xai.route.id).toBe("openai-responses")
|
||||
expect(xai.route.defaults.providerOptions).toEqual({
|
||||
reasoningEffort: "high",
|
||||
store: false,
|
||||
|
||||
@@ -1059,7 +1059,7 @@ describe("ModelsDevPlugin", () => {
|
||||
expect(grok?.variants).toEqual(
|
||||
["low", "medium", "high"].map((id) => ({
|
||||
id: Model.VariantID.make(id),
|
||||
settings: { reasoningEffort: id, reasoningSummary: "auto", include: ["reasoning.encrypted_content"] },
|
||||
settings: { reasoningEffort: id },
|
||||
})),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,19 +4,11 @@ import { Integration } from "@opencode/core/integration"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHost } from "@opencode/core/plugin/host"
|
||||
import { CloudflareAIGatewayPlugin } from "@opencode/core/plugin/provider/cloudflare-ai-gateway"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const providerID = Provider.ID.make("cloudflare-ai-gateway")
|
||||
|
||||
const seed = Effect.fn(function* (fn: (provider: Provider.MutableInfo) => void) {
|
||||
const catalog = yield* Provider.Service
|
||||
yield* catalog.transform((catalog) => catalog.update(providerID, fn))
|
||||
return catalog
|
||||
})
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* Plugin.Service
|
||||
@@ -54,55 +46,4 @@ describe("CloudflareAIGatewayPlugin", () => {
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("requests only the gateway ID when the environment provides the account ID", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "account", CLOUDFLARE_GATEWAY_ID: undefined }, () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const integrations = yield* Integration.Service
|
||||
expect((yield* integrations.get(Integration.ID.make("cloudflare-ai-gateway")))?.methods).toContainEqual({
|
||||
type: "key",
|
||||
label: "Gateway API token",
|
||||
form: [expect.objectContaining({ type: "string", key: "gatewayId", required: true })],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("gives the provider its environment account and gateway IDs", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "account", CLOUDFLARE_GATEWAY_ID: "gateway" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seed(() => {})
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.get(providerID))?.settings).toEqual({ accountId: "account", gatewayId: "gateway" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("prefers configured IDs over environment IDs", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-account", CLOUDFLARE_GATEWAY_ID: "env-gateway" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seed((provider) => {
|
||||
provider.settings = { accountId: "configured-account", gatewayId: "configured-gateway" }
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.get(providerID))?.settings).toEqual({
|
||||
accountId: "configured-account",
|
||||
gatewayId: "configured-gateway",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("leaves a configured endpoint URL untouched", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "account", CLOUDFLARE_GATEWAY_ID: "gateway" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seed((provider) => {
|
||||
provider.settings = { baseURL: "https://proxy.example/v1" }
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect((yield* catalog.get(providerID))?.settings).toEqual({ baseURL: "https://proxy.example/v1" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -119,7 +119,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("uses configured account ID over env account ID", () =>
|
||||
it.effect("uses env account ID over configured account ID", () =>
|
||||
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* seed((provider) => {
|
||||
@@ -129,7 +129,7 @@ describe("CloudflareWorkersAIPlugin", () => {
|
||||
yield* addPlugin()
|
||||
expect(required(yield* catalog.get(providerID))).toMatchObject({
|
||||
package: "aisdk:test-provider",
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/configured-acct/ai/v1" },
|
||||
settings: { baseURL: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -183,11 +183,10 @@ describe("GithubCopilotPlugin", () => {
|
||||
it.effect("classifies child-session steps as subagent interactions", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const ids = yield* sessions()
|
||||
const event = yield* modelRequest(ids.child, "primary")
|
||||
const event = yield* modelRequest((yield* sessions()).child, "primary")
|
||||
expect(event.headers).toEqual({
|
||||
"X-Interaction-Type": "conversation-subagent",
|
||||
"X-Interaction-Id": ids.parent,
|
||||
"X-Interaction-Id": event.sessionID,
|
||||
"x-initiator": "agent",
|
||||
})
|
||||
}),
|
||||
@@ -208,11 +207,10 @@ describe("GithubCopilotPlugin", () => {
|
||||
it.effect("classifies compaction requests by kind rather than agent", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* addPlugin()
|
||||
const ids = yield* sessions()
|
||||
const event = yield* modelRequest(ids.child, "compaction", "build")
|
||||
const event = yield* modelRequest((yield* sessions()).child, "compaction", "build")
|
||||
expect(event.headers).toEqual({
|
||||
"X-Interaction-Type": "conversation-compaction",
|
||||
"X-Interaction-Id": ids.parent,
|
||||
"X-Interaction-Id": event.sessionID,
|
||||
"x-initiator": "agent",
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { OpenAIResponses } from "@opencode/ai/protocols/openai-responses"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, DateTime, Effect } from "effect"
|
||||
@@ -41,14 +41,10 @@ function required<T>(value: T | undefined): T {
|
||||
return value
|
||||
}
|
||||
|
||||
const request = Effect.fn(function* (
|
||||
providerID: Provider.ID,
|
||||
baseURL: string,
|
||||
sessionID = Session.ID.make("ses_test"),
|
||||
) {
|
||||
const request = Effect.fn(function* (providerID: Provider.ID, baseURL: string) {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const event = yield* hooks.trigger("session", "model.request", {
|
||||
sessionID,
|
||||
sessionID: Session.ID.make("ses_test"),
|
||||
agent: Agent.ID.make("build"),
|
||||
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
|
||||
kind: "primary",
|
||||
@@ -160,12 +156,6 @@ describe("OpenAIPlugin", () => {
|
||||
expect(custom.headers).not.toHaveProperty("originator")
|
||||
expect(proxy.baseURL).toBe("https://proxy.example/v1?region=us")
|
||||
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
|
||||
const sessions = yield* Session.Service
|
||||
const location = yield* Location.Service
|
||||
const parent = yield* sessions.create({ location: { directory: location.directory } })
|
||||
const child = yield* sessions.create({ parentID: parent.id })
|
||||
const childRequest = yield* request(Provider.ID.openai, "https://api.openai.com/v1", child.id)
|
||||
expect(childRequest.headers).toMatchObject({ "session-id": parent.id })
|
||||
const eligible = required(yield* models.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
|
||||
expect(eligible.package).toBe("@opencode/ai/providers/openai")
|
||||
expect(eligible.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
|
||||
|
||||
@@ -41,12 +41,6 @@ describe("Repository", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("caches a host with a port under a directory per host part", () => {
|
||||
expect(Repository.cachePath("/cache", Repository.parseRemote("ssh://git@example.com:2222/owner/repo"))).toBe(
|
||||
path.join("/cache", "example.com", "2222", "owner", "repo"),
|
||||
)
|
||||
})
|
||||
|
||||
test("keeps local file repositories distinct from remote repositories", () => {
|
||||
const localPath = path.resolve("repo.git")
|
||||
const reference = Repository.parse(pathToFileURL(localPath).href)
|
||||
@@ -68,18 +62,6 @@ describe("Repository", () => {
|
||||
expect(() => Repository.validateBranch("bad branch")).toThrow(Repository.InvalidBranchError)
|
||||
})
|
||||
|
||||
test.each([
|
||||
"..:repo",
|
||||
"git@..:repo",
|
||||
"../owner/repo",
|
||||
"ssh://../repo",
|
||||
"ssh://..:22/repo",
|
||||
"https://%2e%2e/owner/repo",
|
||||
".:repo",
|
||||
])("rejects %s because its host contains a relative path segment", (input) => {
|
||||
expect(() => Repository.parseRemote(input)).toThrow(Repository.InvalidReferenceError)
|
||||
})
|
||||
|
||||
test("compares cache identity independent of input spelling", () => {
|
||||
const shorthand = Repository.parseRemote("owner/repo")
|
||||
|
||||
|
||||
@@ -445,12 +445,12 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
|
||||
expect(requests).toHaveLength(1)
|
||||
expect(requests[0]?.promptCacheKey).toBe(parentID)
|
||||
expect(requests[0]?.http?.headers).toEqual({
|
||||
"x-session-affinity": parentID,
|
||||
"X-Session-Id": parentID,
|
||||
"x-session-affinity": sessionID,
|
||||
"X-Session-Id": sessionID,
|
||||
"x-parent-session-id": parentID,
|
||||
"User-Agent": App.useragent(App.make()),
|
||||
"x-opencode-project": Project.ID.global,
|
||||
"x-opencode-session": parentID,
|
||||
"x-opencode-session": sessionID,
|
||||
"x-opencode-client": "opencode",
|
||||
})
|
||||
expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 20_000 }))
|
||||
|
||||
@@ -236,7 +236,7 @@ const setup = Effect.gen(function* () {
|
||||
db,
|
||||
bus,
|
||||
session,
|
||||
instructions: yield* instructionBuiltIns.load(),
|
||||
instructions: yield* instructionBuiltIns.load(sessionID),
|
||||
instances: Instance.Service.of({
|
||||
// Generation only exercises the Location's model context.
|
||||
provide: () =>
|
||||
|
||||
@@ -87,7 +87,7 @@ import { permissionLayer } from "./lib/permission"
|
||||
import { agentHost, modelHost, host, noProviders } from "./plugin/host"
|
||||
import { CodeModeInstructions } from "@opencode/core/codemode/instructions"
|
||||
|
||||
const emptyCodeMode = `${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}\n\n`
|
||||
const emptyCodeMode = `\n\n${CodeModeInstructions.render({ total: 0, shown: 0, namespaces: [] })}`
|
||||
type ToolBarrier = {
|
||||
readonly count: number
|
||||
readonly started: Deferred.Deferred<void>
|
||||
@@ -128,7 +128,6 @@ const undersizedContextModel = testModel("undersized-context", { context: 1, out
|
||||
const recoveryModel = testModel("recovery", { context: 200_000, output: 1_000 })
|
||||
const fittedOutputModel = testModel("fitted-output", { context: 100_000, output: 64_000 })
|
||||
const smallWindowModel = testModel("small-window", { context: 64_000, output: 16_000 })
|
||||
const largeOutputModel = testModel("large-output", { context: 1_000_000, output: 1_000_000 })
|
||||
|
||||
test("calculates step cost using the matching context tier", () => {
|
||||
expect(
|
||||
@@ -1875,8 +1874,8 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.runPrompt("Second")
|
||||
|
||||
expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
[defaultSystem, fakeIdentity, "Build skills\n\nInitial context"],
|
||||
[defaultSystem, fakeIdentity, "Build skills\n\nInitial context"],
|
||||
[defaultSystem, fakeIdentity, "Initial context\n\nBuild skills"],
|
||||
[defaultSystem, fakeIdentity, "Initial context\n\nBuild skills"],
|
||||
])
|
||||
expect(systemTexts(s.requests[1])).toContainEqual(expect.stringContaining("Reviewer skills"))
|
||||
})
|
||||
@@ -1898,7 +1897,7 @@ describe("SessionRunnerLLM", () => {
|
||||
yield* s.runPrompt("First")
|
||||
|
||||
expect(s.requests.map((request) => request.system.map((part) => part.text))).toEqual([
|
||||
[defaultSystem, fakeIdentity, "Build skills\n\nInitial context"],
|
||||
[defaultSystem, fakeIdentity, "Initial context\n\nBuild skills"],
|
||||
])
|
||||
})
|
||||
|
||||
@@ -3235,11 +3234,14 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(userTexts(s.requests[1]).at(-1)).toContain("## Objective")
|
||||
expect(userTexts(s.requests[2])[0]).toContain("<summary>\n## Objective\n- Recover overflow\n</summary>")
|
||||
expect(yield* s.context).toMatchObject([
|
||||
{ type: "compaction", summary: "## Objective\n- Recover overflow" },
|
||||
{ type: "compaction", reason: "overflow", summary: "## Objective\n- Recover overflow" },
|
||||
{ type: "assistant", finish: "stop" },
|
||||
])
|
||||
yield* replaySessionProjection(sessionID)
|
||||
expect(yield* s.context).toMatchObject([{ type: "compaction" }, { type: "assistant", finish: "stop" }])
|
||||
expect(yield* s.context).toMatchObject([
|
||||
{ type: "compaction", reason: "overflow" },
|
||||
{ type: "assistant", finish: "stop" },
|
||||
])
|
||||
})
|
||||
|
||||
scenario("refreshes preparation after overflow compaction without promoting new input", function* (s) {
|
||||
@@ -3425,14 +3427,6 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(s.requests[1]?.generation?.maxTokens).toBeGreaterThan(100_000 - 50_000 - 200)
|
||||
})
|
||||
|
||||
scenario("caps the output limit a large model advertises", function* (s) {
|
||||
s.currentModel = largeOutputModel
|
||||
yield* s.llm.push(TestLLM.text("Answer", "text-large-output"))
|
||||
yield* s.runPrompt("Question")
|
||||
|
||||
expect(s.requests[0]?.generation?.maxTokens).toBe(256_000)
|
||||
})
|
||||
|
||||
scenario("gives the summary its full output limit when the conversation overshot the threshold", function* (s) {
|
||||
// The conversation overshot the threshold, so the prepared summary request exceeds the budget and `deliver`
|
||||
// shrinks it before sending. The output limit must follow the budget, not the oversized prepared request.
|
||||
@@ -3485,13 +3479,13 @@ describe("SessionRunnerLLM", () => {
|
||||
expect.objectContaining({
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "auto",
|
||||
reason: "overflow",
|
||||
error: { type: "provider.error", message: "summary unavailable" },
|
||||
}),
|
||||
)
|
||||
expect(context.slice(-3)).toMatchObject([
|
||||
Expected.user("Continue"),
|
||||
{ type: "compaction", status: "failed", reason: "auto" },
|
||||
{ type: "compaction", status: "failed", reason: "overflow" },
|
||||
{ type: "assistant", finish: "error", error: { message: "prompt too long" } },
|
||||
])
|
||||
})
|
||||
@@ -3518,7 +3512,7 @@ describe("SessionRunnerLLM", () => {
|
||||
expect.objectContaining({
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "auto",
|
||||
reason: "overflow",
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
}),
|
||||
)
|
||||
@@ -4702,12 +4696,7 @@ describe("SessionRunnerLLM", () => {
|
||||
.pipe(Effect.orDie)
|
||||
yield* s.runPrompt("Run child request")
|
||||
|
||||
expect(s.requests[0]?.http?.headers).toMatchObject({
|
||||
"x-session-affinity": parentID,
|
||||
"X-Session-Id": parentID,
|
||||
"x-parent-session-id": parentID,
|
||||
"x-opencode-session": parentID,
|
||||
})
|
||||
expect(s.requests[0]?.http?.headers?.["x-parent-session-id"]).toBe(parentID)
|
||||
expect(s.requests[0]?.promptCacheKey).toBe(parentID)
|
||||
})
|
||||
|
||||
|
||||
@@ -956,59 +956,6 @@ describe("ShellTool", () => {
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
productionIt.live(
|
||||
"identifies agent shell commands without inheriting a stale session ID",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
yield* sessions.environment({
|
||||
sessionID,
|
||||
variables: { AGENT: "0", OPENCODE: "0", AI_AGENT: "", OPENCODE_SESSION_ID: "stale" },
|
||||
})
|
||||
const command = isWindows
|
||||
? '[Console]::Out.Write("$env:AGENT|$env:OPENCODE|$env:AI_AGENT|$env:OPENCODE_SESSION_ID")'
|
||||
: 'printf %s "$AGENT|$OPENCODE|$AI_AGENT|$OPENCODE_SESSION_ID"'
|
||||
const settled = yield* executeTool(registry, call({ command }))
|
||||
|
||||
expect(settled.status).toBe("completed")
|
||||
expect(settled.content?.[0]).toEqual({ type: "text", text: `1|1|opencode|${sessionID}` })
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
productionIt.live(
|
||||
"preserves an outer AI_AGENT marker in agent shell commands",
|
||||
() =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
yield* sessions.environment({ sessionID, variables: { AI_AGENT: "outer-agent" } })
|
||||
const command = isWindows ? '[Console]::Out.Write($env:AI_AGENT)' : 'printf %s "$AI_AGENT"'
|
||||
const settled = yield* executeTool(registry, call({ command }))
|
||||
|
||||
expect(settled.status).toBe("completed")
|
||||
expect(settled.content?.[0]).toEqual({ type: "text", text: "outer-agent" })
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
|
||||
),
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
it.live("resolves a relative workdir from the active Location", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -126,26 +126,6 @@ test("spells Cloudflare AI Gateway variants for their upstream routes", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("spells xAI Responses variants with catalog effort levels", () => {
|
||||
const supports: Variant.Support[] = [{ type: "effort", values: ["low", "medium", "high", "xhigh"] }]
|
||||
expect(resolve(model("@opencode/ai/providers/xai", "grok-4.6"), supports)).toEqual(
|
||||
["low", "medium", "high", "xhigh"].map((effort) => ({
|
||||
id: effort,
|
||||
settings: { reasoningEffort: effort, reasoningSummary: "auto", include: ["reasoning.encrypted_content"] },
|
||||
})),
|
||||
)
|
||||
expect(resolve(model("@opencode/ai/providers/xai", "grok-4.3"), [{ type: "effort", values: ["none", "low"] }])).toEqual([
|
||||
{
|
||||
id: "none",
|
||||
settings: { reasoningEffort: "none", reasoningSummary: "auto", include: ["reasoning.encrypted_content"] },
|
||||
},
|
||||
{ id: "low", settings: { reasoningEffort: "low", reasoningSummary: "auto", include: ["reasoning.encrypted_content"] } },
|
||||
])
|
||||
expect(resolve(model("@opencode/ai/providers/xai", "grok-4.6"), [{ type: "effort" }]).map((item) => item.id)).toEqual([
|
||||
"low", "medium", "high",
|
||||
])
|
||||
})
|
||||
|
||||
test("spells Chat Completions variants for direct providers", () => {
|
||||
expect(
|
||||
resolve(model("@opencode/ai/providers/deepseek", "deepseek-v4-flash"), [
|
||||
|
||||
@@ -588,7 +588,7 @@ export namespace Compaction {
|
||||
...options,
|
||||
schema: {
|
||||
...Base,
|
||||
reason: Schema.Literals(["auto", "manual"]),
|
||||
reason: SessionMessage.CompactionRunning.fields.reason,
|
||||
recent: Schema.String,
|
||||
inputID: SessionMessage.ID.pipe(optional),
|
||||
},
|
||||
|
||||
@@ -237,6 +237,9 @@ export const Assistant = Schema.Struct({
|
||||
|
||||
const CompactionBase = { type: Schema.tag("compaction"), ...Base }
|
||||
|
||||
/** `overflow`: the provider rejected the request as too long, before our own size check called for it. */
|
||||
const CompactionReason = Schema.Literals(["auto", "manual", "overflow"])
|
||||
|
||||
/** Usage of the compaction request itself, not the size of the resulting context. */
|
||||
const CompactionUsage = {
|
||||
cost: Money.USD.pipe(optional),
|
||||
@@ -247,7 +250,7 @@ export interface CompactionRunning extends Schema.Schema.Type<typeof CompactionR
|
||||
export const CompactionRunning = Schema.Struct({
|
||||
...CompactionBase,
|
||||
status: Schema.tag("running"),
|
||||
reason: Schema.Literals(["auto", "manual"]),
|
||||
reason: CompactionReason,
|
||||
summary: Schema.String,
|
||||
recent: Schema.String,
|
||||
}).annotate({ identifier: "Session.Message.Compaction.Running" })
|
||||
@@ -256,7 +259,7 @@ export interface CompactionCompleted extends Schema.Schema.Type<typeof Compactio
|
||||
export const CompactionCompleted = Schema.Struct({
|
||||
...CompactionBase,
|
||||
status: Schema.tag("completed"),
|
||||
reason: Schema.Literals(["auto", "manual"]),
|
||||
reason: CompactionReason,
|
||||
model: Model.Ref.pipe(optional),
|
||||
providerState: ProviderState.pipe(optional),
|
||||
summary: Schema.String,
|
||||
@@ -269,7 +272,7 @@ export interface CompactionFailed extends Schema.Schema.Type<typeof CompactionFa
|
||||
export const CompactionFailed = Schema.Struct({
|
||||
...CompactionBase,
|
||||
status: Schema.tag("failed"),
|
||||
reason: Schema.Literals(["auto", "manual"]),
|
||||
reason: CompactionReason,
|
||||
error: SessionError.Error,
|
||||
...CompactionUsage,
|
||||
}).annotate({ identifier: "Session.Message.Compaction.Failed" })
|
||||
|
||||
@@ -263,40 +263,19 @@ export namespace Timeline {
|
||||
)
|
||||
}),
|
||||
]
|
||||
const groupedRows = detail
|
||||
? groupMessages(
|
||||
rows,
|
||||
detail,
|
||||
new Set(
|
||||
messages
|
||||
.filter((message) => message.type === "compaction" || message.type === "model-switched")
|
||||
.map((message) => message.id),
|
||||
),
|
||||
)
|
||||
: rows
|
||||
return {
|
||||
activeMessageID,
|
||||
rows: groupedRows.filter((row, index) => {
|
||||
if (status.type !== "busy" || row.userMessageID !== activeMessageID) return true
|
||||
if (row._tag !== "AssistantPart" || row.group.type !== "context") return true
|
||||
const previous = groupedRows[index - 1]
|
||||
if (
|
||||
previous?.userMessageID === activeMessageID &&
|
||||
previous._tag !== "TurnGap" &&
|
||||
previous._tag !== "UserMessage"
|
||||
)
|
||||
return true
|
||||
if (groupedRows[index + 1]?.userMessageID === activeMessageID) return true
|
||||
return row.group.refs.some((ref) => {
|
||||
const message = messages.find((item) => item.id === ref.messageID)
|
||||
return (
|
||||
message?.type !== "assistant" ||
|
||||
!!message.error ||
|
||||
!!message.retry ||
|
||||
resolveContent(message, ref.partID)?.type !== "reasoning"
|
||||
rows: detail
|
||||
? groupMessages(
|
||||
rows,
|
||||
detail,
|
||||
new Set(
|
||||
messages
|
||||
.filter((message) => message.type === "compaction" || message.type === "model-switched")
|
||||
.map((message) => message.id),
|
||||
),
|
||||
)
|
||||
})
|
||||
}),
|
||||
: rows,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
SessionMessageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { storyDocument, storyTool } from "../storybook/current-session-scenarios"
|
||||
import { timelinePresets } from "./detail"
|
||||
import { createTimelineProjection, Timeline, TimelineRow } from "./projection"
|
||||
|
||||
describe("current session timeline rows", () => {
|
||||
@@ -268,54 +267,6 @@ describe("current session timeline rows", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("omits a grouped reasoning-only row while busy until other content or completion", () => {
|
||||
const thought = { type: "reasoning", text: "Thinking", time: { created: 1, completed: 2 } } as const
|
||||
const rows = (content: SessionMessageAssistant["content"], busy: boolean) =>
|
||||
createTimelineProjection({
|
||||
sessionMessages: storyDocument(content, busy).messages,
|
||||
status: { type: busy ? "busy" : "idle" },
|
||||
reasoningMode: "compact",
|
||||
timelineDetail: timelinePresets[2].value,
|
||||
}).rows
|
||||
|
||||
expect(rows([{ ...thought, time: { created: 1 } }], true).map((row) => row._tag)).toEqual(["UserMessage"])
|
||||
expect(rows([thought], true).map((row) => row._tag)).toEqual(["UserMessage"])
|
||||
expect(rows([thought, { type: "text", text: "" }], true).map((row) => row._tag)).toEqual(["UserMessage"])
|
||||
expect(rows([thought, { type: "text", text: "Answer" }], true).map((row) => row._tag)).toEqual([
|
||||
"UserMessage",
|
||||
"AssistantPart",
|
||||
"AssistantPart",
|
||||
])
|
||||
expect(
|
||||
rows([thought, storyTool("read", "read", "running", { filePath: "package.json" })], true).map((row) => row._tag),
|
||||
).toEqual(["UserMessage", "AssistantPart"])
|
||||
expect(rows([thought], false).map((row) => row._tag)).toEqual(["UserMessage", "AssistantPart"])
|
||||
|
||||
const failed = storyDocument([thought], true)
|
||||
const messages = failed.messages.map((message) =>
|
||||
message.type === "assistant" ? { ...message, error: { type: "provider.error", message: "Failed" } } : message,
|
||||
)
|
||||
expect(
|
||||
createTimelineProjection({
|
||||
sessionMessages: messages,
|
||||
status: failed.status,
|
||||
reasoningMode: "compact",
|
||||
timelineDetail: timelinePresets[2].value,
|
||||
}).rows.map((row) => row._tag),
|
||||
).toEqual(["UserMessage", "AssistantPart", "Error"])
|
||||
|
||||
expect(
|
||||
createTimelineProjection({
|
||||
sessionMessages: failed.messages.map((message) =>
|
||||
message.type === "assistant" ? { ...message, error: { type: "Interrupted", message: "Stopped" } } : message,
|
||||
),
|
||||
status: failed.status,
|
||||
reasoningMode: "compact",
|
||||
timelineDetail: { ...timelinePresets[2].value, notices: { placement: "hidden" } },
|
||||
}).rows.map((row) => row._tag),
|
||||
).toEqual(["UserMessage", "AssistantPart"])
|
||||
})
|
||||
|
||||
test("stops thinking on idle, message completion, errors and retries", () => {
|
||||
const document = storyDocument([{ type: "reasoning", text: "Current thought" }], true)
|
||||
expect(
|
||||
|
||||
@@ -208,8 +208,6 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
|
||||
const projectOptions = data.project
|
||||
.list()
|
||||
.filter((project) => project.canonical !== "/")
|
||||
// Historical project identities can share a checkout. The list is newest-active first.
|
||||
.filter((project, index, projects) => projects.findIndex((item) => item.canonical === project.canonical) === index)
|
||||
.map((project) => ({ directory: project.canonical, project }))
|
||||
.map((item) => {
|
||||
const title =
|
||||
|
||||
@@ -53,7 +53,6 @@ import { usePromptMove } from "./move"
|
||||
import { resolvePastedAttachments } from "./local-attachment"
|
||||
import { locationKey, useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { useArgs } from "../../context/args"
|
||||
import { Keymap, type KeymapCommand } from "../../context/keymap"
|
||||
import { useInteractivity } from "../../context/interactivity"
|
||||
import { abbreviateHome } from "../../runtime"
|
||||
@@ -207,7 +206,6 @@ export function Prompt(props: PromptProps) {
|
||||
const directoryRecents = useDirectoryRecents()
|
||||
const keymapCommands = Keymap.useCommands()
|
||||
const currentLocation = useLocation()
|
||||
const args = useArgs()
|
||||
const config = useConfig().data
|
||||
const dialog = useDialog()
|
||||
const toast = useToast()
|
||||
@@ -1231,9 +1229,7 @@ export function Prompt(props: PromptProps) {
|
||||
// a local session record synchronously, so the navigation below happens
|
||||
// immediately — enter feels sent even while the create round-trip is in
|
||||
// flight. Sends against the new session gate on the request.
|
||||
const newSessionID = args.takeNewSessionID()
|
||||
const created = data.session.create({
|
||||
id: newSessionID,
|
||||
location: directory ? { directory } : location,
|
||||
agent: agent.id,
|
||||
model: {
|
||||
@@ -1242,7 +1238,6 @@ export function Prompt(props: PromptProps) {
|
||||
variant,
|
||||
},
|
||||
})
|
||||
if (newSessionID !== undefined) created.request.catch(() => args.restoreNewSessionID(newSessionID))
|
||||
sessionID = created.id
|
||||
session = data.session.get(created.id)
|
||||
newSession = {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mergeProps } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export interface Args {
|
||||
@@ -7,25 +6,11 @@ export interface Args {
|
||||
prompt?: string
|
||||
continue?: boolean
|
||||
sessionID?: string
|
||||
newSessionID?: string
|
||||
fork?: boolean
|
||||
auto?: boolean
|
||||
}
|
||||
|
||||
export const { use: useArgs, provider: ArgsProvider } = createSimpleContext({
|
||||
name: "Args",
|
||||
init: (props: Args) => {
|
||||
// The first new session created from home takes this ID; later ones mint their own.
|
||||
let pending = props.newSessionID
|
||||
return mergeProps(props, {
|
||||
takeNewSessionID() {
|
||||
const id = pending
|
||||
pending = undefined
|
||||
return id
|
||||
},
|
||||
restoreNewSessionID(id: string) {
|
||||
pending ??= id
|
||||
},
|
||||
})
|
||||
},
|
||||
init: (props: Args) => props,
|
||||
})
|
||||
|
||||
@@ -65,13 +65,12 @@ export function Home() {
|
||||
untrack(() => composer.set(prompt))
|
||||
})
|
||||
|
||||
// Wait for everything submit needs before auto-submitting --prompt; it runs once.
|
||||
// Wait for the model store to be ready before auto-submitting --prompt.
|
||||
createEffect(() => {
|
||||
const r = ref()
|
||||
if (sent) return
|
||||
if (!r) return
|
||||
if (!local.model.ready || !local.model.catalogReady) return
|
||||
if (!local.agent.current() || !local.model.current()) return
|
||||
if (!local.model.ready) return
|
||||
if (!args.prompt) return
|
||||
if (r.current.text !== args.prompt) return
|
||||
sent = true
|
||||
|
||||
@@ -2052,6 +2052,9 @@ function CompactionMessage(props: { message: Extract<SessionMessageInfo, { type:
|
||||
? "Provider compaction"
|
||||
: "Compaction"}
|
||||
</text>
|
||||
<Show when={props.message.reason === "overflow"}>
|
||||
<text fg={color()}>· context overflow</text>
|
||||
</Show>
|
||||
<Show when={cancelled()}>
|
||||
<text fg={color()}>· cancelled</text>
|
||||
</Show>
|
||||
@@ -2808,7 +2811,7 @@ function Shell(props: ToolProps) {
|
||||
command={stringValue(props.input.command)}
|
||||
workdir={stringValue(props.input.workdir)}
|
||||
status={props.part.state.status}
|
||||
background={props.part.state.status === "completed" && props.metadata.status === "running"}
|
||||
background={Boolean(stringValue(props.metadata.shellID)) && props.part.state.status !== "running"}
|
||||
output={stringValue(props.metadata.shellID) ? undefined : props.output}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -876,61 +876,6 @@ test("session startup prompt is submitted exactly once", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("home startup prompt is submitted exactly once", async () => {
|
||||
await using state = await tmpdir()
|
||||
const cwd = process.cwd()
|
||||
const location = { directory: cwd, project: { id: "project", directory: cwd, canonical: cwd } }
|
||||
const bodies: unknown[] = []
|
||||
const submitted = Promise.withResolvers<void>()
|
||||
let session: unknown
|
||||
await using setup = await createAppFixture({
|
||||
state: state.path,
|
||||
args: { prompt: "HOME_READY" },
|
||||
config: { animations: false, tabs: { mode: "off" } },
|
||||
fetch: async (url, request) => {
|
||||
if (url.pathname === "/api/location") return json(location)
|
||||
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
|
||||
if (url.pathname === "/api/model")
|
||||
return json({ location, data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }] })
|
||||
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
|
||||
if (url.pathname === "/api/session" && request.method === "POST") {
|
||||
const input: unknown = await request.json()
|
||||
if (typeof input !== "object" || input === null) throw new Error("Expected a session input")
|
||||
session = {
|
||||
...input,
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
return json({ data: session })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/prompt$/.test(url.pathname)) {
|
||||
bodies.push(await request.json())
|
||||
submitted.resolve()
|
||||
return json({ data: {} })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
|
||||
return json({ data: [], cursor: {} })
|
||||
if (session && /^\/api\/session\/[^/]+$/.test(url.pathname)) return json({ data: session })
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
|
||||
await setup.ready
|
||||
await Promise.race([
|
||||
submitted.promise,
|
||||
Bun.sleep(2000).then(() => {
|
||||
throw new Error("startup prompt was not submitted")
|
||||
}),
|
||||
])
|
||||
await Bun.sleep(20)
|
||||
expect(bodies).toHaveLength(1)
|
||||
expect(bodies[0]).toMatchObject({ text: "HOME_READY" })
|
||||
})
|
||||
|
||||
test.each([false, true])("uses the resolved launch directory for new prompts (fallback: %s)", async (fallback) => {
|
||||
await using state = await tmpdir()
|
||||
const target = fallback ? directory : process.cwd()
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createAppFixture } from "./fixture/app"
|
||||
import { json } from "./fixture/tui-client"
|
||||
|
||||
type SessionInput = { id?: string; location?: { directory: string } }
|
||||
|
||||
const location = {
|
||||
directory: "/tmp/opencode/packages/tui",
|
||||
project: { id: "project", directory: "/tmp/opencode", canonical: "/tmp/opencode" },
|
||||
}
|
||||
|
||||
function sessionInfo(record: SessionInput) {
|
||||
return {
|
||||
...record,
|
||||
location: record.location ?? { directory: location.directory },
|
||||
projectID: "project",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
async function launch(options: { failCreates?: number } = {}) {
|
||||
const attempts: (string | undefined)[] = []
|
||||
const created: SessionInput[] = []
|
||||
const prompts: string[] = []
|
||||
let failures = options.failCreates ?? 0
|
||||
const setup = await createAppFixture({
|
||||
args: { newSessionID: "ses_chosen" },
|
||||
config: { animations: false, keybinds: { "session.new": "f6" } },
|
||||
fetch: async (url, request) => {
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
|
||||
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "demo", name: "Demo" }] })
|
||||
if (url.pathname === "/api/model")
|
||||
return json({ location, data: [{ id: "model", providerID: "demo", name: "Demo Model", variants: [] }] })
|
||||
if (url.pathname === "/api/session" && request.method === "POST") {
|
||||
const record: SessionInput = await request.json()
|
||||
attempts.push(record.id)
|
||||
if (failures > 0) {
|
||||
failures--
|
||||
return json({ message: "create failed" }, { status: 500 })
|
||||
}
|
||||
created.push(record)
|
||||
return json({ data: sessionInfo(record) })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/prompt$/.test(url.pathname)) {
|
||||
prompts.push(url.pathname.split("/")[3] ?? "")
|
||||
return json({ data: {} })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
|
||||
return json({ data: [], cursor: {} })
|
||||
if (/^\/api\/session\/[^/]+\/(agent|model)$/.test(url.pathname)) return new Response(null, { status: 204 })
|
||||
if (/^\/api\/session\/[^/]+$/.test(url.pathname)) {
|
||||
const record = created.find((item) => item.id === url.pathname.split("/")[3])
|
||||
if (!record) return json({ message: "not found" }, { status: 404 })
|
||||
return json({ data: sessionInfo(record) })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
return { setup, attempts, created, prompts }
|
||||
}
|
||||
|
||||
test("the first new session uses the launch session ID and later ones mint their own", async () => {
|
||||
const run = await launch()
|
||||
await using setup = run.setup
|
||||
|
||||
await setup.ready
|
||||
await setup.waitForFrame((frame) => frame.includes("Demo Model"))
|
||||
await setup.mockInput.typeText("hello")
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitForFrame(() => run.prompts.length === 1)
|
||||
expect(run.prompts[0]).toBe("ses_chosen")
|
||||
expect(run.created.map((item) => item.id)).toEqual(["ses_chosen"])
|
||||
|
||||
setup.mockInput.pressKey("F6")
|
||||
await setup.renderOnce()
|
||||
await setup.mockInput.typeText("again")
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitForFrame(() => run.created.length === 2)
|
||||
expect(run.created[1]?.id).toMatch(/^ses/)
|
||||
expect(run.created[1]?.id).not.toBe("ses_chosen")
|
||||
})
|
||||
|
||||
test("a failed first create keeps the launch session ID for the retry", async () => {
|
||||
const run = await launch({ failCreates: 1 })
|
||||
await using setup = run.setup
|
||||
|
||||
await setup.ready
|
||||
await setup.waitForFrame((frame) => frame.includes("Demo Model"))
|
||||
await setup.mockInput.typeText("hello")
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitForFrame((frame) => frame.includes("Creating a session failed") && frame.includes("hello"))
|
||||
expect(run.attempts).toEqual(["ses_chosen"])
|
||||
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitForFrame(() => run.created.length === 1)
|
||||
expect(run.attempts).toEqual(["ses_chosen", "ses_chosen"])
|
||||
})
|
||||
@@ -283,7 +283,6 @@ https://opencode.ai/zen/v1/models
|
||||
- Nemotron 3 Ultra Free (نقاط نهاية NVIDIA المجانية): للاستخدام التجريبي فقط — لا ترسل بيانات شخصية أو سرية. يُسجَّل استخدامك لأغراض أمنية ولتحسين منتجات وخدمات NVIDIA. بيانات الجلسة المُسجَّلة لأغراض التحسين غير مرتبطة بهويتك أو بأي مُعرِّف دائم. لمزيد من المعلومات حول ممارسات معالجة البيانات لدينا، راجع [سياسة الخصوصية](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). بتفاعلك مع نقطة النهاية هذه، فإنك توافق على جمعنا لهذه المعلومات وتسجيلها واستخدامها وعلى [شروط خدمة النسخة التجريبية من واجهة NVIDIA API](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
- Jev APIs: لا تُستخدم المطالبات والمدخلات الأخرى في التدريب. يُحتفظ بالبيانات وفقًا لـ [سياسة الخصوصية الخاصة بـ TypeSafe AI](https://typesafe.ai/legal/privacy-policy).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -295,7 +295,6 @@ i ne koriste vaše podatke za treniranje modela, uz sljedeće izuzetke:
|
||||
- Nemotron 3 Ultra Free (besplatni NVIDIA endpointi): Samo za probnu upotrebu — nemojte slati lične ili povjerljive podatke. Vaše korištenje se bilježi radi sigurnosti i poboljšanja NVIDIA proizvoda i usluga. Zabilježeni podaci sesije koji se koriste u svrhu poboljšanja nisu povezani s vašim identitetom niti bilo kojim trajnim identifikatorom. Za više informacija o našim praksama obrade podataka pogledajte našu [Politiku privatnosti](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Interakcijom s ovim endpointom pristajete na naše prikupljanje, bilježenje i korištenje takvih informacija te na [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
- Jev APIs: Promptovi i drugi ulazni podaci ne koriste se za treniranje modela. Podaci se čuvaju u skladu s [Politikom privatnosti TypeSafe AI](https://typesafe.ai/legal/privacy-policy).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -293,7 +293,6 @@ Alle vores modeller hostes i US. Vores udbydere følger en nul-opbevaringspoliti
|
||||
- Nemotron 3 Ultra Free (gratis NVIDIA-endpoints): Kun til prøvebrug — indsend ikke personlige eller fortrolige data. Din brug logges af sikkerhedshensyn og for at forbedre NVIDIAs produkter og tjenester. De loggede sessionsdata, der bruges til forbedringsformål, er ikke knyttet til din identitet eller nogen vedvarende identifikator. For mere information om vores databehandlingspraksis, se vores [privatlivspolitik](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Ved at interagere med dette endpoint giver du samtykke til vores indsamling, registrering og brug af sådanne oplysninger samt [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
- Jev APIs: Prompts og andre input bruges ikke til træning. Data opbevares i overensstemmelse med [TypeSafe AI's privatlivspolitik](https://typesafe.ai/legal/privacy-policy).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -279,7 +279,6 @@ Alle unsere Modelle werden in den USA gehostet. Unsere Provider folgen einer Zer
|
||||
- Nemotron 3 Ultra Free (kostenlose NVIDIA-Endpunkte): Nur für Testzwecke — übermitteln Sie keine personenbezogenen oder vertraulichen Daten. Ihre Nutzung wird zu Sicherheitszwecken und zur Verbesserung der Produkte und Dienste von NVIDIA protokolliert. Die zu Verbesserungszwecken protokollierten Sitzungsdaten sind nicht mit Ihrer Identität oder einem dauerhaften Identifikator verknüpft. Weitere Informationen zu unseren Datenverarbeitungspraktiken finden Sie in unserer [Datenschutzrichtlinie](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Durch die Interaktion mit diesem Endpunkt stimmen Sie unserer Erhebung, Aufzeichnung und Nutzung solcher Informationen sowie den [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) zu.
|
||||
- OpenAI APIs: Anfragen werden in Übereinstimmung mit [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 30 Tage lang gespeichert.
|
||||
- Anthropic APIs: Anfragen werden in Übereinstimmung mit [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 30 Tage lang gespeichert.
|
||||
- Jev APIs: Prompts und andere Eingaben werden nicht für das Training verwendet. Daten werden gemäß der [Datenschutzrichtlinie von TypeSafe AI](https://typesafe.ai/legal/privacy-policy) gespeichert.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -293,7 +293,6 @@ Todos nuestros modelos están alojados en US. Nuestros proveedores siguen una po
|
||||
- Nemotron 3 Ultra Free (endpoints gratuitos de NVIDIA): Solo para uso de prueba — no envíes datos personales ni confidenciales. Tu uso se registra con fines de seguridad y para mejorar los productos y servicios de NVIDIA. Los datos de sesión registrados con fines de mejora no están vinculados a tu identidad ni a ningún identificador persistente. Para obtener más información sobre nuestras prácticas de procesamiento de datos, consulta nuestra [Política de privacidad](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Al interactuar con este endpoint, aceptas que recopilemos, registremos y usemos dicha información, así como los [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Las solicitudes se conservan durante 30 días de acuerdo con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: Las solicitudes se conservan durante 30 días de acuerdo con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
- Jev APIs: Los prompts y otras entradas no se usan para el entrenamiento. Los datos se conservan de acuerdo con la [política de privacidad de TypeSafe AI](https://typesafe.ai/legal/privacy-policy).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -279,7 +279,6 @@ Tous nos modèles sont hébergés aux US. Nos fournisseurs suivent une politique
|
||||
- Nemotron 3 Ultra Free (endpoints NVIDIA gratuits) : Réservé à un usage d'essai — n'envoyez pas de données personnelles ou confidentielles. Votre utilisation est journalisée à des fins de sécurité et pour améliorer les produits et services de NVIDIA. Les données de session journalisées à des fins d'amélioration ne sont pas liées à votre identité ni à un quelconque identifiant persistant. Pour plus d'informations sur nos pratiques de traitement des données, consultez notre [Politique de confidentialité](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). En interagissant avec cet endpoint, vous consentez à notre collecte, à notre enregistrement et à notre utilisation de ces informations ainsi qu'aux [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs : Les requêtes sont conservées pendant 30 jours conformément à [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs : Les requêtes sont conservées pendant 30 jours conformément à [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
- Jev APIs : Les prompts et autres données saisies ne sont pas utilisés pour l'entraînement. Les données sont conservées conformément à la [politique de confidentialité de TypeSafe AI](https://typesafe.ai/legal/privacy-policy).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -293,7 +293,6 @@ Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una pol
|
||||
- Nemotron 3 Ultra Free (endpoint NVIDIA gratuiti): solo per uso di prova — non inviare dati personali o riservati. Il tuo utilizzo viene registrato per finalità di sicurezza e per migliorare i prodotti e i servizi di NVIDIA. I dati di sessione registrati a fini di miglioramento non sono collegati alla tua identità né ad alcun identificatore persistente. Per maggiori informazioni sulle nostre pratiche di trattamento dei dati, consulta la nostra [Informativa sulla privacy](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Interagendo con questo endpoint, acconsenti alla nostra raccolta, registrazione e utilizzo di tali informazioni e ai [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: le richieste vengono conservate per 30 giorni in conformità con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: le richieste vengono conservate per 30 giorni in conformità con [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
- Jev APIs: i prompt e gli altri input non vengono usati per l'addestramento. I dati vengono conservati in conformità con l'[informativa sulla privacy di TypeSafe AI](https://typesafe.ai/legal/privacy-policy).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -279,7 +279,6 @@ https://opencode.ai/zen/v1/models
|
||||
- Nemotron 3 Ultra Free(NVIDIA の無料エンドポイント): 試用専用です — 個人情報や機密データは送信しないでください。お客様の利用は、セキュリティ目的および NVIDIA の製品とサービスの改善のために記録されます。改善目的で記録されたセッションデータは、お客様の身元や永続的な識別子とは関連付けられません。当社のデータ処理慣行の詳細については、[プライバシーポリシー](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)をご覧ください。このエンドポイントを利用することで、お客様はそのような情報の当社による収集、記録、利用、および [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) に同意したものとみなされます。
|
||||
- OpenAI APIs: リクエストは [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) に従って 30 日間保持されます。
|
||||
- Anthropic APIs: リクエストは [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) に従って 30 日間保持されます。
|
||||
- Jev APIs: プロンプトやその他の入力は学習に使用されません。データは [TypeSafe AI のプライバシーポリシー](https://typesafe.ai/legal/privacy-policy) に従って保持されます。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -279,7 +279,6 @@ https://opencode.ai/zen/v1/models
|
||||
- Nemotron 3 Ultra Free(NVIDIA 무료 엔드포인트): 평가판 전용이며 — 개인 정보나 기밀 데이터는 제출하지 마세요. 사용 내역은 보안 목적과 NVIDIA 제품 및 서비스 개선을 위해 기록됩니다. 개선 목적으로 기록된 세션 데이터는 사용자의 신원이나 영구 식별자와 연결되지 않습니다. 당사의 데이터 처리 관행에 대한 자세한 내용은 [개인정보처리방침](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)을 참조하세요. 이 엔드포인트와 상호 작용함으로써 사용자는 당사가 이러한 정보를 수집, 기록, 사용하는 것과 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)에 동의하게 됩니다.
|
||||
- OpenAI APIs: 요청은 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data)에 따라 30일 동안 보관됩니다.
|
||||
- Anthropic APIs: 요청은 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage)에 따라 30일 동안 보관됩니다.
|
||||
- Jev APIs: 프롬프트와 기타 입력은 학습에 사용되지 않습니다. 데이터는 [TypeSafe AI의 개인정보처리방침](https://typesafe.ai/legal/privacy-policy)에 따라 보관됩니다.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -293,7 +293,6 @@ Alle modellene våre hostes i US. Leverandørene våre følger en policy for zer
|
||||
- Nemotron 3 Ultra Free (gratis NVIDIA-endepunkter): Kun for prøvebruk — ikke send inn personopplysninger eller konfidensielle data. Bruken din logges av sikkerhetshensyn og for å forbedre NVIDIAs produkter og tjenester. Sesjonsdataene som logges for forbedringsformål, er ikke knyttet til identiteten din eller noen vedvarende identifikator. For mer informasjon om vår databehandlingspraksis, se vår [personvernerklæring](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Ved å samhandle med dette endepunktet samtykker du til at vi samler inn, registrerer og bruker slik informasjon, samt til [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Forespørsler lagres i 30 dager i samsvar med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: Forespørsler lagres i 30 dager i samsvar med [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
- Jev APIs: Ledetekster og andre inndata brukes ikke til trening. Data lagres i samsvar med [TypeSafe AIs personvernerklæring](https://typesafe.ai/legal/privacy-policy).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -293,7 +293,6 @@ Wszystkie nasze modele są hostowane w US. Nasi dostawcy stosują politykę zero
|
||||
- Nemotron 3 Ultra Free (darmowe endpointy NVIDIA): Tylko do użytku próbnego — nie przesyłaj danych osobowych ani poufnych. Twoje korzystanie jest rejestrowane w celach bezpieczeństwa oraz w celu ulepszania produktów i usług NVIDIA. Rejestrowane dane sesji wykorzystywane do celów ulepszania nie są powiązane z Twoją tożsamością ani żadnym trwałym identyfikatorem. Aby uzyskać więcej informacji o naszych praktykach przetwarzania danych, zapoznaj się z naszą [Polityką prywatności](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Korzystając z tego endpointu, wyrażasz zgodę na gromadzenie, rejestrowanie i wykorzystywanie przez nas takich informacji oraz na [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Żądania są przechowywane przez 30 dni zgodnie z [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: Żądania są przechowywane przez 30 dni zgodnie z [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
- Jev APIs: Prompty i inne dane wejściowe nie są wykorzystywane do trenowania modeli. Dane są przechowywane zgodnie z [Polityką prywatności TypeSafe AI](https://typesafe.ai/legal/privacy-policy).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -279,7 +279,6 @@ Todos os nossos modelos são hospedados nos US. Nossos provedores seguem uma pol
|
||||
- Nemotron 3 Ultra Free (endpoints gratuitos da NVIDIA): Apenas para uso de avaliação — não envie dados pessoais ou confidenciais. Seu uso é registrado para fins de segurança e para melhorar os produtos e serviços da NVIDIA. Os dados de sessão registrados para fins de melhoria não estão vinculados à sua identidade nem a qualquer identificador persistente. Para mais informações sobre nossas práticas de processamento de dados, consulte nossa [Política de Privacidade](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Ao interagir com este endpoint, você consente com a nossa coleta, registro e uso dessas informações e com os [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: As solicitações são retidas por 30 dias de acordo com [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: As solicitações são retidas por 30 dias de acordo com [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
- Jev APIs: Os prompts e outras entradas não são usados para treinamento. Os dados são retidos de acordo com a [Política de Privacidade da TypeSafe AI](https://typesafe.ai/legal/privacy-policy).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -293,7 +293,6 @@ https://opencode.ai/zen/v1/models
|
||||
- Nemotron 3 Ultra Free (бесплатные эндпоинты NVIDIA): только для пробного использования — не отправляйте персональные или конфиденциальные данные. Использование логируется в целях безопасности и для улучшения продуктов и сервисов NVIDIA. Логируемые данные сессии, используемые в целях улучшения, не связаны с вашей личностью или каким-либо постоянным идентификатором. Подробнее о наших практиках обработки данных см. в нашей [Политике конфиденциальности](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Взаимодействуя с этим эндпоинтом, вы соглашаетесь на сбор, запись и использование нами такой информации, а также с [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: запросы хранятся 30 дней в соответствии с [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: запросы хранятся 30 дней в соответствии с [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
- Jev APIs: запросы и другие входные данные не используются для обучения. Данные хранятся в соответствии с [Политикой конфиденциальности TypeSafe AI](https://typesafe.ai/legal/privacy-policy).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -281,7 +281,6 @@ https://opencode.ai/zen/v1/models
|
||||
- Nemotron 3 Ultra Free (endpoint ฟรีของ NVIDIA): ใช้สำหรับการทดลองเท่านั้น — โปรดอย่าส่งข้อมูลส่วนบุคคลหรือข้อมูลลับ การใช้งานของคุณจะถูกบันทึกเพื่อวัตถุประสงค์ด้านความปลอดภัยและเพื่อปรับปรุงผลิตภัณฑ์และบริการของ NVIDIA ข้อมูลเซสชันที่บันทึกไว้เพื่อวัตถุประสงค์ในการปรับปรุงจะไม่เชื่อมโยงกับตัวตนของคุณหรือตัวระบุถาวรใด ๆ สำหรับข้อมูลเพิ่มเติมเกี่ยวกับแนวปฏิบัติในการประมวลผลข้อมูลของเรา โปรดดู [นโยบายความเป็นส่วนตัว](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) ของเรา การโต้ตอบกับ endpoint นี้ถือว่าคุณยินยอมให้เราเก็บรวบรวม บันทึก และใช้ข้อมูลดังกล่าว รวมถึง [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)
|
||||
- OpenAI APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
- Jev APIs: พรอมป์ต์และข้อมูลนำเข้าอื่น ๆ จะไม่ถูกนำไปใช้ฝึกโมเดล ข้อมูลจะถูกเก็บรักษาตาม [นโยบายความเป็นส่วนตัวของ TypeSafe AI](https://typesafe.ai/legal/privacy-policy).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -279,7 +279,6 @@ Tüm modellerimiz US'de barındırılıyor. Sağlayıcılarımız zero-retention
|
||||
- Nemotron 3 Ultra Free (ücretsiz NVIDIA uç noktaları): Yalnızca deneme amaçlıdır — kişisel veya gizli veri göndermeyin. Kullanımınız güvenlik amacıyla ve NVIDIA ürünlerini ve hizmetlerini geliştirmek için kaydedilir. Geliştirme amacıyla kaydedilen oturum verileri kimliğinizle veya herhangi bir kalıcı tanımlayıcıyla ilişkilendirilmez. Veri işleme uygulamalarımız hakkında daha fazla bilgi için [Gizlilik Politikamıza](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) bakın. Bu uç noktayla etkileşime geçerek, bu tür bilgileri toplamamıza, kaydetmemize ve kullanmamıza ve [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) koşullarına onay vermiş olursunuz.
|
||||
- OpenAI APIs: İstekler [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) uyarınca 30 gün boyunca saklanır.
|
||||
- Anthropic APIs: İstekler [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) uyarınca 30 gün boyunca saklanır.
|
||||
- Jev APIs: İstemler ve diğer girdiler eğitim için kullanılmaz. Veriler [TypeSafe AI Gizlilik Politikası](https://typesafe.ai/legal/privacy-policy) uyarınca saklanır.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -293,7 +293,6 @@ All our models are hosted in the US. Our providers follow a zero-retention polic
|
||||
- Nemotron 3 Ultra Free (NVIDIA free endpoints): Trial use only — do not submit personal or confidential data. Your use is logged for security purposes and to improve NVIDIA products and services. The logged session data for improvement purposes is not linked to your identity or any persistent identifier. For more information about our data processing practices, see our [Privacy Policy](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). By interacting with this endpoint, you consent to our collection, recording, and use of such information and the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf).
|
||||
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
|
||||
- Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).
|
||||
- Jev APIs: Prompts and other inputs are not used for training. Data is retained in accordance with [TypeSafe AI's Privacy Policy](https://typesafe.ai/legal/privacy-policy).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -279,7 +279,6 @@ https://opencode.ai/zen/v1/models
|
||||
- Nemotron 3 Ultra Free(NVIDIA 免费端点):仅供试用 — 请勿提交个人或机密数据。出于安全目的以及为改进 NVIDIA 产品和服务,系统会记录你的使用情况。出于改进目的而记录的会话数据不会与你的身份或任何持久标识符相关联。有关我们数据处理实践的更多信息,请参阅我们的[隐私政策](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。与此端点进行交互,即表示你同意我们收集、记录和使用此类信息,并同意 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。
|
||||
- OpenAI APIs:请求会根据 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。
|
||||
- Anthropic APIs:请求会根据 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。
|
||||
- Jev APIs:提示词和其他输入不会用于训练。数据会根据 [TypeSafe AI 的隐私政策](https://typesafe.ai/legal/privacy-policy) 保留。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -285,7 +285,6 @@ https://opencode.ai/zen/v1/models
|
||||
- Nemotron 3 Ultra Free(NVIDIA 免費端點):僅供試用 — 請勿提交個人或機密資料。基於安全目的以及為了改進 NVIDIA 產品與服務,系統會記錄你的使用情況。基於改進目的而記錄的工作階段資料不會與你的身分或任何持久識別碼相關聯。有關我們資料處理實務的更多資訊,請參閱我們的[隱私政策](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。與此端點進行互動,即表示你同意我們收集、記錄與使用此類資訊,並同意 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。
|
||||
- OpenAI APIs: 請求會依據 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。
|
||||
- Anthropic APIs: 請求會依據 [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage) 保留 30 天。
|
||||
- Jev APIs: 提示詞與其他輸入不會用於訓練。資料會依據 [TypeSafe AI 的隱私政策](https://typesafe.ai/legal/privacy-policy) 保留。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+2
-4
@@ -61,9 +61,7 @@ if ((await editor.exited) !== 0) {
|
||||
const document = await Bun.file(review).text()
|
||||
const notes = document.match(/<!-- changelog:start -->\s*([\s\S]*?)\s*<!-- changelog:end -->/)
|
||||
if (!notes) throw new Error("Release review is missing its changelog markers")
|
||||
const releaseNotes = notes[1].trim()
|
||||
if (!releaseNotes) throw new Error("Release review has no changelog")
|
||||
await Bun.write(changelog, `${releaseNotes}\n`)
|
||||
await Bun.write(changelog, `${notes[1].trim()}\n`)
|
||||
|
||||
const answer = prompt(`Trigger the ${version} release? [y/N]`)
|
||||
if (answer?.trim().toLowerCase() !== "y" && answer?.trim().toLowerCase() !== "yes") {
|
||||
@@ -71,7 +69,7 @@ if (answer?.trim().toLowerCase() !== "y" && answer?.trim().toLowerCase() !== "ye
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await $`gh workflow run publish.yml --ref v2 ${input} -f release_notes=${releaseNotes}`
|
||||
await $`gh workflow run publish.yml --ref v2 ${input}`
|
||||
console.log(`Triggered the ${version} release`)
|
||||
|
||||
async function generateReview(base: string) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import Card from "./Card.astro"
|
||||
import CardGroup from "./CardGroup.astro"
|
||||
import CodeBlock from "./CodeBlock.astro"
|
||||
import CodeTabs from "./CodeTabs.astro"
|
||||
import PlanTabs from "./PlanTabs.astro"
|
||||
import DocsLayout from "../layouts/DocsLayout.astro"
|
||||
|
||||
interface Props {
|
||||
@@ -23,5 +22,5 @@ const rendered = await render(entry)
|
||||
headings={rendered.headings}
|
||||
showTableOfContents={entry.data.tableOfContents !== false}
|
||||
>
|
||||
<rendered.Content components={{ Callout, Card, CardGroup, CodeBlock, CodeTabs, PlanTabs }} />
|
||||
<rendered.Content components={{ Callout, Card, CardGroup, CodeBlock, CodeTabs }} />
|
||||
</DocsLayout>
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
---
|
||||
interface Props {
|
||||
id: string
|
||||
label: string
|
||||
syncKey: string
|
||||
}
|
||||
|
||||
const plans = [
|
||||
{ id: "go", label: "Go" },
|
||||
{ id: "go-plus", label: "Go Plus" },
|
||||
] as const
|
||||
---
|
||||
|
||||
<div class="docs-plan-tabs" data-plan-tabs data-sync-key={Astro.props.syncKey}>
|
||||
<div class="docs-plan-tabs-list" role="tablist" aria-label={Astro.props.label}>
|
||||
{
|
||||
plans.map((plan, index) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`${Astro.props.id}-tab-${plan.id}`}
|
||||
aria-controls={`${Astro.props.id}-panel-${plan.id}`}
|
||||
aria-selected={index === 0 ? "true" : "false"}
|
||||
tabindex={index === 0 ? 0 : -1}
|
||||
data-plan-tab={plan.id}
|
||||
>
|
||||
{plan.label}
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
{
|
||||
plans.map((plan, index) => (
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`${Astro.props.id}-panel-${plan.id}`}
|
||||
aria-labelledby={`${Astro.props.id}-tab-${plan.id}`}
|
||||
hidden={index !== 0}
|
||||
data-plan-panel={plan.id}
|
||||
>
|
||||
<slot name={plan.id} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const selectPlan = (tabs: HTMLElement, plan: string) => {
|
||||
tabs.querySelectorAll<HTMLButtonElement>("[data-plan-tab]").forEach((button) => {
|
||||
const selected = button.dataset.planTab === plan
|
||||
button.setAttribute("aria-selected", String(selected))
|
||||
button.tabIndex = selected ? 0 : -1
|
||||
})
|
||||
tabs.querySelectorAll<HTMLElement>("[data-plan-panel]").forEach((panel) => {
|
||||
panel.hidden = panel.dataset.planPanel !== plan
|
||||
})
|
||||
}
|
||||
|
||||
const selectSyncedPlan = (source: HTMLElement, plan: string) => {
|
||||
const syncKey = source.dataset.syncKey
|
||||
if (!syncKey) return
|
||||
document.querySelectorAll<HTMLElement>("[data-plan-tabs]").forEach((tabs) => {
|
||||
if (tabs.dataset.syncKey === syncKey) selectPlan(tabs, plan)
|
||||
})
|
||||
localStorage.setItem(`docs-plan-tabs:${syncKey}`, plan)
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>("[data-plan-tabs]").forEach((tabs) => {
|
||||
const syncKey = tabs.dataset.syncKey
|
||||
const plan = syncKey ? localStorage.getItem(`docs-plan-tabs:${syncKey}`) : undefined
|
||||
if (plan) selectPlan(tabs, plan)
|
||||
})
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!(event.target instanceof Element)) return
|
||||
const button = event.target.closest<HTMLButtonElement>("[data-plan-tab]")
|
||||
const tabs = button?.closest<HTMLElement>("[data-plan-tabs]")
|
||||
if (!button || !tabs || !button.dataset.planTab) return
|
||||
selectSyncedPlan(tabs, button.dataset.planTab)
|
||||
})
|
||||
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!(event.target instanceof HTMLButtonElement) || !event.target.matches("[data-plan-tab]")) return
|
||||
const tabs = event.target.closest<HTMLElement>("[data-plan-tabs]")
|
||||
if (!tabs) return
|
||||
const buttons = [...tabs.querySelectorAll<HTMLButtonElement>("[data-plan-tab]")]
|
||||
const selected = buttons.indexOf(event.target)
|
||||
const next =
|
||||
event.key === "Home"
|
||||
? buttons[0]
|
||||
: event.key === "End"
|
||||
? buttons.at(-1)
|
||||
: event.key === "ArrowRight"
|
||||
? buttons[(selected + 1) % buttons.length]
|
||||
: event.key === "ArrowLeft"
|
||||
? buttons[(selected - 1 + buttons.length) % buttons.length]
|
||||
: undefined
|
||||
if (!next?.dataset.planTab) return
|
||||
event.preventDefault()
|
||||
selectSyncedPlan(tabs, next.dataset.planTab)
|
||||
next.focus()
|
||||
})
|
||||
</script>
|
||||
@@ -1,22 +1,17 @@
|
||||
---
|
||||
title: "Go"
|
||||
description: "Reliable access to open coding models with two usage tiers."
|
||||
description: "Low cost subscription for open coding models."
|
||||
---
|
||||
|
||||
OpenCode Go gives you reliable access to popular open coding models, with two monthly plans:
|
||||
|
||||
| Plan | Price | Included usage |
|
||||
| ---- | ----- | -------------- |
|
||||
| **Go** | **$10/month** | Lower-cost access to the models below |
|
||||
| **Go Plus** | **$40/month** | Higher usage limits across the models below |
|
||||
OpenCode Go is a low cost **$10/month subscription** that gives you reliable access to popular open coding models.
|
||||
|
||||
Go works like any other provider in OpenCode. You subscribe to OpenCode Go and get your API key. It's **completely optional** and you don't need it to use OpenCode.
|
||||
|
||||
The service is designed primarily for international users and provides stable global access.
|
||||
It is designed primarily for international users and provides stable global access.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Sign in to the [OpenCode Console](https://opencode.ai/console), subscribe to Go or Go Plus, add your billing details, and copy your API key.
|
||||
1. Sign in to the [OpenCode console](https://opencode.ai/console), subscribe to Go, add your billing details, and copy your API key.
|
||||
2. Run `/connect` in the TUI, select **OpenCode Go**, and paste your API key.
|
||||
|
||||
```text
|
||||
@@ -29,7 +24,7 @@ The service is designed primarily for international users and provides stable gl
|
||||
/models
|
||||
```
|
||||
|
||||
<Callout>Only one member per workspace can subscribe to OpenCode Go or Go Plus.</Callout>
|
||||
<Callout>Only one member per workspace can subscribe to OpenCode Go.</Callout>
|
||||
|
||||
The current list of models includes:
|
||||
|
||||
@@ -38,13 +33,13 @@ The current list of models includes:
|
||||
- **GLM-5.3-Flash**
|
||||
- **GLM-5.3**
|
||||
- **GLM-5.2**
|
||||
- **GLM-5.1**
|
||||
- **GPT 6 Luna**
|
||||
- **GPT 5.6 Luna**
|
||||
- **Kimi K3**
|
||||
- **Kimi K2.7 Code**
|
||||
- **Kimi K2.6**
|
||||
- **LongCat-2.0**
|
||||
- **LongCat 2.5 Preview Free** (limited time)
|
||||
- **MiMo-V2.6-Flash**
|
||||
- **MiMo-V2.6-Pro**
|
||||
- **MiMo-V2.5**
|
||||
@@ -55,7 +50,9 @@ The current list of models includes:
|
||||
- **Muse Spark 1.2 Contributor** ([limited regions](https://ai.developer.meta.com/legal/geographic-use-policy))
|
||||
- **Qwen3.8 Max**
|
||||
- **Qwen3.8 Flash**
|
||||
- **Qwen3.7 Max**
|
||||
- **Qwen3.7 Plus**
|
||||
- **Qwen3.6 Plus**
|
||||
- **DeepSeek V4.1 Flash**
|
||||
- **DeepSeek V4 Pro**
|
||||
- **DeepSeek V4 Flash**
|
||||
@@ -63,6 +60,7 @@ The current list of models includes:
|
||||
- **Hy4 preview**
|
||||
- **Hy3**
|
||||
- **Space Bunny Free** (limited time)
|
||||
- **LongCat 2.5 Preview Free** (limited time)
|
||||
|
||||
The list of models may change as we test and add new ones.
|
||||
|
||||
@@ -111,202 +109,127 @@ investigated. The linked reports track fixes and workarounds.
|
||||
## Usage limits
|
||||
|
||||
Usage limits are defined as monthly dollar amounts. The table below shows the
|
||||
monthly limit for each plan and the token costs for each model. Token pricing is
|
||||
the same for Go and Go Plus.
|
||||
monthly limit and token costs for each model.
|
||||
|
||||
Each model has the following usage limits: 5-hour — 20% of the monthly limit;
|
||||
weekly — 50%; and monthly — 100%.
|
||||
|
||||
Each model's monthly limit below determines how its usage counts toward those allowances.
|
||||
For example, if a model has a $60 monthly limit, you can spend up to:
|
||||
|
||||
- **5-hour limit** — $12 of usage
|
||||
- **Weekly limit** — $30 of usage
|
||||
- **Monthly limit** — $60 of usage
|
||||
|
||||
Token prices are per 1M tokens.
|
||||
|
||||
<PlanTabs id="go-pricing" label="Go plan" syncKey="go-plan">
|
||||
<div slot="go">
|
||||
<div class="docs-table-scroll" role="region" aria-label="Go model pricing" tabIndex={0}>
|
||||
|
||||
| Model | Input | Output | Cached Read | Cached Write | Monthly limit |
|
||||
| --------------------------------------- | ------ | ------ | ----------- | ------------ | ---------------------------------------------- |
|
||||
| GLM-5.3-Flash | $0.15 | $0.50 | $0.03 | - | **$60** |
|
||||
| GLM-5.3 | $1.40 | $4.40 | $0.26 | - | **$15** |
|
||||
| GLM-5.2 | $1.40 | $4.40 | $0.26 | - | **$60** |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - | **$15** |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | **$60** |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | **$60** |
|
||||
| LongCat-2.0 | $0.30 | $1.20 | $0.006 | - | **$60** |
|
||||
| LongCat 2.5 Preview Free | Free | Free | Free | - | **Unlimited**<br /><small>limited time</small> |
|
||||
| MiMo-V2.6-Flash | $0.14 | $0.28 | $0.0028 | - | **$60** |
|
||||
| MiMo-V2.6-Pro | $0.435 | $0.87 | $0.003625 | - | **$15** |
|
||||
| MiMo-V2.5 | $0.14 | $0.28 | $0.0028 | - | **$60** |
|
||||
| MiMo-V2.5-Pro | $0.435 | $0.87 | $0.003625 | - | **$15** |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - | **$60** |
|
||||
| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | **$60** |
|
||||
| Muse Spark 1.3 Contributor | $0.10 | $0.20 | $0.002 | - | **$60** |
|
||||
| Muse Spark 1.2 Contributor | $0.10 | $0.20 | $0.002 | - | **$60** |
|
||||
| Qwen3.8 Max | $2.00 | $6.00 | $0.25 | $2.50 | **$15** |
|
||||
| Qwen3.8 Flash | $0.15 | $0.47 | $0.016 | $0.20 | **$30** |
|
||||
| Qwen3.7 Plus (≤ 256K tokens) | $0.40 | $1.60 | $0.04 | $0.50 | **$60** |
|
||||
| Qwen3.7 Plus (> 256K tokens) | $1.20 | $4.80 | $0.12 | $1.50 | **$60** |
|
||||
| DeepSeek V4.1 Flash (Off-Peak) | $0.15 | $0.60 | $0.003 | - | **$60** |
|
||||
| DeepSeek V4.1 Flash (Peak) | $0.30 | $1.20 | $0.006 | - | **$60** |
|
||||
| DeepSeek V4 Pro (Off-Peak) | $0.66 | $1.98 | $0.022 | - | **$15** |
|
||||
| DeepSeek V4 Pro (Peak) | $1.32 | $3.96 | $0.044 | - | **$15** |
|
||||
| DeepSeek V4 Flash (Off-Peak) | $0.15 | $0.60 | $0.003 | - | **$30** |
|
||||
| DeepSeek V4 Flash (Peak) | $0.30 | $1.20 | $0.006 | - | **$30** |
|
||||
| DeepSeek V4 Flash Vision Exp (Off-Peak) | $0.15 | $0.60 | $0.003 | - | **$15** |
|
||||
| DeepSeek V4 Flash Vision Exp (Peak) | $0.30 | $1.20 | $0.006 | - | **$15** |
|
||||
| Hy4 preview | $0.834 | $2.501 | $0.042 | - | **$30** |
|
||||
| Hy3 | $0.14 | $0.58 | $0.035 | - | **$60** |
|
||||
| Space Bunny Free | Free | Free | Free | - | **Unlimited**<br /><small>limited time</small> |
|
||||
| Grok 4.7 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$15** |
|
||||
| Grok 4.7 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$15** |
|
||||
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$15** |
|
||||
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$15** |
|
||||
| GPT 6 Luna (≤ 272K tokens) | $0.10 | $0.50 | $0.01 | $0.125 | **$15** |
|
||||
| GPT 6 Luna (> 272K tokens) | $0.20 | $0.75 | $0.02 | $0.25 | **$15** |
|
||||
| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 | **$15** |
|
||||
| GPT 5.6 Luna (> 272K tokens) | $0.40 | $1.80 | $0.04 | $0.50 | **$15** |
|
||||
| Model | Input | Output | Cached Read | Cached Write | Monthly limit |
|
||||
| --------------------------------------- | ------ | ------ | ----------- | ------------ | ---------------------------------------------------- |
|
||||
| GLM-5.3-Flash | $0.15 | $0.50 | $0.03 | - | **$60** |
|
||||
| GLM-5.3 | $1.40 | $4.40 | $0.26 | - | **$15** |
|
||||
| GLM-5.2 | $1.40 | $4.40 | $0.26 | - | **$60** |
|
||||
| GLM-5.1 | $1.40 | $4.40 | $0.26 | - | **$60** |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - | **$15** |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | **$60** |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | **$60** |
|
||||
| LongCat-2.0 | $0.30 | $1.20 | $0.006 | - | **$60** |
|
||||
| MiMo-V2.6-Flash | $0.14 | $0.28 | $0.0028 | - | **$60** |
|
||||
| MiMo-V2.6-Pro | $0.435 | $0.87 | $0.003625 | - | **$15** |
|
||||
| MiMo-V2.5 | $0.14 | $0.28 | $0.0028 | - | **$60** |
|
||||
| MiMo-V2.5-Pro | $0.435 | $0.87 | $0.003625 | - | **$15** |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - | **$60** |
|
||||
| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | **$60** |
|
||||
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 | **$60** |
|
||||
| Muse Spark 1.3 Contributor | $0.10 | $0.20 | $0.002 | - | **$60** |
|
||||
| Muse Spark 1.2 Contributor | $0.10 | $0.20 | $0.002 | - | **$60** |
|
||||
| Qwen3.8 Max | $2.00 | $6.00 | $0.25 | $2.50 | **$15** |
|
||||
| Qwen3.8 Flash | $0.15 | $0.47 | $0.016 | $0.20 | **$30** |
|
||||
| Qwen3.7 Max | $2.50 | $7.50 | $0.50 | $3.125 | **$30** |
|
||||
| Qwen3.7 Plus (≤ 256K tokens) | $0.40 | $1.60 | $0.04 | $0.50 | **$60** |
|
||||
| Qwen3.7 Plus (> 256K tokens) | $1.20 | $4.80 | $0.12 | $1.50 | **$60** |
|
||||
| Qwen3.6 Plus (≤ 256K tokens) | $0.50 | $3.00 | $0.05 | $0.625 | **$60** |
|
||||
| Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | **$60** |
|
||||
| DeepSeek V4.1 Flash (Off-Peak) | $0.15 | $0.60 | $0.003 | - | **$60** |
|
||||
| DeepSeek V4.1 Flash (Peak) | $0.30 | $1.20 | $0.006 | - | **$60** |
|
||||
| DeepSeek V4 Pro (Off-Peak) | $0.66 | $1.98 | $0.022 | - | **$15** |
|
||||
| DeepSeek V4 Pro (Peak) | $1.32 | $3.96 | $0.044 | - | **$15** |
|
||||
| DeepSeek V4 Flash (Off-Peak) | $0.15 | $0.60 | $0.003 | - | **$30** |
|
||||
| DeepSeek V4 Flash (Peak) | $0.30 | $1.20 | $0.006 | - | **$30** |
|
||||
| DeepSeek V4 Flash Vision Exp (Off-Peak) | $0.15 | $0.60 | $0.003 | - | **$15** |
|
||||
| DeepSeek V4 Flash Vision Exp (Peak) | $0.30 | $1.20 | $0.006 | - | **$15** |
|
||||
| Hy4 preview | $0.834 | $2.501 | $0.042 | - | **$30** |
|
||||
| Hy3 | $0.14 | $0.58 | $0.035 | - | **$60** |
|
||||
| Space Bunny Free | Free | Free | Free | - | **Unlimited**<br /><small>limited time</small> |
|
||||
| LongCat 2.5 Preview Free | Free | Free | Free | - | **Unlimited**<br /><small>limited time</small> |
|
||||
| Grok 4.7 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$15** |
|
||||
| Grok 4.7 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$15** |
|
||||
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$15** |
|
||||
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$15** |
|
||||
| GPT 6 Luna (≤ 272K tokens) | $0.10 | $0.50 | $0.01 | $0.125 | **$15** |
|
||||
| GPT 6 Luna (> 272K tokens) | $0.20 | $0.75 | $0.02 | $0.25 | **$15** |
|
||||
| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 | **$15** |
|
||||
| GPT 5.6 Luna (> 272K tokens) | $0.40 | $1.80 | $0.04 | $0.50 | **$15** |
|
||||
|
||||
</div>
|
||||
<div slot="go-plus">
|
||||
|
||||
| Model | Input | Output | Cached Read | Cached Write | Monthly limit |
|
||||
| --------------------------------------- | ------ | ------ | ----------- | ------------ | ---------------------------------------------- |
|
||||
| GLM-5.3-Flash | $0.15 | $0.50 | $0.03 | - | **$180** |
|
||||
| GLM-5.3 | $1.40 | $4.40 | $0.26 | - | **$120** |
|
||||
| GLM-5.2 | $1.40 | $4.40 | $0.26 | - | **$180** |
|
||||
| Kimi K3 | $3.00 | $15.00 | $0.30 | - | **$60** |
|
||||
| Kimi K2.7 Code | $0.95 | $4.00 | $0.19 | - | **$180** |
|
||||
| Kimi K2.6 | $0.95 | $4.00 | $0.16 | - | **$240** |
|
||||
| LongCat-2.0 | $0.30 | $1.20 | $0.006 | - | **$240** |
|
||||
| LongCat 2.5 Preview Free | Free | Free | Free | - | **Unlimited**<br /><small>limited time</small> |
|
||||
| MiMo-V2.6-Flash | $0.14 | $0.28 | $0.0028 | - | **$120** |
|
||||
| MiMo-V2.6-Pro | $0.435 | $0.87 | $0.003625 | - | **$60** |
|
||||
| MiMo-V2.5 | $0.14 | $0.28 | $0.0028 | - | **$120** |
|
||||
| MiMo-V2.5-Pro | $0.435 | $0.87 | $0.003625 | - | **$60** |
|
||||
| MiniMax M3 | $0.30 | $1.20 | $0.06 | - | **$180** |
|
||||
| MiniMax M2.7 | $0.30 | $1.20 | $0.06 | $0.375 | **$240** |
|
||||
| Muse Spark 1.3 Contributor | $0.10 | $0.20 | $0.002 | - | **$120** |
|
||||
| Muse Spark 1.2 Contributor | $0.10 | $0.20 | $0.002 | - | **$120** |
|
||||
| Qwen3.8 Max | $2.00 | $6.00 | $0.25 | $2.50 | **$60** |
|
||||
| Qwen3.8 Flash | $0.15 | $0.47 | $0.016 | $0.20 | **$90** |
|
||||
| Qwen3.7 Plus (≤ 256K tokens) | $0.40 | $1.60 | $0.04 | $0.50 | **$180** |
|
||||
| Qwen3.7 Plus (> 256K tokens) | $1.20 | $4.80 | $0.12 | $1.50 | **$180** |
|
||||
| DeepSeek V4.1 Flash (Off-Peak) | $0.15 | $0.60 | $0.003 | - | **$120** |
|
||||
| DeepSeek V4.1 Flash (Peak) | $0.30 | $1.20 | $0.006 | - | **$120** |
|
||||
| DeepSeek V4 Pro (Off-Peak) | $0.66 | $1.98 | $0.022 | - | **$60** |
|
||||
| DeepSeek V4 Pro (Peak) | $1.32 | $3.96 | $0.044 | - | **$60** |
|
||||
| DeepSeek V4 Flash (Off-Peak) | $0.15 | $0.60 | $0.003 | - | **$120** |
|
||||
| DeepSeek V4 Flash (Peak) | $0.30 | $1.20 | $0.006 | - | **$120** |
|
||||
| DeepSeek V4 Flash Vision Exp (Off-Peak) | $0.15 | $0.60 | $0.003 | - | **$60** |
|
||||
| DeepSeek V4 Flash Vision Exp (Peak) | $0.30 | $1.20 | $0.006 | - | **$60** |
|
||||
| Hy4 preview | $0.834 | $2.501 | $0.042 | - | **$120** |
|
||||
| Hy3 | $0.14 | $0.58 | $0.035 | - | **$240** |
|
||||
| Space Bunny Free | Free | Free | Free | - | **Unlimited**<br /><small>limited time</small> |
|
||||
| Grok 4.7 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$60** |
|
||||
| Grok 4.7 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$60** |
|
||||
| Grok 4.6 (≤ 200K tokens) | $2.00 | $6.00 | $0.50 | - | **$60** |
|
||||
| Grok 4.6 (> 200K tokens) | $4.00 | $12.00 | $1.00 | - | **$60** |
|
||||
| GPT 6 Luna (≤ 272K tokens) | $0.10 | $0.50 | $0.01 | $0.125 | **$60** |
|
||||
| GPT 6 Luna (> 272K tokens) | $0.20 | $0.75 | $0.02 | $0.25 | **$60** |
|
||||
| GPT 5.6 Luna (≤ 272K tokens) | $0.20 | $1.20 | $0.02 | $0.25 | **$60** |
|
||||
| GPT 5.6 Luna (> 272K tokens) | $0.40 | $1.80 | $0.04 | $0.50 | **$60** |
|
||||
|
||||
</div>
|
||||
</PlanTabs>
|
||||
</div>
|
||||
|
||||
**Space Bunny Free:** Free for a limited time.
|
||||
|
||||
**LongCat 2.5 Preview Free:** Free for a limited time.
|
||||
|
||||
**DeepSeek V4.1 Flash / V4 Pro / V4 Flash / V4 Flash Vision Exp:** Peak hours are 01:00-04:00 and 06:00-10:00 UTC, Monday through Friday; all other hours, including weekends, are Off-Peak. [Learn more](https://api-docs.deepseek.com/quick_start/pricing/).
|
||||
**DeepSeek V4.1 Flash / V4 Pro / V4 Flash Vision Exp:** Peak hours are 01:00-04:00 and 06:00-10:00 UTC, Monday through Friday; all other hours, including weekends, are Off-Peak. [Learn more](https://api-docs.deepseek.com/quick_start/pricing/).
|
||||
|
||||
**DeepSeek V4 Flash Vision Exp:** Images are converted into tokens based on their dimensions and billed as input tokens alongside text tokens. [Learn more](https://api-docs.deepseek.com/quick_start/pricing/).
|
||||
|
||||
### Estimated requests
|
||||
|
||||
The tables below estimate request counts based on typical Go usage patterns. Go
|
||||
Plus estimates scale with each model's higher usage limit.
|
||||
The table below provides an estimated request count based on typical Go usage patterns:
|
||||
|
||||
<PlanTabs id="go-requests" label="Go plan" syncKey="go-plan">
|
||||
<div slot="go">
|
||||
<div class="docs-table-scroll" role="region" aria-label="Go estimated requests" tabIndex={0}>
|
||||
|
||||
| Model | Requests per 5 hours | Requests per week | Requests per month |
|
||||
| ---------------------------- | -------------------- | ----------------- | ------------------ |
|
||||
| GLM-5.3-Flash | 6,320 | 15,790 | 31,580 |
|
||||
| GLM-5.3 | 220 | 540 | 1,080 |
|
||||
| GLM-5.2 | 880 | 2,150 | 4,300 |
|
||||
| Kimi K3 | 110 | 250 | 490 |
|
||||
| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 |
|
||||
| Kimi K2.6 | 1,150 | 2,880 | 5,750 |
|
||||
| LongCat-2.0 | 11,400 | 28,600 | 57,200 |
|
||||
| LongCat 2.5 Preview Free | Unlimited | Unlimited | Unlimited |
|
||||
| MiMo-V2.6-Flash | 30,100 | 75,200 | 150,400 |
|
||||
| MiMo-V2.6-Pro | 3,250 | 8,150 | 16,300 |
|
||||
| MiMo-V2.5 | 30,100 | 75,200 | 150,400 |
|
||||
| MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 |
|
||||
| MiniMax M3 | 3,200 | 8,000 | 16,000 |
|
||||
| MiniMax M2.7 | 3,400 | 8,500 | 17,000 |
|
||||
| Muse Spark 1.3 Contributor | 45,300 | 113,300 | 226,600 |
|
||||
| Muse Spark 1.2 Contributor | 45,300 | 113,300 | 226,600 |
|
||||
| Qwen3.8 Max | 160 | 400 | 810 |
|
||||
| Qwen3.8 Flash | 5,400 | 13,500 | 27,000 |
|
||||
| Qwen3.7 Plus | 4,300 | 10,800 | 21,600 |
|
||||
| DeepSeek V4.1 Flash | 26,000 | 65,000 | 130,000 |
|
||||
| DeepSeek V4 Pro | 1,050 | 2,600 | 5,200 |
|
||||
| DeepSeek V4 Flash | 13,000 | 32,500 | 65,000 |
|
||||
| DeepSeek V4 Flash Vision Exp | 6,500 | 16,250 | 32,500 |
|
||||
| Hy4 preview | 1,350 | 3,380 | 6,770 |
|
||||
| Hy3 | 4,300 | 10,750 | 21,500 |
|
||||
| Space Bunny Free | Unlimited | Unlimited | Unlimited |
|
||||
| Grok 4.7 | 169 | 423 | 845 |
|
||||
| Grok 4.6 | 169 | 423 | 845 |
|
||||
| GPT 6 Luna | 4,230 | 10,560 | 21,130 |
|
||||
| GPT 5.6 Luna | 2,050 | 5,100 | 10,250 |
|
||||
| Model | requests per 5 hour | requests per week | requests per month |
|
||||
| -------------------------------------------------------- | ------------------------- | -------------------------- | --------------------------- |
|
||||
| GLM-5.3-Flash | 6,320 | 15,790 | 31,580 |
|
||||
| GLM-5.3 | 220 | 540 | 1,080 |
|
||||
| GLM-5.2 | 880 | 2,150 | 4,300 |
|
||||
| GLM-5.1 | 880 | 2,150 | 4,300 |
|
||||
| Kimi K3 | 110 | 250 | 490 |
|
||||
| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 |
|
||||
| Kimi K2.6 | 1,150 | 2,880 | 5,750 |
|
||||
| LongCat-2.0 | 11,400 | 28,600 | 57,200 |
|
||||
| MiMo-V2.6-Flash | 30,100 | 75,200 | 150,400 |
|
||||
| MiMo-V2.6-Pro | 3,250 | 8,150 | 16,300 |
|
||||
| MiMo-V2.5 | 30,100 | 75,200 | 150,400 |
|
||||
| MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 |
|
||||
| MiniMax M3 | 3,200 | 8,000 | 16,000 |
|
||||
| MiniMax M2.7 | 3,400 | 8,500 | 17,000 |
|
||||
| Muse Spark 1.3 Contributor | 45,300 | 113,300 | 226,600 |
|
||||
| Muse Spark 1.2 Contributor | 45,300 | 113,300 | 226,600 |
|
||||
| Qwen3.8 Max | 160 | 400 | 810 |
|
||||
| Qwen3.8 Flash | 5,400 | 13,500 | 27,000 |
|
||||
| Qwen3.7 Max | 170 | 420 | 840 |
|
||||
| Qwen3.7 Plus | 4,300 | 10,800 | 21,600 |
|
||||
| Qwen3.6 Plus | 3,300 | 8,200 | 16,300 |
|
||||
| DeepSeek V4.1 Flash | 26,000 | 65,000 | 130,000 |
|
||||
| DeepSeek V4 Pro | 1,050 | 2,600 | 5,200 |
|
||||
| DeepSeek V4 Flash | 13,000 | 32,500 | 65,000 |
|
||||
| DeepSeek V4 Flash Vision Exp | 6,500 | 16,250 | 32,500 |
|
||||
| Hy4 preview | 1,350 | 3,380 | 6,770 |
|
||||
| Hy3 | 4,300 | 10,750 | 21,500 |
|
||||
| Space Bunny Free | Unlimited | Unlimited | Unlimited |
|
||||
| LongCat 2.5 Preview Free | Unlimited | Unlimited | Unlimited |
|
||||
| Grok 4.7 | 169 | 423 | 845 |
|
||||
| Grok 4.6 | 169 | 423 | 845 |
|
||||
| GPT 6 Luna | 4,230 | 10,560 | 21,130 |
|
||||
| GPT 5.6 Luna | 2,050 | 5,100 | 10,250 |
|
||||
|
||||
</div>
|
||||
<div slot="go-plus">
|
||||
|
||||
| Model | Requests per 5 hours | Requests per week | Requests per month |
|
||||
| ---------------------------- | -------------------- | ----------------- | ------------------ |
|
||||
| GLM-5.3-Flash | 18,960 | 47,370 | 94,740 |
|
||||
| GLM-5.3 | 1,760 | 4,320 | 8,640 |
|
||||
| GLM-5.2 | 2,640 | 6,450 | 12,900 |
|
||||
| Kimi K3 | 440 | 1,000 | 1,960 |
|
||||
| Kimi K2.7 Code | 4,050 | 10,140 | 20,250 |
|
||||
| Kimi K2.6 | 4,600 | 11,520 | 23,000 |
|
||||
| LongCat-2.0 | 45,600 | 114,400 | 228,800 |
|
||||
| LongCat 2.5 Preview Free | Unlimited | Unlimited | Unlimited |
|
||||
| MiMo-V2.6-Flash | 60,200 | 150,400 | 300,800 |
|
||||
| MiMo-V2.6-Pro | 13,000 | 32,600 | 65,200 |
|
||||
| MiMo-V2.5 | 60,200 | 150,400 | 300,800 |
|
||||
| MiMo-V2.5-Pro | 13,000 | 32,600 | 65,200 |
|
||||
| MiniMax M3 | 9,600 | 24,000 | 48,000 |
|
||||
| MiniMax M2.7 | 13,600 | 34,000 | 68,000 |
|
||||
| Muse Spark 1.3 Contributor | 90,600 | 226,600 | 453,200 |
|
||||
| Muse Spark 1.2 Contributor | 90,600 | 226,600 | 453,200 |
|
||||
| Qwen3.8 Max | 640 | 1,600 | 3,240 |
|
||||
| Qwen3.8 Flash | 16,200 | 40,500 | 81,000 |
|
||||
| Qwen3.7 Plus | 12,900 | 32,400 | 64,800 |
|
||||
| DeepSeek V4.1 Flash | 52,000 | 130,000 | 260,000 |
|
||||
| DeepSeek V4 Pro | 4,200 | 10,400 | 20,800 |
|
||||
| DeepSeek V4 Flash | 52,000 | 130,000 | 260,000 |
|
||||
| DeepSeek V4 Flash Vision Exp | 26,000 | 65,000 | 130,000 |
|
||||
| Hy4 preview | 5,400 | 13,520 | 27,080 |
|
||||
| Hy3 | 17,200 | 43,000 | 86,000 |
|
||||
| Space Bunny Free | Unlimited | Unlimited | Unlimited |
|
||||
| Grok 4.7 | 676 | 1,692 | 3,380 |
|
||||
| Grok 4.6 | 676 | 1,692 | 3,380 |
|
||||
| GPT 6 Luna | 16,920 | 42,240 | 84,520 |
|
||||
| GPT 5.6 Luna | 8,200 | 20,400 | 41,000 |
|
||||
|
||||
</div>
|
||||
</PlanTabs>
|
||||
</div>
|
||||
|
||||
The estimates use the following token counts per request; actual usage varies.
|
||||
|
||||
- Grok 4.7/4.6 — 390 input, 32,500 cached, 120 output tokens per request
|
||||
- GLM-5.3-Flash — 1,000 input, 55,000 cached, 200 output tokens per request
|
||||
- GLM-5.3/5.2 — 700 input, 52,000 cached, 150 output tokens per request
|
||||
- GLM-5.3/5.2/5.1 — 700 input, 52,000 cached, 150 output tokens per request
|
||||
- GPT 6 Luna — 1,000 input, 50,000 cached, 220 output tokens per request
|
||||
- GPT 5.6 Luna — 1,000 input, 50,000 cached, 220 output tokens per request
|
||||
- Kimi K3 — 1,050 input, 76,500 cached, 300 output tokens per request
|
||||
@@ -326,7 +249,9 @@ The estimates use the following token counts per request; actual usage varies.
|
||||
- MiMo-V2.5-Pro — 790 input, 86,000 cached, 305 output tokens per request
|
||||
- Qwen3.8 Max — 420 input, 66,000 cached, 200 output tokens per request
|
||||
- Qwen3.8 Flash — 600 input, 58,000 cached, 200 output tokens per request
|
||||
- Qwen3.7 Max — 420 input, 66,000 cached, 200 output tokens per request
|
||||
- Qwen3.7 Plus — 500 input, 57,000 cached, 190 output tokens per request
|
||||
- Qwen3.6 Plus — 500 input, 57,000 cached, 190 output tokens per request
|
||||
- Hy4 preview — 830 input, 71,500 cached, 295 output tokens per request
|
||||
- Hy3 — 830 input, 71,500 cached, 295 output tokens per request
|
||||
|
||||
@@ -336,7 +261,6 @@ You can track your current usage in the [console](https://opencode.ai/console).
|
||||
|
||||
Usage limits may change as we learn from early usage and feedback.
|
||||
|
||||
---
|
||||
|
||||
### Usage beyond limits
|
||||
|
||||
@@ -347,7 +271,7 @@ after you've reached your usage limits instead of blocking requests.
|
||||
|
||||
### Why some models have lower usage
|
||||
|
||||
With Go, the included monthly usage varies by model.
|
||||
With Go, you pay $10/month, and the included monthly usage varies by model.
|
||||
|
||||
For most models, we make this work through bulk discounts and reserved GPU capacity. We then pass those savings on to you as higher monthly usage.
|
||||
|
||||
@@ -370,6 +294,7 @@ You can also access Go models through the following API endpoints.
|
||||
| GLM-5.3-Flash | glm-5.3-flash | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM-5.3 | glm-5.3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM-5.2 | glm-5.2 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| GLM-5.1 | glm-5.1 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K3 | kimi-k3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.7 Code | kimi-k2.7-code | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Kimi K2.6 | kimi-k2.6 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -384,11 +309,14 @@ You can also access Go models through the following API endpoints.
|
||||
| MiMo-V2.5-Pro | mimo-v2.5-pro | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| MiniMax M3 | minimax-m3 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.7 | minimax-m2.7 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Muse Spark 1.3 Contributor | muse-spark-1.3-contributor | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` |
|
||||
| Muse Spark 1.2 Contributor | muse-spark-1.2-contributor | `https://opencode.ai/zen/go/v1/responses` | `@ai-sdk/openai` |
|
||||
| Qwen3.8 Max | qwen3.8-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.8 Flash | qwen3.8-flash | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` |
|
||||
| Hy4 preview | hy4-preview | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
| Space Bunny Free | space-bunny-free | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` |
|
||||
@@ -425,6 +353,7 @@ curl https://opencode.ai/zen/go/v1/models
|
||||
| GLM-5.3-Flash | Not used | 0 days |
|
||||
| GLM-5.3 | Not used | 0 days |
|
||||
| GLM-5.2 | Not used | 0 days |
|
||||
| GLM-5.1 | Not used | 0 days |
|
||||
| Kimi K3 | Not used | 0 days |
|
||||
| Kimi K2.7 Code | Not used | 0 days |
|
||||
| Kimi K2.6 | Not used | 0 days |
|
||||
@@ -435,7 +364,9 @@ curl https://opencode.ai/zen/go/v1/models
|
||||
| MiMo-V2.5 | Not used | 0 days |
|
||||
| Qwen3.8 Max | Not used | 0 days |
|
||||
| Qwen3.8 Flash | Not used | 0 days |
|
||||
| Qwen3.7 Max | Not used | 0 days |
|
||||
| Qwen3.7 Plus | Not used | 0 days |
|
||||
| Qwen3.6 Plus | Not used | 0 days |
|
||||
| MiniMax M3 | Not used | 0 days |
|
||||
| MiniMax M2.7 | Not used | 0 days |
|
||||
| Muse Spark 1.3 Contributor | Yes | Not ZDR |
|
||||
@@ -453,7 +384,7 @@ curl https://opencode.ai/zen/go/v1/models
|
||||
- **GPT 6 Luna / GPT 5.6 Luna:** Abuse monitoring logs are generated for all API feature usage and retained for up to 30 days. [Learn more](https://developers.openai.com/api/docs/guides/your-data#data-retention-controls-for-abuse-monitoring).
|
||||
- **Muse Spark 1.3 Contributor:** Heavily discounted token pricing in exchange for permission to use your prompts and completions to train future Meta models. Availability is limited to regions permitted by Meta's [Geographic Use Policy](https://ai.developer.meta.com/legal/geographic-use-policy). [Learn more](https://dev.meta.ai/docs/pricing-rate-limits#contributor-tier).
|
||||
- **Muse Spark 1.2 Contributor:** Heavily discounted token pricing in exchange for permission to use your prompts and completions to train future Meta models. Availability is limited to regions permitted by Meta's [Geographic Use Policy](https://ai.developer.meta.com/legal/geographic-use-policy). [Learn more](https://dev.meta.ai/docs/pricing-rate-limits#contributor-tier).
|
||||
- **DeepSeek:** ZDR agreement is renewed monthly. The current agreement is valid through October 31, 2026.
|
||||
- **DeepSeek:** ZDR agreement is renewed monthly. The current agreement is valid through September 30, 2026.
|
||||
|
||||
## Background
|
||||
|
||||
@@ -469,7 +400,7 @@ To fix this, we did a couple of things:
|
||||
2. We worked with a few providers to make sure these were being served correctly.
|
||||
3. We benchmarked the combination of the model/provider and came up with a list that we feel good recommending.
|
||||
|
||||
Both Go and Go Plus provide access to these models; choose the plan that fits how much you use them.
|
||||
OpenCode Go gives you access to these models for **$10/month**.
|
||||
|
||||
## Goals
|
||||
|
||||
|
||||
@@ -912,48 +912,6 @@ main {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.docs-plan-tabs {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.docs-plan-tabs-list {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.docs-plan-tabs-list button {
|
||||
margin-bottom: -1px;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.docs-plan-tabs-list button[aria-selected="true"] {
|
||||
border-bottom-color: var(--foreground);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.docs-plan-tabs-list button:focus-visible {
|
||||
outline: 2px solid var(--link);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.docs-plan-tabs [role="tabpanel"] {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.docs-plan-tabs [role="tabpanel"] > :last-child,
|
||||
.docs-plan-tabs [role="tabpanel"] > :last-child > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose table {
|
||||
width: 100%;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
Reference in New Issue
Block a user