mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-25 18:10:46 +08:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c949b0356 | |||
| 7827dbe396 | |||
| 5f9ced439b | |||
| 8c1ce954d0 | |||
| 07338c5d48 | |||
| 46e53e3f2b | |||
| 6cf442b545 | |||
| f20f5b68ee | |||
| 96dd9f77a9 | |||
| dd786c62af | |||
| 87c402a124 | |||
| 7076a878a4 | |||
| 45b91eed82 |
@@ -24,6 +24,10 @@ 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 }}
|
||||
|
||||
@@ -653,3 +657,19 @@ 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,8 +129,9 @@ 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 confidence plus score legends remain available in provider metadata, and the
|
||||
provider's rounded probabilities are returned unchanged.
|
||||
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.
|
||||
|
||||
## Alibaba Cloud Model Studio
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ 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>
|
||||
|
||||
@@ -70,6 +71,7 @@ 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>
|
||||
|
||||
@@ -92,6 +94,7 @@ 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,32 +142,37 @@ 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 }]
|
||||
return [
|
||||
id,
|
||||
{
|
||||
type: "score",
|
||||
score: answer.score,
|
||||
probabilities: answer.probabilities,
|
||||
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
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({
|
||||
|
||||
@@ -39,17 +39,18 @@ 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,8 +26,10 @@ 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* () {
|
||||
|
||||
@@ -92,5 +92,6 @@ 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.providerMetadata?.[metadataKey]?.confidence).toBeDefined()
|
||||
expect(response.answers.department.confidence).toBeGreaterThan(0)
|
||||
expect(response.answers.urgency.confidence).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
@@ -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"),
|
||||
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
|
||||
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"),
|
||||
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
|
||||
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"),
|
||||
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
|
||||
Flag.optional,
|
||||
),
|
||||
fork: Flag.boolean("fork").pipe(
|
||||
|
||||
@@ -11,6 +11,10 @@ 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* () {
|
||||
@@ -46,6 +50,15 @@ 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>()
|
||||
@@ -81,7 +94,8 @@ export default Runtime.handler(Commands, (input) =>
|
||||
},
|
||||
args: {
|
||||
continue: input.continue,
|
||||
sessionID: Option.getOrUndefined(input.session),
|
||||
sessionID: sessionExists ? session : undefined,
|
||||
newSessionID: sessionExists ? undefined : session,
|
||||
prompt: Option.getOrUndefined(input.prompt),
|
||||
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
|
||||
},
|
||||
|
||||
@@ -135,6 +135,7 @@ 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 +
|
||||
@@ -163,6 +164,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
if (!formAlreadySettled(error)) throw error
|
||||
}
|
||||
formCancelled = true
|
||||
if (input.compatibility !== "v1") process.exitCode = 1
|
||||
}
|
||||
|
||||
const consume = async () => {
|
||||
@@ -493,7 +495,8 @@ 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
|
||||
if (event.data.reason !== "user" && !emittedError) {
|
||||
// A declined tool call ends the step with an interruption; it was already reported above.
|
||||
if (event.data.reason !== "user" && !emittedError && !permissionRejected && !formCancelled) {
|
||||
emittedError = true
|
||||
process.exitCode = 1
|
||||
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
|
||||
@@ -620,7 +623,9 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
UI.error(item.state.error.message)
|
||||
}
|
||||
|
||||
if (message.error && !emittedError) {
|
||||
// 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) {
|
||||
emittedError = true
|
||||
process.exitCode = 1
|
||||
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
|
||||
|
||||
@@ -63,6 +63,7 @@ export async function resolveSessionTarget(input: {
|
||||
(await input.client.session
|
||||
.create(
|
||||
{
|
||||
id: input.session,
|
||||
agent: prepared.agent,
|
||||
model: prepared.model,
|
||||
location: { directory: location.directory },
|
||||
@@ -101,14 +102,11 @@ async function selectSession(input: {
|
||||
fork?: boolean
|
||||
signal?: AbortSignal
|
||||
}) {
|
||||
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")
|
||||
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 }
|
||||
}
|
||||
if (explicit)
|
||||
return {
|
||||
session: input.fork
|
||||
@@ -133,6 +131,13 @@ 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,
|
||||
|
||||
@@ -42,6 +42,28 @@ 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"))
|
||||
|
||||
@@ -2117,6 +2117,7 @@ export type ConfigEntry =
|
||||
name?: string
|
||||
env?: Array<string>
|
||||
package?: string
|
||||
compatibility?: ModelCompatibility
|
||||
settings?: ConfigProviderSettings
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
|
||||
@@ -99,6 +99,13 @@ export const Plugin = define({
|
||||
if (configuredDefault !== undefined) models.default.set(configuredDefault.providerID, configuredDefault.model)
|
||||
for (const [item, definition] of sources.models) {
|
||||
const providerID = definition.providerID
|
||||
if (item.compatibility !== undefined)
|
||||
for (const model of models.list(providerID)) {
|
||||
if (Object.hasOwn(item.models ?? {}, model.id)) continue
|
||||
models.update(providerID, model.id, (draft) => {
|
||||
draft.compatibility = { ...draft.compatibility, ...item.compatibility }
|
||||
})
|
||||
}
|
||||
for (const [id, config] of Object.entries(item.models ?? {})) {
|
||||
const source = definition.models.get(id)
|
||||
const inherit = source?.inherit || !models.get(providerID, id)
|
||||
@@ -111,6 +118,8 @@ export const Plugin = define({
|
||||
if (config.family !== undefined) model.family = config.family
|
||||
if (config.name !== undefined) model.name = config.name
|
||||
if (config.modelID !== undefined) model.modelID = config.modelID
|
||||
if (item.compatibility !== undefined)
|
||||
model.compatibility = { ...model.compatibility, ...item.compatibility }
|
||||
if (config.compatibility !== undefined)
|
||||
model.compatibility = { ...model.compatibility, ...config.compatibility }
|
||||
if (config.package !== undefined) model.package = config.package
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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"
|
||||
@@ -60,15 +61,24 @@ 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 })).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,
|
||||
// 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,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return response.text
|
||||
})
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -273,12 +273,12 @@ export const layer = Layer.effect(
|
||||
model: model.model,
|
||||
http: {
|
||||
headers: {
|
||||
"x-session-affinity": session.id,
|
||||
"X-Session-Id": session.id,
|
||||
"x-session-affinity": affinity,
|
||||
"X-Session-Id": affinity,
|
||||
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
|
||||
"User-Agent": App.useragent(app),
|
||||
"x-opencode-project": session.projectID,
|
||||
"x-opencode-session": session.id,
|
||||
"x-opencode-session": affinity,
|
||||
"x-opencode-client": app.name,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -250,6 +250,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
|
||||
name: info.name,
|
||||
env: info.env,
|
||||
package: info.npm ? Provider.aisdk(info.npm) : undefined,
|
||||
compatibility: options.compatibility,
|
||||
settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined,
|
||||
headers: info.options && options.headers,
|
||||
body: info.options && options.body,
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
export * as ConfigProviderOptionsV1 from "./provider-options.js"
|
||||
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
|
||||
type Options = Readonly<Record<string, unknown>>
|
||||
|
||||
export interface ProviderResult {
|
||||
readonly settings: Record<string, unknown>
|
||||
readonly headers?: Record<string, string>
|
||||
readonly body?: Record<string, unknown>
|
||||
readonly compatibility?: Model.Compatibility
|
||||
}
|
||||
|
||||
export function provider(options: Options): ProviderResult {
|
||||
const headers = options.headers
|
||||
const body = options.body
|
||||
const settings = Object.fromEntries(Object.entries(options).filter(([key]) => key !== "headers" && key !== "body"))
|
||||
const settings = Object.fromEntries(
|
||||
Object.entries(options).filter(([key]) => key !== "headers" && key !== "body" && key !== "setCacheKey"),
|
||||
)
|
||||
const headerOverlay =
|
||||
typeof headers === "object" && headers !== null && !Array.isArray(headers)
|
||||
? Object.fromEntries(
|
||||
@@ -23,6 +28,10 @@ export function provider(options: Options): ProviderResult {
|
||||
settings,
|
||||
headers: headerOverlay,
|
||||
body: bodyOverlay,
|
||||
// V1 `setCacheKey` was a provider-wide switch for sending the prompt cache key; false also disabled the
|
||||
// built-in defaults for OpenAI and xAI.
|
||||
compatibility:
|
||||
typeof options.setCacheKey === "boolean" ? { supportsPromptCacheKey: options.setCacheKey } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -835,6 +835,22 @@ describe("Config", () => {
|
||||
expect(migrated.providers?.custom?.models?.boolean?.compatibility).toBeUndefined()
|
||||
})
|
||||
|
||||
test("migrates v1 setCacheKey to provider compatibility", () => {
|
||||
const migrated = ConfigMigrateV1.migrate({
|
||||
provider: {
|
||||
enabled: { options: { setCacheKey: true, baseURL: "https://enabled.example/v1" } },
|
||||
disabled: { options: { setCacheKey: false } },
|
||||
unset: { options: { baseURL: "https://unset.example/v1" } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(migrated.providers?.enabled?.compatibility).toEqual({ supportsPromptCacheKey: true })
|
||||
expect(migrated.providers?.enabled?.settings).toEqual({ baseURL: "https://enabled.example/v1" })
|
||||
expect(migrated.providers?.disabled?.compatibility).toEqual({ supportsPromptCacheKey: false })
|
||||
expect(migrated.providers?.disabled?.settings).toEqual({})
|
||||
expect(migrated.providers?.unset?.compatibility).toBeUndefined()
|
||||
})
|
||||
|
||||
for (const subtask of [true, false]) {
|
||||
test(`migrates v1 command configuration with subtask: ${subtask}`, () => {
|
||||
expect(
|
||||
|
||||
@@ -24,6 +24,19 @@ describe("ConfigProviderOptionsV1", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("lowers setCacheKey to prompt cache key compatibility", () => {
|
||||
expect(ConfigProviderOptionsV1.provider({ apiKey: "secret", setCacheKey: true })).toEqual({
|
||||
settings: { apiKey: "secret" },
|
||||
headers: undefined,
|
||||
body: undefined,
|
||||
compatibility: { supportsPromptCacheKey: true },
|
||||
})
|
||||
expect(ConfigProviderOptionsV1.provider({ setCacheKey: false }).compatibility).toEqual({
|
||||
supportsPromptCacheKey: false,
|
||||
})
|
||||
expect(ConfigProviderOptionsV1.provider({ apiKey: "secret" }).compatibility).toBeUndefined()
|
||||
})
|
||||
|
||||
test("keeps model and variant options unchanged", () => {
|
||||
expect(
|
||||
ConfigProviderOptionsV1.model({
|
||||
|
||||
@@ -420,6 +420,84 @@ describe("ConfigProviderPlugin.Plugin", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies provider compatibility to every model unless the model overrides it", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
const providerID = Provider.ID.make("custom")
|
||||
yield* providers.transform((editor) => {
|
||||
editor.models.update(providerID, Model.ID.make("catalog"), (model) => {
|
||||
model.compatibility = { requireFinishReason: true }
|
||||
})
|
||||
editor.models.update(Provider.ID.make("other"), Model.ID.make("catalog"), () => {})
|
||||
})
|
||||
|
||||
yield* addPlugin([
|
||||
new Document({
|
||||
type: "document",
|
||||
info: decode({
|
||||
providers: {
|
||||
custom: {
|
||||
compatibility: { supportsPromptCacheKey: true },
|
||||
models: {
|
||||
inherited: {},
|
||||
overridden: { compatibility: { supportsPromptCacheKey: false } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
expect((yield* models.get(providerID, Model.ID.make("catalog")))?.compatibility).toEqual({
|
||||
requireFinishReason: true,
|
||||
supportsPromptCacheKey: true,
|
||||
})
|
||||
expect((yield* models.get(providerID, Model.ID.make("inherited")))?.compatibility).toEqual({
|
||||
supportsPromptCacheKey: true,
|
||||
})
|
||||
expect((yield* models.get(providerID, Model.ID.make("overridden")))?.compatibility).toEqual({
|
||||
supportsPromptCacheKey: false,
|
||||
})
|
||||
expect((yield* models.get(Provider.ID.make("other"), Model.ID.make("catalog")))?.compatibility).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps migrated v1 setCacheKey onto provider models", () =>
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service
|
||||
const models = yield* Model.Service
|
||||
yield* providers.transform((editor) => {
|
||||
editor.models.update(Provider.ID.make("enabled"), Model.ID.make("catalog"), () => {})
|
||||
editor.models.update(Provider.ID.make("disabled"), Model.ID.make("catalog"), (model) => {
|
||||
model.compatibility = { supportsPromptCacheKey: true }
|
||||
})
|
||||
editor.models.update(Provider.ID.make("unset"), Model.ID.make("catalog"), () => {})
|
||||
})
|
||||
const result = ConfigNormalize.normalize({
|
||||
provider: {
|
||||
enabled: { options: { setCacheKey: true }, models: { chat: {} } },
|
||||
disabled: { options: { setCacheKey: false } },
|
||||
unset: { options: { baseURL: "https://unset.example/v1" } },
|
||||
},
|
||||
})
|
||||
if (result.type !== "normalized") throw new Error("Expected normalized config")
|
||||
expect(result.diagnostics).toEqual([])
|
||||
|
||||
yield* addPlugin([new Document({ type: "document", info: decode(result.encoded) })])
|
||||
|
||||
const compatibility = (providerID: string, modelID: string) =>
|
||||
models
|
||||
.get(Provider.ID.make(providerID), Model.ID.make(modelID))
|
||||
.pipe(Effect.map((model) => model?.compatibility?.supportsPromptCacheKey))
|
||||
expect(yield* compatibility("enabled", "catalog")).toBe(true)
|
||||
expect(yield* compatibility("enabled", "chat")).toBe(true)
|
||||
expect(yield* compatibility("disabled", "catalog")).toBe(false)
|
||||
expect(yield* compatibility("unset", "catalog")).toBeUndefined()
|
||||
expect((yield* providers.get(Provider.ID.make("enabled")))?.settings).not.toHaveProperty("setCacheKey")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("generates variants after rewriting a configured model package", () =>
|
||||
Effect.gen(function* () {
|
||||
const models = yield* Model.Service
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { expect } from "bun:test"
|
||||
import { LanguageModel } from "@opencode/ai"
|
||||
import { LanguageModel, LLMClient } from "@opencode/ai"
|
||||
import { RequestExecutor } from "@opencode/ai/route"
|
||||
import { OpenAIChat } from "@opencode/ai/protocols"
|
||||
import { TestLLM } from "@opencode/ai/testing"
|
||||
import { AISDK } from "@opencode/core/aisdk"
|
||||
@@ -10,6 +11,7 @@ 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({
|
||||
@@ -89,3 +91,56 @@ 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])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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": sessionID,
|
||||
"X-Session-Id": sessionID,
|
||||
"x-session-affinity": parentID,
|
||||
"X-Session-Id": parentID,
|
||||
"x-parent-session-id": parentID,
|
||||
"User-Agent": App.useragent(App.make()),
|
||||
"x-opencode-project": Project.ID.global,
|
||||
"x-opencode-session": sessionID,
|
||||
"x-opencode-session": parentID,
|
||||
"x-opencode-client": "opencode",
|
||||
})
|
||||
expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 20_000 }))
|
||||
|
||||
@@ -4693,7 +4693,12 @@ describe("SessionRunnerLLM", () => {
|
||||
.pipe(Effect.orDie)
|
||||
yield* s.runPrompt("Run child request")
|
||||
|
||||
expect(s.requests[0]?.http?.headers?.["x-parent-session-id"]).toBe(parentID)
|
||||
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]?.promptCacheKey).toBe(parentID)
|
||||
})
|
||||
|
||||
|
||||
@@ -87,6 +87,8 @@ export class Info extends Schema.Class<Info>("Config.Provider")({
|
||||
name: Schema.String.pipe(optional),
|
||||
env: Schema.String.pipe(Schema.Array, optional),
|
||||
package: Schema.String.pipe(optional),
|
||||
/** Compatibility defaults for every model of this provider. Model-level compatibility takes precedence. */
|
||||
compatibility: Compatibility.pipe(optional),
|
||||
...Overlays,
|
||||
models: Schema.Record(Schema.String, Model).pipe(optional),
|
||||
}) {}
|
||||
|
||||
@@ -208,6 +208,8 @@ 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,6 +53,7 @@ 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"
|
||||
@@ -206,6 +207,7 @@ 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()
|
||||
@@ -1229,7 +1231,9 @@ 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: {
|
||||
@@ -1238,6 +1242,7 @@ 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,3 +1,4 @@
|
||||
import { mergeProps } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
|
||||
export interface Args {
|
||||
@@ -6,11 +7,25 @@ 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) => props,
|
||||
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
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -65,12 +65,13 @@ export function Home() {
|
||||
untrack(() => composer.set(prompt))
|
||||
})
|
||||
|
||||
// Wait for the model store to be ready before auto-submitting --prompt.
|
||||
// Wait for everything submit needs before auto-submitting --prompt; it runs once.
|
||||
createEffect(() => {
|
||||
const r = ref()
|
||||
if (sent) return
|
||||
if (!r) return
|
||||
if (!local.model.ready) return
|
||||
if (!local.model.ready || !local.model.catalogReady) return
|
||||
if (!local.agent.current() || !local.model.current()) return
|
||||
if (!args.prompt) return
|
||||
if (r.current.text !== args.prompt) return
|
||||
sent = true
|
||||
|
||||
@@ -876,6 +876,61 @@ 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()
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
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"])
|
||||
})
|
||||
+4
-2
@@ -61,7 +61,9 @@ 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")
|
||||
await Bun.write(changelog, `${notes[1].trim()}\n`)
|
||||
const releaseNotes = notes[1].trim()
|
||||
if (!releaseNotes) throw new Error("Release review has no changelog")
|
||||
await Bun.write(changelog, `${releaseNotes}\n`)
|
||||
|
||||
const answer = prompt(`Trigger the ${version} release? [y/N]`)
|
||||
if (answer?.trim().toLowerCase() !== "y" && answer?.trim().toLowerCase() !== "yes") {
|
||||
@@ -69,7 +71,7 @@ if (answer?.trim().toLowerCase() !== "y" && answer?.trim().toLowerCase() !== "ye
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await $`gh workflow run publish.yml --ref v2 ${input}`
|
||||
await $`gh workflow run publish.yml --ref v2 ${input} -f release_notes=${releaseNotes}`
|
||||
console.log(`Triggered the ${version} release`)
|
||||
|
||||
async function generateReview(base: string) {
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 {
|
||||
@@ -22,5 +23,5 @@ const rendered = await render(entry)
|
||||
headings={rendered.headings}
|
||||
showTableOfContents={entry.data.tableOfContents !== false}
|
||||
>
|
||||
<rendered.Content components={{ Callout, Card, CardGroup, CodeBlock, CodeTabs }} />
|
||||
<rendered.Content components={{ Callout, Card, CardGroup, CodeBlock, CodeTabs, PlanTabs }} />
|
||||
</DocsLayout>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
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,17 +1,22 @@
|
||||
---
|
||||
title: "Go"
|
||||
description: "Low cost subscription for open coding models."
|
||||
description: "Reliable access to open coding models with two usage tiers."
|
||||
---
|
||||
|
||||
OpenCode Go is a low cost **$10/month subscription** that gives you reliable access to popular 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 |
|
||||
|
||||
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.
|
||||
|
||||
It is designed primarily for international users and provides stable global access.
|
||||
The service 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, add your billing details, and copy your API key.
|
||||
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.
|
||||
2. Run `/connect` in the TUI, select **OpenCode Go**, and paste your API key.
|
||||
|
||||
```text
|
||||
@@ -24,7 +29,7 @@ It is designed primarily for international users and provides stable global acce
|
||||
/models
|
||||
```
|
||||
|
||||
<Callout>Only one member per workspace can subscribe to OpenCode Go.</Callout>
|
||||
<Callout>Only one member per workspace can subscribe to OpenCode Go or Go Plus.</Callout>
|
||||
|
||||
The current list of models includes:
|
||||
|
||||
@@ -33,13 +38,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**
|
||||
@@ -50,9 +55,7 @@ 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**
|
||||
@@ -60,7 +63,6 @@ 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.
|
||||
|
||||
@@ -109,127 +111,202 @@ 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 and token costs for each model.
|
||||
monthly limit for each plan and the token costs for each model. Token pricing is
|
||||
the same for Go and Go Plus.
|
||||
|
||||
Each model has the following usage limits: 5-hour — 20% of the monthly limit;
|
||||
weekly — 50%; and monthly — 100%.
|
||||
|
||||
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
|
||||
Each model's monthly limit below determines how its usage counts toward those allowances.
|
||||
|
||||
Token prices are per 1M tokens.
|
||||
|
||||
<div class="docs-table-scroll" role="region" aria-label="Go model pricing" tabIndex={0}>
|
||||
<PlanTabs id="go-pricing" label="Go plan" syncKey="go-plan">
|
||||
<div slot="go">
|
||||
|
||||
| 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** |
|
||||
| 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** |
|
||||
|
||||
</div>
|
||||
</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>
|
||||
|
||||
**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 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 / 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 table below provides an estimated request count based on typical Go usage patterns:
|
||||
The tables below estimate request counts based on typical Go usage patterns. Go
|
||||
Plus estimates scale with each model's higher usage limit.
|
||||
|
||||
<div class="docs-table-scroll" role="region" aria-label="Go estimated requests" tabIndex={0}>
|
||||
<PlanTabs id="go-requests" label="Go plan" syncKey="go-plan">
|
||||
<div slot="go">
|
||||
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
</div>
|
||||
</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>
|
||||
|
||||
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/5.1 — 700 input, 52,000 cached, 150 output tokens per request
|
||||
- GLM-5.3/5.2 — 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
|
||||
@@ -249,9 +326,7 @@ 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
|
||||
|
||||
@@ -261,6 +336,7 @@ 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
|
||||
|
||||
@@ -271,7 +347,7 @@ after you've reached your usage limits instead of blocking requests.
|
||||
|
||||
### Why some models have lower usage
|
||||
|
||||
With Go, you pay $10/month, and the included monthly usage varies by model.
|
||||
With Go, 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.
|
||||
|
||||
@@ -294,7 +370,6 @@ 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` |
|
||||
@@ -309,14 +384,11 @@ 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` |
|
||||
@@ -353,7 +425,6 @@ 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 |
|
||||
@@ -364,9 +435,7 @@ 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 |
|
||||
@@ -384,7 +453,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 September 30, 2026.
|
||||
- **DeepSeek:** ZDR agreement is renewed monthly. The current agreement is valid through October 31, 2026.
|
||||
|
||||
## Background
|
||||
|
||||
@@ -400,7 +469,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.
|
||||
|
||||
OpenCode Go gives you access to these models for **$10/month**.
|
||||
Both Go and Go Plus provide access to these models; choose the plan that fits how much you use them.
|
||||
|
||||
## Goals
|
||||
|
||||
|
||||
@@ -912,6 +912,48 @@ 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