Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
6631e6f
feat: strict mode compliance and tool schema normalization for Concen…
Patel230 Jul 29, 2026
549afd5
fix: fetch Concentrate pricing by default instead of opt-in
Patel230 Jul 29, 2026
961dc2d
feat: implement Concentrate Responses API extended parameters
Patel230 Jul 29, 2026
a51be0a
fix: restore go.mod after workspace testing
Patel230 Jul 29, 2026
00e94dd
feat: add Agnes AI as free OpenAI-compatible provider
Patel230 Jul 29, 2026
ee40aaf
fix: treat unknown capabilities as supported for all providers
Patel230 Jul 29, 2026
8b21d75
fix: add Agnes deployment routing and runtime profile
Patel230 Jul 30, 2026
3761a15
feat: add Agnes AI provider with OmitMaxTokens compat fix
Patel230 Jul 30, 2026
11de5dd
feat: add Agnes-to-Anthropic fallback routing
Patel230 Jul 30, 2026
d98c382
feat: add LongCat AI provider (OpenAI-compatible endpoint)
Patel230 Jul 30, 2026
b8d2ed9
feat: add longcat-anthropic fallback deployment
Patel230 Jul 30, 2026
2999be6
fix: remove OpenRouter fallback from LongCat routing
Patel230 Jul 30, 2026
1cdea5e
fix: alphabetical SortOrder values for all providers
Patel230 Jul 30, 2026
18e452d
fix: keep Agnes and LongCat OpenAI-only with full live capabilities
Patel230 Jul 30, 2026
a5bf104
fix: complete changes for Agnes/LongCat OpenAI-only capabilities
Patel230 Jul 30, 2026
b090ac8
fix: remove invalid ChatOptions field accesses in concentrate adapter…
Patel230 Jul 30, 2026
16ef247
fix: resolve golangci-lint issues for gocritic nilValReturn and unuse…
Patel230 Jul 30, 2026
885e872
style: format test_helpers_test.go with gofumpt
Patel230 Jul 30, 2026
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
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ When starting any new work (feature, fix, refactor, chore), always create a feat

## Design Principles

- **Model-agnostic** — single interface for 75+ LLM providers
- **Model-agnostic** — single interface for 23 registered LLM providers
- **Plan-accurate provider metadata** — Concentrate AI is pay-as-you-go, uses
`concentrate-payg` as its deployment identifier, and uses its native Responses
API (`/v1/responses`) without legacy Chat Completions or Messages routing
- **Host-neutral engine** — Eyrie owns provider routing, transport, caching,
retry/fallback, and normalized telemetry; hosts own product UX and semantics
- **Streaming-first** — all responses are streamed; blocking is opt-in
Expand Down
88 changes: 2 additions & 86 deletions catalog/concentrate/concentrate.go
Original file line number Diff line number Diff line change
@@ -1,90 +1,6 @@
// Package concentrate holds shared constants and helpers for the Concentrate AI gateway
// (https://concentrate.ai). Models are discovered via GET /v1/models; chat
// routing picks /v1/chat/completions or /v1/messages per model based on owned_by.
// Package concentrate holds shared constants and pricing-cache helpers for the
// Concentrate AI Responses API gateway (https://concentrate.ai).
package concentrate

import (
"strings"
"sync"
)

// DefaultBaseURL is the Concentrate AI API root.
const DefaultBaseURL = "https://api.concentrate.ai/v1"

// protocolFromOwner maps Concentrate's owned_by field to a protocol.
// Anthropic models use the Messages API; everything else uses Chat Completions.
func protocolFromOwner(ownedBy string) string {
switch strings.ToLower(strings.TrimSpace(ownedBy)) {
case "anthropic":
return "anthropic"
default:
return "openai"
}
}

// Dynamic protocol map — populated from live fetch results.
var (
protocolMapMu sync.RWMutex
protocolMap = map[string]string{} // model ID → "anthropic" | "openai"
protocolMapValid = false
)

