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
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