Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,6 @@ jobs:
- name: Run markdownlint-cli2
run: |
npm install -g markdownlint-cli2
printf '%s\n' '{"config":{"default":true,"line-length":false,"no-inline-html":false,"first-line-h1":false,"no-duplicate-heading":false,"no-emphasis-as-heading":false,"blanks-around-headings":false,"blanks-around-lists":false,"blanks-around-fences":false,"fenced-code-language":false,"table-column-style":false,"no-space-in-emphasis":false,"ol-prefix":false,"link-fragments":false,"blanks-around-tables":false,"table-column-count":false,"single-trailing-newline":false,"no-multiple-blanks":false,"ul-style":false}}' > .markdownlint-cli2.jsonc
markdownlint-cli2 '**/*.md'

# -------------------------------------------------------------------------
Expand Down
23 changes: 23 additions & 0 deletions .markdownlint-cli2.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"config": {
"default": true,
"line-length": false,
"no-inline-html": false,
"first-line-h1": false,
"no-duplicate-heading": false,
"no-emphasis-as-heading": false,
"blanks-around-headings": false,
"blanks-around-lists": false,
"blanks-around-fences": false,
"fenced-code-language": false,
"table-column-style": false,
"no-space-in-emphasis": false,
"ol-prefix": false,
"link-fragments": false,
"blanks-around-tables": false,
"table-column-count": false,
"single-trailing-newline": false,
"no-multiple-blanks": false,
"ul-style": false
}
}
2 changes: 1 addition & 1 deletion catalog/testdata_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
// Run with EXPORT_HAWK_FIXTURE=1 to refresh hawk/internal/catalogtest/testdata/minimal_v1.json
func TestExportHawkCatalogFixture(t *testing.T) {
if os.Getenv("EXPORT_HAWK_FIXTURE") != "1" {
t.Skip("set EXPORT_HAWK_FIXTURE=1 to export")
t.Skip("set EXPORT_HAWK_FIXTURE=1 to export") // TODO: https://github.com/GrayCodeAI/eyrie/issues/30
}
c := testLegacyCatalogV1()
data, err := json.MarshalIndent(c, "", " ")
Expand Down
4 changes: 2 additions & 2 deletions catalog/user_catalog_dump_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ import (
func TestUserCatalog_GatewayCountsMatchDeploymentOfferings(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip(err)
t.Skip(err) // TODO: https://github.com/GrayCodeAI/eyrie/issues/31
}
path := filepath.Join(home, ".eyrie", "model_catalog.json")
if _, err := os.Stat(path); err != nil {
t.Skip("no user catalog")
t.Skip("no user catalog") // TODO: https://github.com/GrayCodeAI/eyrie/issues/31
}
compiled, err := catalog.LoadCatalogV1(context.Background(), catalog.LoadCatalogV1Options{
CachePath: path,
Expand Down
6 changes: 3 additions & 3 deletions client/benchmarks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,12 @@ func BenchmarkCachedProvider_CacheHit(b *testing.B) {
opts := ChatOptions{Model: "gpt-4"}

// Prime the cache
_, _ = cp.Chat(context.TODO(), messages, opts)
_, _ = cp.Chat(context.Background(), messages, opts)

b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = cp.Chat(context.TODO(), messages, opts)
_, _ = cp.Chat(context.Background(), messages, opts)
}
}

Expand All @@ -153,7 +153,7 @@ func BenchmarkCachedProvider_CacheMiss(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
messages := []EyrieMessage{{Role: "user", Content: "unique query"}}
_, _ = cp.Chat(context.TODO(), messages, opts)
_, _ = cp.Chat(context.Background(), messages, opts)
}
}

Expand Down
30 changes: 28 additions & 2 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ func TestDetectProvider(t *testing.T) {
credentials.SetDefaultStore(store)
t.Cleanup(func() { credentials.SetDefaultStore(nil) })

for _, k := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "CANOPYWAVE_API_KEY", "ZAI_API_KEY", "OPENCODEGO_API_KEY", "OLLAMA_BASE_URL"} {
for _, k := range []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "CANOPYWAVE_API_KEY", "DEEPSEEK_API_KEY", "ZAI_API_KEY", "OPENCODEGO_API_KEY", "MOONSHOT_API_KEY", "XIAOMI_MIMO_PAYG_API_KEY", "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", "OLLAMA_BASE_URL"} {
_ = os.Unsetenv(k)
}
credentials.ScrubProcessEnv([]string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "CANOPYWAVE_API_KEY", "ZAI_API_KEY", "OPENCODEGO_API_KEY"})
credentials.ScrubProcessEnv([]string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "XAI_API_KEY", "GEMINI_API_KEY", "CANOPYWAVE_API_KEY", "DEEPSEEK_API_KEY", "ZAI_API_KEY", "OPENCODEGO_API_KEY", "MOONSHOT_API_KEY", "XIAOMI_MIMO_PAYG_API_KEY", "XIAOMI_MIMO_TOKEN_PLAN_API_KEY"})