// UpdateProtocolMap refreshes the dynamic protocol map from live fetch entries.
// Called after every successful FetchConcentrate in the discover pipeline.
func UpdateProtocolMap(entries []struct{ ID, Protocol string }) {
protocolMapMu.Lock()
defer protocolMapMu.Unlock()
for _, e := range entries {
id := strings.ToLower(e.ID)
if id == "" || e.Protocol == "" {
continue
}
protocolMap[id] = e.Protocol
}
protocolMapValid = true
}

// UsesMessagesAPI reports whether a model should use Anthropic /v1/messages on
// Concentrate AI.
//
// Resolution order:
// 1. Dynamic protocol map (populated from live /v1/models response)
// 2. Heuristic fallback (model name pattern matching for Claude models)
func UsesMessagesAPI(modelID string) bool {
id := strings.ToLower(modelID)
if id == "" {
return false
}

// Check dynamic map (from live fetch).
protocolMapMu.RLock()
if protocolMapValid {
if proto, ok := protocolMap[id]; ok {
protocolMapMu.RUnlock()
return proto == "anthropic"
}
}
protocolMapMu.RUnlock()

// Fallback to heuristic: Claude models use Messages API.
return strings.Contains(id, "claude")
}

// ProtocolMapSnapshot returns a copy of the current protocol map for testing/debugging.
func ProtocolMapSnapshot() map[string]string {
protocolMapMu.RLock()
defer protocolMapMu.RUnlock()
out := make(map[string]string, len(protocolMap))
for k, v := range protocolMap {
out[k] = v
}
return out
}

// ResetProtocolMap clears the dynamic protocol map. For testing only.
func ResetProtocolMap() {
protocolMapMu.Lock()
defer protocolMapMu.Unlock()
protocolMap = map[string]string{}
protocolMapValid = false
}
99 changes: 0 additions & 99 deletions catalog/concentrate/concentrate_test.go

This file was deleted.

