mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 18:08:31 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71b1896778 | ||
|
|
6b5650af94 | ||
|
|
99721c762f |
No files matched your search
+18
-4
@@ -106,6 +106,14 @@ type acpSession struct {
|
||||
restrictModels bool
|
||||
cancel context.CancelFunc
|
||||
history []turnRecord
|
||||
|
||||
// readOnly marks a session published by session/load for a kind that is
|
||||
// not resumable (a child, side or spec sub-run). Load still replays its
|
||||
// transcript, which is the render-only use a client needs, but a prompt
|
||||
// would continue the sub-run as a standalone conversation, which every
|
||||
// other resume path refuses (sessions.IsResumableKind). Set once when the
|
||||
// session is first registered and never changed, so it is read without mu.
|
||||
readOnly bool
|
||||
}
|
||||
|
||||
// NewAgent builds the ACP server and registers its method handlers on conn.
|
||||
@@ -204,7 +212,7 @@ func (a *Agent) handleSessionNew(ctx context.Context, params json.RawMessage) (a
|
||||
if err != nil {
|
||||
return nil, RPCError(codeInternalError, "create session: "+err.Error())
|
||||
}
|
||||
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels)
|
||||
sess, _ := a.registerSession(meta.SessionID, root, nil, model, models, restrictModels, false)
|
||||
return NewSessionResult{
|
||||
SessionID: sess.id,
|
||||
ConfigOptions: a.configOptions(sess),
|
||||
@@ -311,7 +319,10 @@ func (a *Agent) activatePersistedSession(ctx context.Context, p LoadSessionParam
|
||||
models = append(models, SessionConfigOptionValue{Value: persistedModel, Name: persistedModel})
|
||||
}
|
||||
}
|
||||
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels)
|
||||
// Resume has already refused a non-resumable kind above, so only load can
|
||||
// reach here with one, and it publishes that session for viewing only.
|
||||
readOnly := !sessions.IsResumableKind(meta.SessionKind)
|
||||
sess, existed := a.registerSession(meta.SessionID, root, history, model, models, restrictModels, readOnly)
|
||||
if existed {
|
||||
_, messages, historyWarning, historyErr = a.refreshSessionHistory(sess, requireHistoryLog)
|
||||
if operation == persistedSessionResume && historyErr != nil {
|
||||
@@ -433,6 +444,9 @@ func (a *Agent) handleSessionPrompt(ctx context.Context, params json.RawMessage)
|
||||
if sess == nil {
|
||||
return nil, RPCError(codeInvalidParams, "unknown session: "+p.SessionID)
|
||||
}
|
||||
if sess.readOnly {
|
||||
return nil, RPCError(codeInvalidParams, "session is not resumable, so it was loaded for viewing only and cannot be prompted: "+p.SessionID)
|
||||
}
|
||||
|
||||
// Serialize turns for this session so two prompts can't interleave history or
|
||||
// fight over the single cancel slot. session/cancel still works concurrently
|
||||
@@ -1154,13 +1168,13 @@ func promptImages(blocks []ContentBlock) []zeroruntime.ImageBlock {
|
||||
// The bool reports that case so persisted activation can re-read and apply disk
|
||||
// history under turnMu. history is set BEFORE first publication so no concurrent
|
||||
// prompt can read a half-initialized session.
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool) (*acpSession, bool) {
|
||||
func (a *Agent) registerSession(id, cwd string, history []turnRecord, model string, models []SessionConfigOptionValue, restrictModels bool, readOnly bool) (*acpSession, bool) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if existing := a.sessions[id]; existing != nil {
|
||||
return existing, true
|
||||
}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history}
|
||||
sess := &acpSession{id: id, cwd: cwd, mode: agent.PermissionModeAuto, model: model, models: models, restrictModels: restrictModels, history: history, readOnly: readOnly}
|
||||
a.sessions[id] = sess
|
||||
return sess, false
|
||||
}
|
||||
|
||||
@@ -1251,6 +1251,85 @@ func TestACPResumeAppliesStandaloneSessionKindPolicy(t *testing.T) {
|
||||
}, &LoadSessionResult{}); err != nil {
|
||||
t.Fatalf("session/load should retain render-only access: %v", err)
|
||||
}
|
||||
// The check above only showed the refused resume never registered the
|
||||
// session. Load does register it, so the prompt that matters is the one
|
||||
// AFTER the load: render-only means the transcript can be read and the
|
||||
// sub-run cannot be continued.
|
||||
err = h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("must stay closed after load")},
|
||||
}, &PromptResult{})
|
||||
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInvalidParams {
|
||||
t.Fatalf("session/prompt after load of a %s session = %v, want invalid params", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// LOAD PUBLISHES A SUB-RUN FOR VIEWING, NOT FOR CONTINUING. Every other resume
|
||||
// path (the TUI, exec sessions, the exec CLI, and ACP's own session/resume)
|
||||
// refuses a kind sessions.IsResumableKind rejects. session/load has a real
|
||||
// render-only use, a client rebuilding a transcript it can scroll, so it keeps
|
||||
// loading those sessions; what it must not do is publish one that accepts a
|
||||
// prompt, because the prompt would carry on a child, side or spec sub-run as a
|
||||
// standalone conversation. Checked in both directions, so a gate that refused
|
||||
// everything would fail on the resumable kinds.
|
||||
func TestACPLoadPublishesSubRunsReadOnly(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
kind sessions.SessionKind
|
||||
readOnly bool
|
||||
}{
|
||||
{name: "regular", kind: ""},
|
||||
{name: "fork", kind: sessions.SessionKindFork},
|
||||
{name: "child", kind: sessions.SessionKindChild, readOnly: true},
|
||||
{name: "side", kind: sessions.SessionKindSide, readOnly: true},
|
||||
{name: "spec draft", kind: sessions.SessionKindSpecDraft, readOnly: true},
|
||||
{name: "spec impl", kind: sessions.SessionKindSpecImpl, readOnly: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := !sessions.IsResumableKind(tc.kind); got != tc.readOnly {
|
||||
t.Fatalf("SETUP INVALID: IsResumableKind(%q) disagrees with this table (readOnly=%v), so the cases no longer mean what they say", tc.kind, tc.readOnly)
|
||||
}
|
||||
deps := testDeps(t)
|
||||
workspace := t.TempDir()
|
||||
created, err := deps.Store.Create(sessions.CreateInput{
|
||||
SessionID: "load-" + strings.ReplaceAll(tc.name, " ", "-"), Cwd: workspace, SessionKind: tc.kind,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := newHarness(t, deps)
|
||||
defer h.stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.client.Call(ctx, MethodSessionLoad, LoadSessionParams{
|
||||
SessionID: created.SessionID, Cwd: workspace,
|
||||
}, &LoadSessionResult{}); err != nil {
|
||||
t.Fatalf("session/load of a %s session: %v", tc.name, err)
|
||||
}
|
||||
err = h.client.Call(ctx, MethodSessionPrompt, PromptParams{
|
||||
SessionID: created.SessionID, Prompt: []ContentBlock{TextBlock("continue")},
|
||||
}, &PromptResult{})
|
||||
// A resumable session has to be genuinely promptable, not merely "not
|
||||
// refused as view-only": any other failure would hide a broken
|
||||
// writable path behind a passing test.
|
||||
if !tc.readOnly {
|
||||
if err != nil {
|
||||
t.Fatalf("session/prompt after load of a %s session = %v, want success", tc.name, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
// The decision is the RPC code; the message only distinguishes the
|
||||
// view-only refusal from other invalid-params errors such as an
|
||||
// unknown session id.
|
||||
var rpcErr *rpcError
|
||||
if !errors.As(err, &rpcErr) || rpcErr.Code != codeInvalidParams {
|
||||
t.Fatalf("session/prompt after load of a %s session = %v, want invalid params", tc.name, err)
|
||||
}
|
||||
if !strings.Contains(rpcErr.Message, "loaded for viewing only") {
|
||||
t.Fatalf("session/prompt after load of a %s session was refused for another reason: %v", tc.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ type Options struct {
|
||||
// When unset, Zero uses providerio.ResolveStreamIdleTimeout — the
|
||||
// ZERO_STREAM_IDLE_TIMEOUT override or providerio.DefaultStreamIdleTimeout.
|
||||
StreamIdleTimeout time.Duration
|
||||
SessionHeader string
|
||||
}
|
||||
|
||||
// Provider streams completions from Anthropic's Messages API.
|
||||
@@ -108,6 +109,7 @@ type Provider struct {
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
streamIdleTimeout time.Duration
|
||||
sessionHeader providerio.SessionHeader
|
||||
}
|
||||
|
||||
// New creates an Anthropic provider.
|
||||
@@ -143,6 +145,7 @@ func New(options Options) (*Provider, error) {
|
||||
httpClient: providerio.HTTPClient(options.HTTPClient),
|
||||
userAgent: options.UserAgent,
|
||||
streamIdleTimeout: providerio.ResolveStreamIdleTimeout(options.StreamIdleTimeout),
|
||||
sessionHeader: providerio.NewSessionHeader(options.SessionHeader),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -163,12 +166,12 @@ func (provider *Provider) StreamCompletion(
|
||||
events := make(chan zeroruntime.StreamEvent, 16)
|
||||
go func() {
|
||||
defer close(events)
|
||||
provider.stream(ctx, body, events)
|
||||
provider.stream(ctx, body, request.PromptCacheKey, events)
|
||||
}()
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, sessionID string, events chan<- zeroruntime.StreamEvent) {
|
||||
// streamCtx lets the idle watchdog abort an in-flight body read by cancelling
|
||||
// the request, which unblocks the SSE reader goroutine.
|
||||
streamCtx, cancelStream := context.WithCancel(ctx)
|
||||
@@ -185,6 +188,7 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
},
|
||||
provider.oauthResolver,
|
||||
func(request *http.Request) {
|
||||
provider.sessionHeader.Apply(request, sessionID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("anthropic-version", provider.version)
|
||||
if provider.beta != "" {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
@@ -53,6 +54,11 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
||||
return newCodexProvider(profile, resolved, options)
|
||||
}
|
||||
|
||||
sessionHeader := sessionHeaderForProfile(profile, resolved)
|
||||
if sessionHeader != "" && strings.TrimSpace(options.UserAgent) == "" {
|
||||
options.UserAgent = "zero"
|
||||
}
|
||||
|
||||
switch resolved.providerKind {
|
||||
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible:
|
||||
// prompt_cache_key is an OpenAI-only chat-completions field. Strict
|
||||
@@ -74,6 +80,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
||||
UserAgent: options.UserAgent,
|
||||
ParseThinkTags: parseThinkTagsForProfile(profile, resolved),
|
||||
DisablePromptCacheKey: resolved.providerKind == config.ProviderKindOpenAICompatible,
|
||||
SessionHeader: sessionHeader,
|
||||
})
|
||||
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
|
||||
return anthropic.New(anthropic.Options{
|
||||
@@ -88,6 +95,7 @@ func New(profile config.ProviderProfile, options Options) (zeroruntime.Provider,
|
||||
MaxTokens: resolved.maxOutputTokens,
|
||||
HTTPClient: options.HTTPClient,
|
||||
UserAgent: options.UserAgent,
|
||||
SessionHeader: sessionHeader,
|
||||
})
|
||||
case config.ProviderKindGoogle:
|
||||
return gemini.New(gemini.Options{
|
||||
@@ -345,6 +353,18 @@ func isCodexCatalog(profile config.ProviderProfile, _ resolvedProfile) bool {
|
||||
return providercatalog.NormalizeID(profile.CatalogID) == "chatgpt"
|
||||
}
|
||||
|
||||
func sessionHeaderForProfile(profile config.ProviderProfile, resolved resolvedProfile) string {
|
||||
switch providercatalog.NormalizeID(profile.CatalogID) {
|
||||
case "opencode", "opencode-go", "opencode-go-anthropic-compatible":
|
||||
return "x-opencode-session"
|
||||
}
|
||||
endpoint, err := url.Parse(resolved.baseURL)
|
||||
if err == nil && strings.EqualFold(endpoint.Hostname(), "opencode.ai") && (endpoint.Path == "/zen" || strings.HasPrefix(endpoint.Path, "/zen/")) {
|
||||
return "x-opencode-session"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// newCodexProvider builds a Codex-flavored openai provider for the chatgpt
|
||||
// catalog. The Codex headers (`originator`, `chatgpt-account-id`) are
|
||||
// injected by the CodexProvider wrapper. The `chatgpt-account-id` is read from
|
||||
|
||||
@@ -3,13 +3,17 @@ package providers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/Gitlawb/zero/internal/config"
|
||||
"github.com/Gitlawb/zero/internal/oauth"
|
||||
"github.com/Gitlawb/zero/internal/providers/providerio"
|
||||
"github.com/Gitlawb/zero/internal/zeroruntime"
|
||||
)
|
||||
|
||||
@@ -794,3 +798,566 @@ func TestResolveRuntimeMetadataRejectsModelOutsideProviderAllowlist(t *testing.T
|
||||
t.Fatalf("error = %q, want it to name the rejected model", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeader(t *testing.T) {
|
||||
const sessionHeader = "x-opencode-session"
|
||||
tests := []struct {
|
||||
name string
|
||||
profile config.ProviderProfile
|
||||
response string
|
||||
wantHeader bool
|
||||
wantAuth map[string]string
|
||||
}{
|
||||
{
|
||||
name: "catalog opencode-go default URL",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc-go",
|
||||
Model: "deepseek-v4-pro",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-oc-go"},
|
||||
},
|
||||
{
|
||||
name: "catalog opencode-go-anthropic-compatible",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "opencode-go-anthropic",
|
||||
CatalogID: "opencode-go-anthropic-compatible",
|
||||
ProviderKind: config.ProviderKindAnthropicCompat,
|
||||
APIKey: "sk-oc-ant",
|
||||
Model: "minimax-m3",
|
||||
},
|
||||
response: "data: {\"type\":\"message_stop\"}\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"x-api-key": "sk-oc-ant"},
|
||||
},
|
||||
{
|
||||
name: "catalog opencode Zen",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "opencode-zen",
|
||||
CatalogID: "opencode",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-zen",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-zen"},
|
||||
},
|
||||
{
|
||||
name: "renamed catalog profile with custom proxy URL",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "my-opencode",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://proxy.example/v1",
|
||||
APIKey: "sk-proxy",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-proxy"},
|
||||
},
|
||||
{
|
||||
name: "catalog-less OpenAI-compatible OpenCode Go URL",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "manual-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://opencode.ai/zen/go/v1",
|
||||
APIKey: "sk-manual",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-manual"},
|
||||
},
|
||||
{
|
||||
name: "catalog-less Anthropic-compatible OpenCode Go URL",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "manual-go-anthropic",
|
||||
ProviderKind: config.ProviderKindAnthropicCompat,
|
||||
BaseURL: "https://opencode.ai/zen/go",
|
||||
APIKey: "sk-manual-ant",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: {\"type\":\"message_stop\"}\n\n",
|
||||
wantHeader: true,
|
||||
wantAuth: map[string]string{"x-api-key": "sk-manual-ant"},
|
||||
},
|
||||
{
|
||||
name: "official OpenAI",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "openai",
|
||||
ProviderKind: config.ProviderKindOpenAI,
|
||||
APIKey: "sk-openai",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-openai"},
|
||||
},
|
||||
{
|
||||
name: "generic OpenAI-compatible",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "generic",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://provider.example/v1",
|
||||
APIKey: "sk-generic",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-generic"},
|
||||
},
|
||||
{
|
||||
name: "official Anthropic",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "anthropic",
|
||||
ProviderKind: config.ProviderKindAnthropic,
|
||||
APIKey: "sk-ant",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: {\"type\":\"message_stop\"}\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"x-api-key": "sk-ant"},
|
||||
},
|
||||
{
|
||||
name: "lookalike subdomain is not OpenCode",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "deceptive-host",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://opencode.ai.example/zen/go/v1",
|
||||
APIKey: "sk-fake",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-fake"},
|
||||
},
|
||||
{
|
||||
name: "lookalike path is not OpenCode",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "deceptive-path",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://example.com/opencode.ai/zen/go",
|
||||
APIKey: "sk-fake",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-fake"},
|
||||
},
|
||||
{
|
||||
name: "zenith path is not Zen",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "zenith",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
BaseURL: "https://opencode.ai/zenith",
|
||||
APIKey: "sk-zenith",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
wantHeader: false,
|
||||
wantAuth: map[string]string{"Authorization": "Bearer sk-zenith"},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
transport := &captureTransport{responseBody: test.response}
|
||||
provider, err := New(test.profile, Options{
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
UserAgent: "zero-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
PromptCacheKey: "conversation-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
if transport.request == nil {
|
||||
t.Fatal("HTTP client was not used")
|
||||
}
|
||||
got := transport.request.Header.Get(sessionHeader)
|
||||
if test.wantHeader && got != "conversation-a" {
|
||||
t.Fatalf("%s = %q, want %q", sessionHeader, got, "conversation-a")
|
||||
}
|
||||
if !test.wantHeader && got != "" {
|
||||
t.Fatalf("%s = %q, want no session header", sessionHeader, got)
|
||||
}
|
||||
for header, want := range test.wantAuth {
|
||||
if got := transport.request.Header.Get(header); got != want {
|
||||
t.Fatalf("%s = %q, want %q", header, got, want)
|
||||
}
|
||||
}
|
||||
if got := transport.request.Header.Get("User-Agent"); got != "zero-test" {
|
||||
t.Fatalf("User-Agent = %q, want zero-test", got)
|
||||
}
|
||||
if test.profile.ProviderKind == config.ProviderKindOpenAICompatible {
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(transport.body()).Decode(&body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if _, ok := body["prompt_cache_key"]; ok {
|
||||
t.Fatalf("openai-compatible body carried prompt_cache_key: %#v", body["prompt_cache_key"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderFollowsConversationKey(t *testing.T) {
|
||||
transport := &recordingTransport{status: http.StatusOK, body: "data: [DONE]\n\n"}
|
||||
profile := config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
}
|
||||
options := Options{HTTPClient: &http.Client{Transport: transport}, UserAgent: "zero-test"}
|
||||
provider, err := New(profile, options)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
for _, key := range []string{"conversation-a", "conversation-a", "conversation-b"} {
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
PromptCacheKey: key,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
}
|
||||
got := transport.sessionHeaders()
|
||||
want := []string{"conversation-a", "conversation-a", "conversation-b"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("captured %d requests, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("request %d x-opencode-session = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
|
||||
reconstructed, err := New(profile, options)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := reconstructed.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
PromptCacheKey: "conversation-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
got = transport.sessionHeaders()
|
||||
if last := got[len(got)-1]; last != "conversation-a" {
|
||||
t.Fatalf("reconstructed provider sent %s = %q, want conversation-a", "x-opencode-session", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderCustomOverride(t *testing.T) {
|
||||
custom := map[string]string{"X-Opencode-Session": "explicit-session"}
|
||||
profile := config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
CustomHeaders: custom,
|
||||
}
|
||||
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
|
||||
provider, err := New(profile, Options{HTTPClient: &http.Client{Transport: transport}, UserAgent: "zero-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
PromptCacheKey: "conversation-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
if got := transport.request.Header.Get("x-opencode-session"); got != "explicit-session" {
|
||||
t.Fatalf("x-opencode-session = %q, want explicit custom value", got)
|
||||
}
|
||||
if len(custom) != 1 || custom["X-Opencode-Session"] != "explicit-session" {
|
||||
t.Fatalf("profile CustomHeaders mutated: %#v", custom)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderFallback(t *testing.T) {
|
||||
transport := &recordingTransport{status: http.StatusOK, body: "data: [DONE]\n\n"}
|
||||
profile := config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
}
|
||||
options := Options{HTTPClient: &http.Client{Transport: transport}}
|
||||
first, err := New(profile, options)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
second, err := New(profile, options)
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
request := zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
}
|
||||
for _, provider := range []zeroruntime.Provider{first, first, second} {
|
||||
stream, err := provider.StreamCompletion(context.Background(), request)
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
}
|
||||
headers := transport.sessionHeaders()
|
||||
if len(headers) != 3 {
|
||||
t.Fatalf("captured %d requests, want 3", len(headers))
|
||||
}
|
||||
if headers[0] == "" || headers[1] != headers[0] {
|
||||
t.Fatalf("fallback headers = %q, %q — want one stable nonempty value per provider", headers[0], headers[1])
|
||||
}
|
||||
if headers[2] == headers[0] {
|
||||
t.Fatalf("distinct provider instances shared fallback %q", headers[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderDefaultsUserAgent(t *testing.T) {
|
||||
transport := &captureTransport{responseBody: "data: [DONE]\n\n"}
|
||||
provider, err := New(config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
}, Options{HTTPClient: &http.Client{Transport: transport}})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
if got := transport.request.Header.Get("User-Agent"); got != "zero" {
|
||||
t.Fatalf("User-Agent = %q, want zero", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderConcurrent(t *testing.T) {
|
||||
transport := &recordingTransport{status: http.StatusOK, body: "data: [DONE]\n\n"}
|
||||
provider, err := New(config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
}, Options{HTTPClient: &http.Client{Transport: transport}})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var errsMu sync.Mutex
|
||||
var errs []string
|
||||
for _, session := range []string{"session-a", "session-b"} {
|
||||
wg.Add(1)
|
||||
go func(session string) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < 5; i++ {
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: fmt.Sprintf("req-%s-%d", session, i)}},
|
||||
PromptCacheKey: session,
|
||||
})
|
||||
if err != nil {
|
||||
errsMu.Lock()
|
||||
errs = append(errs, err.Error())
|
||||
errsMu.Unlock()
|
||||
return
|
||||
}
|
||||
for event := range stream {
|
||||
if event.Type == zeroruntime.StreamEventError {
|
||||
errsMu.Lock()
|
||||
errs = append(errs, event.Error)
|
||||
errsMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}(session)
|
||||
}
|
||||
wg.Wait()
|
||||
for _, errText := range errs {
|
||||
t.Errorf("concurrent stream error: %s", errText)
|
||||
}
|
||||
|
||||
requests := transport.recorded()
|
||||
if len(requests) != 10 {
|
||||
t.Fatalf("captured %d requests, want 10", len(requests))
|
||||
}
|
||||
for _, request := range requests {
|
||||
var payload struct {
|
||||
Messages []struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(request.body), &payload); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if len(payload.Messages) == 0 {
|
||||
t.Fatal("request carried no user message")
|
||||
}
|
||||
content := payload.Messages[0].Content
|
||||
want := "session-a"
|
||||
if strings.Contains(content, "session-b") {
|
||||
want = "session-b"
|
||||
}
|
||||
if got := request.header.Get("x-opencode-session"); got != want {
|
||||
t.Fatalf("request for %q carried x-opencode-session %q, want %q", content, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCodeSessionHeaderAuthRetry(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
profile config.ProviderProfile
|
||||
response string
|
||||
}{
|
||||
{
|
||||
name: "openai-compatible",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "opencode-go",
|
||||
CatalogID: "opencode-go",
|
||||
ProviderKind: config.ProviderKindOpenAICompatible,
|
||||
APIKey: "sk-oc",
|
||||
Model: "test-model",
|
||||
},
|
||||
response: "data: [DONE]\n\n",
|
||||
},
|
||||
{
|
||||
name: "anthropic-compatible",
|
||||
profile: config.ProviderProfile{
|
||||
Name: "opencode-go-anthropic",
|
||||
CatalogID: "opencode-go-anthropic-compatible",
|
||||
ProviderKind: config.ProviderKindAnthropicCompat,
|
||||
APIKey: "sk-oc-ant",
|
||||
Model: "minimax-m3",
|
||||
},
|
||||
response: "data: {\"type\":\"message_stop\"}\n\n",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
transport := &recordingTransport{status: http.StatusOK, body: test.response, unauthorized: 1}
|
||||
var calls atomic.Int32
|
||||
resolver := providerio.TokenResolver(func(context.Context, bool) (string, string, bool, error) {
|
||||
n := calls.Add(1)
|
||||
return "Authorization", fmt.Sprintf("Bearer tok-%d", n), true, nil
|
||||
})
|
||||
provider, err := New(test.profile, Options{
|
||||
HTTPClient: &http.Client{Transport: transport},
|
||||
OAuthResolver: resolver,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
stream, err := provider.StreamCompletion(context.Background(), zeroruntime.CompletionRequest{
|
||||
Messages: []zeroruntime.Message{{Role: zeroruntime.MessageRoleUser, Content: "hello"}},
|
||||
PromptCacheKey: "conversation-a",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("StreamCompletion() error = %v", err)
|
||||
}
|
||||
drainStream(t, stream)
|
||||
requests := transport.recorded()
|
||||
if len(requests) != 2 {
|
||||
t.Fatalf("captured %d requests, want 2 (401 + retry)", len(requests))
|
||||
}
|
||||
for i, request := range requests {
|
||||
if got := request.header.Get("x-opencode-session"); got != "conversation-a" {
|
||||
t.Fatalf("attempt %d x-opencode-session = %q, want conversation-a", i, got)
|
||||
}
|
||||
}
|
||||
if first, second := requests[0].header.Get("Authorization"), requests[1].header.Get("Authorization"); first == second {
|
||||
t.Fatalf("Authorization not refreshed across retry: %q", first)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func drainStream(t *testing.T, stream <-chan zeroruntime.StreamEvent) {
|
||||
t.Helper()
|
||||
for event := range stream {
|
||||
if event.Type == zeroruntime.StreamEventError {
|
||||
t.Fatalf("stream error: %s", event.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type recordedRequest struct {
|
||||
header http.Header
|
||||
body string
|
||||
}
|
||||
|
||||
type recordingTransport struct {
|
||||
mu sync.Mutex
|
||||
requests []recordedRequest
|
||||
status int
|
||||
body string
|
||||
unauthorized int
|
||||
}
|
||||
|
||||
func (transport *recordingTransport) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
var body string
|
||||
if request.Body != nil {
|
||||
data, _ := io.ReadAll(request.Body)
|
||||
body = string(data)
|
||||
}
|
||||
transport.mu.Lock()
|
||||
transport.requests = append(transport.requests, recordedRequest{header: request.Header.Clone(), body: body})
|
||||
attempt := len(transport.requests)
|
||||
transport.mu.Unlock()
|
||||
|
||||
status := transport.status
|
||||
if attempt <= transport.unauthorized {
|
||||
status = http.StatusUnauthorized
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Status: fmt.Sprintf("%d", status),
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(transport.body)),
|
||||
Request: request,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (transport *recordingTransport) recorded() []recordedRequest {
|
||||
transport.mu.Lock()
|
||||
defer transport.mu.Unlock()
|
||||
return append([]recordedRequest(nil), transport.requests...)
|
||||
}
|
||||
|
||||
func (transport *recordingTransport) sessionHeaders() []string {
|
||||
requests := transport.recorded()
|
||||
headers := make([]string, 0, len(requests))
|
||||
for _, request := range requests {
|
||||
headers = append(headers, request.header.Get("x-opencode-session"))
|
||||
}
|
||||
return headers
|
||||
}
|
||||
@@ -65,6 +65,7 @@ type Options struct {
|
||||
// enabled; the ZERO_DISABLE_PROMPT_CACHE_KEY env kill switch still applies
|
||||
// on top for any endpoint.
|
||||
DisablePromptCacheKey bool
|
||||
SessionHeader string
|
||||
}
|
||||
|
||||
// Provider streams completions from an OpenAI-compatible chat completions API.
|
||||
@@ -85,6 +86,7 @@ type Provider struct {
|
||||
parseThinkTags bool
|
||||
setRequestExtra func(*http.Request)
|
||||
disablePromptCacheKey bool
|
||||
sessionHeader providerio.SessionHeader
|
||||
}
|
||||
|
||||
// New creates an OpenAI-compatible provider.
|
||||
@@ -140,6 +142,7 @@ func New(options Options) (*Provider, error) {
|
||||
parseThinkTags: options.ParseThinkTags,
|
||||
setRequestExtra: options.SetRequestExtra,
|
||||
disablePromptCacheKey: options.DisablePromptCacheKey,
|
||||
sessionHeader: providerio.NewSessionHeader(options.SessionHeader),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -156,13 +159,13 @@ func (provider *Provider) StreamCompletion(
|
||||
events := make(chan zeroruntime.StreamEvent, 16)
|
||||
go func() {
|
||||
defer close(events)
|
||||
provider.stream(ctx, body, events)
|
||||
provider.stream(ctx, body, request.PromptCacheKey, events)
|
||||
}()
|
||||
|
||||
return events, nil
|
||||
}
|
||||
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, events chan<- zeroruntime.StreamEvent) {
|
||||
func (provider *Provider) stream(ctx context.Context, body []byte, sessionID string, events chan<- zeroruntime.StreamEvent) {
|
||||
endpoint := provider.endpoint
|
||||
|
||||
// streamCtx lets the idle watchdog abort an in-flight body read by cancelling
|
||||
@@ -186,6 +189,7 @@ func (provider *Provider) stream(ctx context.Context, body []byte, events chan<-
|
||||
},
|
||||
provider.oauthResolver,
|
||||
func(request *http.Request) {
|
||||
provider.sessionHeader.Apply(request, sessionID)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
if provider.userAgent != "" {
|
||||
request.Header.Set("User-Agent", provider.userAgent)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package providerio
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
@@ -52,6 +53,28 @@ func ApplyAuthHeaders(request *http.Request, options AuthHeaders) {
|
||||
request.Header.Set(header, value)
|
||||
}
|
||||
|
||||
type SessionHeader struct {
|
||||
name string
|
||||
fallback string
|
||||
}
|
||||
|
||||
func NewSessionHeader(name string) SessionHeader {
|
||||
if name == "" {
|
||||
return SessionHeader{}
|
||||
}
|
||||
return SessionHeader{name: name, fallback: rand.Text()}
|
||||
}
|
||||
|
||||
func (header SessionHeader) Apply(request *http.Request, sessionID string) {
|
||||
if header.name == "" {
|
||||
return
|
||||
}
|
||||
if sessionID = strings.TrimSpace(sessionID); sessionID == "" {
|
||||
sessionID = header.fallback
|
||||
}
|
||||
request.Header.Set(header.name, sessionID)
|
||||
}
|
||||
|
||||
func CopyHeaders(headers map[string]string) map[string]string {
|
||||
if headers == nil {
|
||||
return nil
|
||||
|
||||
Reference in new issue
Block a user