Files
yetone c65ddd988d opencode zen: big-pickle and Zen's other free models without -free in their id are asked as OpenCode asks, not only -free ones (361 on Discord: 能否修复opencode zen免费级无法使用的问题) — magpie already asked Zen's -free models as OpenCode does (streamed, tools named bash and read, a UA of opencode/1.18.34, an x-opencode-session of ses_ + 12 hex + 14 base62, the rules the reporter's picture lists), but OpenCodeFree knew a free model by its -free suffix alone, so big-pickle, free on Zen though its id doesn't say so, was relayed as the client sent it and Zen answered 403 "OpenCode's free tier can only be used from within OpenCode" (500 on Messages and Responses). A model is now free when its id ends in -free, when it is big-pickle or grok-code, or when models.dev's opencode prices it at nothing (input and output 0), so the gateway's reshaping (zenfree.go) and the Test button's probe take it too; a model models.dev prices is not touched. Through an in-process gateway under a sandbox HOME against the real opencode.ai/zen: zen/big-pickle answered 200 on Chat, Messages and Responses (streamed); on the old code 403 FreeTierError on Chat, 500 on the others. TestZenFreeZeroCostModels (big-pickle, a catalog zero-cost model, a priced one) fails on the old code. go vet, builds (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass (TestProvidersAnswerWhileListsComeIn flaked once under load, passes 3/3 alone).
2026-10-05 06:07:03 +08:00

124 lines
4.7 KiB
Go

package provider
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"regexp"
"strings"
"github.com/yetone/magpie/internal/catalog"
)
// OpenCodeVersion is the OpenCode release magpie says it is to OpenCode's
// gateway. Zen's free tier turns away anything older than 1.18.0 (426
// "OpenCode 1.18.0 or newer is required to use the free tier").
const OpenCodeVersion = "1.18.34"
// OpenCodeAnonymousKey is the key OpenCode asks Zen with when no one is
// signed in to it, which Zen's free models answer.
const OpenCodeAnonymousKey = "public"
// OpenCodeClient makes a request to OpenCode's gateway (Zen or Go) carry
// what OpenCode itself sends there (packages/opencode/src/session/llm/
// request.ts, for a provider whose id starts with "opencode"): its
// User-Agent, the session in x-opencode-session, the turn in
// x-opencode-request, and x-opencode-client and x-opencode-project. Zen's
// free models are served to no one else ("OpenCode's free tier can only be
// used from within OpenCode"). session names the conversation; one already
// in OpenCode's form (ses_…) goes as it is, any other becomes one, the same
// for the same conversation, which Zen routes and caches by.
func OpenCodeClient(h http.Header, session string) {
h.Set("User-Agent", "opencode/"+OpenCodeVersion)
h.Set("x-opencode-session", openCodeID("ses", session))
h.Set("x-opencode-request", openCodeID("msg", ""))
h.Set("x-opencode-client", "cli")
// OpenCode's project outside a git repository
h.Set("x-opencode-project", "global")
}
// OpenCodeFree says whether model is one of OpenCode Zen's free ones on p,
// which Zen serves only to what looks like OpenCode: besides the headers
// OpenCodeClient sets, a streamed request offering tools named bash and
// read (lowercase, by name alone; checked against Zen, October 2026). The
// gateway asks them so whoever the client is (gateway/zenfree.go), and the
// Test button's probe so (zenFreeProbe). A free model is one whose id ends
// in -free, or one models.dev's opencode prices at nothing, as big-pickle,
// whose id doesn't say so (361 on Discord: it answered 403 "OpenCode's
// free tier can only be used from within OpenCode"); openCodeFreeIDs are
// such ones known without the catalog.
func (p Provider) OpenCodeFree(model string) bool {
if !p.IsOpenCode() {
return false
}
if strings.HasSuffix(model, "-free") || openCodeFreeIDs[model] {
return true
}
pr, ok := catalog.PriceOf("opencode", model)
return ok && pr.Input == 0 && pr.Output == 0
}
// openCodeFreeIDs are Zen's free models whose ids don't end in -free.
var openCodeFreeIDs = map[string]bool{"big-pickle": true, "grok-code": true}
// OpenCodeTools are the tools Zen's free tier wants offered, by name.
var OpenCodeTools = []string{"bash", "read"}
// OpenCodeStub describes a tool offered only for Zen's free tier to see,
// which the client doesn't have.
const OpenCodeStub = "Not available in this session. Never call this tool."
// zenFreeProbe is the Test button's smallest request, body, asked as Zen's
// free tier wants it: streamed, offering bash and read, which say they
// aren't to be called.
func zenFreeProbe(proto Protocol, body string) string {
var m map[string]any
if json.Unmarshal([]byte(body), &m) != nil {
return body
}
var tools []any
for _, n := range OpenCodeTools {
schema := map[string]any{"type": "object", "properties": map[string]any{}}
switch proto {
case Chat:
tools = append(tools, map[string]any{"type": "function", "function": map[string]any{"name": n, "description": OpenCodeStub, "parameters": schema}})
case Responses:
tools = append(tools, map[string]any{"type": "function", "name": n, "description": OpenCodeStub, "parameters": schema})
default:
tools = append(tools, map[string]any{"name": n, "description": OpenCodeStub, "input_schema": schema})
}
}
m["stream"], m["tools"] = true, tools
b, err := json.Marshal(m)
if err != nil {
return body
}
return string(b)
}
var openCodeIDRe = regexp.MustCompile(`^[a-z]{3}_[0-9a-f]{12}[0-9A-Za-z]{14}$`)
const base62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
// openCodeID is an id as OpenCode makes them (prefix_, 12 hex digits and 14
// base62 ones): seed's own when it is one with this prefix, else made from
// seed, or at random when there is none.
func openCodeID(prefix, seed string) string {
if strings.HasPrefix(seed, prefix+"_") && openCodeIDRe.MatchString(seed) {
return seed
}
var b [32]byte
if seed != "" {
b = sha256.Sum256([]byte(seed))
} else if _, err := rand.Read(b[:]); err != nil {
b = sha256.Sum256([]byte(randomUUID()))
}
id := []byte(prefix + "_" + hex.EncodeToString(b[:6]))
for _, c := range b[6:20] {
id = append(id, base62[int(c)%len(base62)])
}
return string(id)
}