ctx := context.Background()
if p := DetectProvider(); p != "anthropic" {
Expand All @@ -34,6 +34,32 @@ func TestDetectProvider(t *testing.T) {
}
}

func TestDetectProvider_AdditionalProviders(t *testing.T) {
tests := []struct {
name string
env string
want string
}{
{name: "deepseek", env: "DEEPSEEK_API_KEY", want: "deepseek"},
{name: "kimi", env: "MOONSHOT_API_KEY", want: "kimi"},
{name: "xiaomi payg", env: "XIAOMI_MIMO_PAYG_API_KEY", want: "xiaomi_mimo_payg"},
{name: "xiaomi token plan", env: "XIAOMI_MIMO_TOKEN_PLAN_API_KEY", want: "xiaomi_mimo_token_plan"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
store := &credentials.MapStore{}
credentials.SetDefaultStore(store)
t.Cleanup(func() { credentials.SetDefaultStore(nil) })
if err := store.Set(context.Background(), credentials.AccountForEnv(tc.env), "test"); err != nil {
t.Fatal(err)
}
if got := DetectProvider(); got != tc.want {
t.Fatalf("DetectProvider() = %q, want %q", got, tc.want)
}
})
}
}

func TestParseCustomHeaders(t *testing.T) {
_ = os.Setenv("GRAYCODE_CUSTOM_HEADERS", "X-Custom: value1\nX-Other: value2")
defer func() { _ = os.Unsetenv("GRAYCODE_CUSTOM_HEADERS") }()
Expand Down
186 changes: 186 additions & 0 deletions client/opencodego.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package client

import (
"context"
"strings"
)

// OpenCodeGoClient routes OpenCode Go models to the correct upstream API format.
// Most models use OpenAI-compatible /v1/chat/completions; MiniMax and Qwen3.x
// models require Anthropic-compatible /v1/messages (see opencode.ai/docs/go).
type OpenCodeGoClient struct {
openAI *OpenAIClient
anthropic *AnthropicClient
}

// NewOpenCodeGoClient builds a dual-protocol OpenCode Go provider client.
func NewOpenCodeGoClient(apiKey, baseURL string, opts ...ClientOption) *OpenCodeGoClient {
openBase := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if openBase == "" {
openBase = "https://opencode.ai/zen/go/v1"
}
ocgOpts := append(append([]ClientOption{}, opts...), WithProviderName("opencodego"))
o := NewOpenAIClient(apiKey, openBase, &OpenCodeGoCompat, ocgOpts...)
// Anthropic /messages on OpenCode Go uses X-Api-Key (not Bearer, not MiMo api-key).
a := NewAnthropicClient(apiKey, openCodeGoAnthropicBase(openBase), ocgOpts...)
return &OpenCodeGoClient{openAI: o, anthropic: a}
}

func (c *OpenCodeGoClient) Name() string { return "opencodego" }

func (c *OpenCodeGoClient) Chat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*EyrieResponse, error) {
if openCodeGoUsesAnthropicMessages(opts.Model) {
return c.anthropic.Chat(ctx, messages, opts)
}
resp, err := c.openAI.Chat(ctx, messages, opts)
if err != nil || c.anthropic == nil || !openCodeGoMightNeedAnthropicFallback(opts.Model) {
return resp, err
}
if resp != nil && strings.TrimSpace(resp.Content) != "" {
return resp, nil
}
anthropicResp, anthropicErr := c.anthropic.Chat(ctx, messages, opts)
if anthropicErr == nil && anthropicResp != nil && strings.TrimSpace(anthropicResp.Content) != "" {
return anthropicResp, nil
}
return resp, err
}