22 changes: 20 additions & 2 deletions catalog/default_catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func TestSeedCatalog_ReturnsCatalog(t *testing.T) {
func TestSeedCatalog_HasProviders(t *testing.T) {
t.Parallel()
c := SeedCatalog()
expected := []string{"anthropic", "openai", "google", "xai", "openrouter", "ollama", "opencodego", "canopywave"}
expected := []string{"anthropic", "openai", "google", "xai", "openrouter", "concentrate", "ollama", "opencodego", "canopywave"}
for _, id := range expected {
if c.Providers[id].ID == "" {
t.Errorf("missing provider %q in default catalog", id)
Expand All @@ -32,7 +32,7 @@ func TestSeedCatalog_HasDeployments(t *testing.T) {
c := SeedCatalog()
expected := []string{
"anthropic-direct", "openai-direct",
"grok-direct", "openrouter", "ollama-local",
"grok-direct", "openrouter", "concentrate-payg", "ollama-local",
"opencodego", "canopywave",
}
for _, id := range expected {
Expand All @@ -42,6 +42,24 @@ func TestSeedCatalog_HasDeployments(t *testing.T) {
}
}

func TestSeedCatalog_ConcentrateIsPayAsYouGo(t *testing.T) {
t.Parallel()
c := SeedCatalog()

if got := c.Providers["concentrate"].Name; got != "Concentrate AI (Pay-as-you-go)" {
t.Fatalf("Concentrate provider name = %q", got)
}
if got := c.Deployments["concentrate-payg"].Name; got != "Concentrate AI (Pay-as-you-go)" {
t.Fatalf("Concentrate deployment name = %q", got)
}
if got := c.Deployments["concentrate-payg"].APIProtocolID; got != "openai-responses" {
t.Fatalf("Concentrate protocol = %q", got)
}
if got := c.Deployments["concentrate-payg"].AdapterConstructor; got != "concentrate-responses" {
t.Fatalf("Concentrate adapter = %q", got)
}
}

func TestSeedCatalog_HasModels(t *testing.T) {
t.Parallel()
c := SeedCatalog()
Expand Down
1 change: 1 addition & 0 deletions catalog/discover/discover.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ func run(ctx context.Context, opts Options) (*catalog.RefreshResult, error) {
}
}

catalog.PruneUnreferencedDeployments(base)
if err := catalog.WriteCatalogCache(opts.CachePath, base); err != nil {
return nil, fmt.Errorf("catalog discover: write cache: %w", err)
}
Expand Down
19 changes: 9 additions & 10 deletions catalog/discover/provider_refresh.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package discover

import (
"context"
"encoding/json"
"fmt"
"strings"
"time"

"github.com/GrayCodeAI/eyrie/catalog"
Expand Down Expand Up @@ -97,8 +97,8 @@ func refreshProvider(ctx context.Context, providerID string, opts ProviderRefres
ID: depID,
Name: provID,
ProviderID: provID,
APIProtocolID: "openai-chat-completions",
AdapterConstructor: "openai",
APIProtocolID: spec.ProtocolID,
AdapterConstructor: spec.AdapterID,
NativeModelIDSource: catalog.NativeModelIDDiscovered,
ModelMappingsRequired: false,
}
Expand All @@ -114,13 +114,8 @@ func refreshProvider(ctx context.Context, providerID string, opts ProviderRefres
name = entryID
}
canonicalID := entryID
var liveMeta map[string]interface{}
if json.Unmarshal(entry.RawJSON, &liveMeta) == nil {
if inputPrice, ok := liveMeta["input_token_price_per_m"]; ok {
if _, ok := inputPrice.(float64); ok {
canonicalID = provID + "/" + entryID
}
}
if !strings.Contains(entryID, "/") {
canonicalID = provID + "/" + entryID
}
cat.Models[canonicalID] = catalog.Model{
ID: canonicalID,
Expand All @@ -142,11 +137,15 @@ func refreshProvider(ctx context.Context, providerID string, opts ProviderRefres
})
}

// Replace the selected deployment's topology as well as its offerings so a
// stale cache cannot retain an older adapter or protocol definition.
delete(base.Deployments, depID)
base = MergeCatalogWithPolicy(base, &cat, MergePolicy{
PreferLive: true,
PreferLiveProviders: []string{catalog.CanonicalProviderID(spec.ProviderID)},
ReplaceDeploymentOfferings: []string{spec.DeploymentID},
})
catalog.PruneUnreferencedDeployments(base)
now := time.Now().UTC().Truncate(time.Second)
base.GeneratedAt = now
base.StaleAfter = now.Add(catalog.LiveStaleDuration)
Expand Down
47 changes: 47 additions & 0 deletions catalog/discover/provider_refresh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,50 @@ func TestRefreshProvider_MergesLiveModelsIntoCache(t *testing.T) {
t.Fatal("expected live metadata on cached offering")
}
}

func TestRefreshProvider_QualifiesUnpricedConcentrateModelIDs(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/models" {
t.Errorf("request path = %q, want /models", r.URL.Path)
}
_, _ = w.Write([]byte(`{
"data": [{
"id": "claude-fable-5",
"display_name": "Claude Fable 5",
"owned_by": "anthropic",
"max_input_tokens": 200000,
"max_tokens": 8192
}]
}`))
}))
defer srv.Close()

cachePath := filepath.Join(t.TempDir(), "model_catalog.json")
base := catalog.SeedCatalog()
if err := catalog.WriteCatalogCache(cachePath, &base); err != nil {
t.Fatal(err)
}

result, err := discover.RefreshProviderWithOptions(context.Background(), "concentrate", discover.ProviderRefreshOptions{
Credentials: catalog.Credentials{APIKeys: map[string]string{
"CONCENTRATE_API_KEY": "test-key-12345678",
"CONCENTRATE_BASE_URL": srv.URL,
}},
CachePath: cachePath,
DisableCredentialFallback: true,
})
if err != nil {
t.Fatal(err)
}
if result == nil || result.Compiled == nil {
t.Fatal("expected compiled catalog")
}
const canonicalID = "concentrate/claude-fable-5"
if _, ok := result.Compiled.ModelsByID[canonicalID]; !ok {
t.Fatalf("compiled catalog is missing canonical model %q", canonicalID)
}
if got, ok := catalog.CanonicalModelForProviderNative(result.Compiled, "concentrate", "claude-fable-5"); !ok || got != canonicalID {
t.Fatalf("native model mapping = %q, %v; want %q, true", got, ok, canonicalID)
}
}
Loading
Loading