func (c *OpenCodeGoClient) StreamChat(ctx context.Context, messages []EyrieMessage, opts ChatOptions) (*StreamResult, error) {
if openCodeGoUsesAnthropicMessages(opts.Model) {
return c.anthropic.StreamChat(ctx, messages, opts)
}
primary, err := c.openAI.StreamChat(ctx, messages, opts)
if err != nil || c.anthropic == nil || !openCodeGoMightNeedAnthropicFallback(opts.Model) {
return primary, err
}
return newOpenCodeGoFallbackStream(ctx, c.anthropic, messages, opts, primary), nil
}

func (c *OpenCodeGoClient) Ping(ctx context.Context) error {
if err := c.openAI.Ping(ctx); err == nil {
return nil
}
return c.anthropic.Ping(ctx)
}

// newOpenCodeGoFallbackStream watches an OpenAI-format stream; if it ends with
// reasoning tokens but no answer, transparently retries via /v1/messages.
func newOpenCodeGoFallbackStream(ctx context.Context, anthropic *AnthropicClient, messages []EyrieMessage, opts ChatOptions, primary *StreamResult) *StreamResult {
out := make(chan EyrieStreamEvent, streamChannelBuffer)
cancelCtx, cancel := context.WithCancel(ctx)
go func() {
defer close(out)
defer primary.Close()
defer cancel()

var (
sawReasoning bool
contentLen int
toolCalls int
buffered []EyrieStreamEvent
streamErr bool
)
flush := func() {
for _, ev := range buffered {
select {
case out <- ev:
case <-cancelCtx.Done():
return
}
}
buffered = nil
}
for ev := range primary.Events {
switch ev.Type {
case "thinking":
sawReasoning = true
case "content":
contentLen += len(ev.Content)
case "tool_call":
toolCalls++
case "error":
streamErr = true
}
if contentLen > 0 || toolCalls > 0 {
flush()
select {
case out <- ev:
case <-cancelCtx.Done():
return
}
continue
}
buffered = append(buffered, ev)
}

health := DetectResponseHealth(ResponseSignals{
SawReasoning: sawReasoning,
ContentLen: contentLen,
ToolCalls: toolCalls,
StreamEnded: true,
StreamErr: streamErr,
})
if health != ResponseErrorOnlyReasoning {
flush()
return
}

fallback, err := anthropic.StreamChat(cancelCtx, messages, opts)
if err != nil {
flush()
select {
case out <- EyrieStreamEvent{Type: "error", Error: err.Error()}:
case <-cancelCtx.Done():
}
return
}
defer fallback.Close()
for ev := range fallback.Events {
select {
case out <- ev:
case <-cancelCtx.Done():
return
}
}
}()
return NewStreamResult(out, cancel)
}

// openCodeGoAnthropicBase strips the /v1 suffix so AnthropicClient posts to
// {base}/v1/messages (e.g. https://opencode.ai/zen/go/v1/messages).
func openCodeGoAnthropicBase(openAIBase string) string {
base := strings.TrimRight(strings.TrimSpace(openAIBase), "/")
if strings.HasSuffix(base, "/v1") {
return strings.TrimSuffix(base, "/v1")
}
return base
}

// openCodeGoUsesAnthropicMessages reports whether a model must use the Anthropic
// Messages API on OpenCode Go (not OpenAI chat/completions).
func openCodeGoUsesAnthropicMessages(model string) bool {
model = openCodeGoNativeModel(model)
switch {
case strings.Contains(model, "minimax"):
return true
case strings.HasPrefix(model, "qwen3."):
return true
default:
return false
}
}

func openCodeGoMightNeedAnthropicFallback(model string) bool {
return openCodeGoUsesAnthropicMessages(model)
}

func openCodeGoNativeModel(model string) string {
model = strings.ToLower(strings.TrimSpace(model))
if i := strings.LastIndex(model, "/"); i >= 0 {
model = model[i+1:]
}
return model
}

var _ Provider = (*OpenCodeGoClient)(nil)
Loading
Loading