diff --git a/internal/auth/meta/meta.go b/internal/auth/meta/meta.go index 7912c20f8..a01a00434 100644 --- a/internal/auth/meta/meta.go +++ b/internal/auth/meta/meta.go @@ -111,6 +111,22 @@ func (ts *MetaTokenStorage) SetMetadata(meta map[string]any) { ts.Metadata = meta } +// metaCredentialFields identifies credential and lifecycle fields managed by MetaTokenStorage. +// These fields must never be restored from disk or metadata when omitted from storage. +var metaCredentialFields = map[string]struct{}{ + "type": {}, + "auth_kind": {}, + "access_token": {}, + "token_type": {}, + "dca_token": {}, + "api_key": {}, + "expires_in": {}, + "expired": {}, + "dca_expired": {}, + "dca_expires_at": {}, + "last_refresh": {}, +} + // SaveTokenToFile writes Meta credentials to a JSON auth file. func (ts *MetaTokenStorage) SaveTokenToFile(authFilePath string) error { ts.Type = "meta" @@ -161,6 +177,9 @@ func (ts *MetaTokenStorage) SaveTokenToFile(authFilePath string) error { var existing map[string]any if errJSON := json.Unmarshal(raw, &existing); errJSON == nil { for k, v := range existing { + if _, isCred := metaCredentialFields[k]; isCred { + continue + } if _, exists := data[k]; !exists { data[k] = v } @@ -168,6 +187,9 @@ func (ts *MetaTokenStorage) SaveTokenToFile(authFilePath string) error { } } for k, v := range ts.Metadata { + if _, isCred := metaCredentialFields[k]; isCred { + continue + } if _, exists := data[k]; !exists { data[k] = v } diff --git a/internal/auth/meta/meta_auth_test.go b/internal/auth/meta/meta_auth_test.go index 08943d4f5..496f019ac 100644 --- a/internal/auth/meta/meta_auth_test.go +++ b/internal/auth/meta/meta_auth_test.go @@ -190,3 +190,130 @@ func TestReadLocalMuseCLIAuth(t *testing.T) { t.Errorf("expected email engineer@meta.com, got %s", cred.Email) } } + +func TestSaveTokenToFile_PreservesCustomMetadata(t *testing.T) { + tempDir := t.TempDir() + authFilePath := filepath.Join(tempDir, "meta-test.json") + + storage := &MetaTokenStorage{ + Type: "meta", + AuthKind: "oauth", + AccessToken: "test-access-token", + APIKey: "test-api-key", + DCAToken: "dca:token", + Email: "user@example.com", + } + storage.SetMetadata(map[string]any{ + "priority": float64(10), + "disable_cooling": true, + "proxy_url": "http://proxy:8080", + }) + + if errSave := storage.SaveTokenToFile(authFilePath); errSave != nil { + t.Fatalf("SaveTokenToFile() error = %v", errSave) + } + + savedRaw, errRead := os.ReadFile(authFilePath) + if errRead != nil { + t.Fatalf("os.ReadFile error = %v", errRead) + } + + var saved map[string]any + if errUnmarshal := json.Unmarshal(savedRaw, &saved); errUnmarshal != nil { + t.Fatalf("json.Unmarshal error = %v", errUnmarshal) + } + + if saved["api_key"] != "test-api-key" { + t.Errorf("api_key = %v, want test-api-key", saved["api_key"]) + } + if saved["priority"] != float64(10) { + t.Errorf("priority = %v, want 10", saved["priority"]) + } + if saved["disable_cooling"] != true { + t.Errorf("disable_cooling = %v, want true", saved["disable_cooling"]) + } + if saved["proxy_url"] != "http://proxy:8080" { + t.Errorf("proxy_url = %v, want http://proxy:8080", saved["proxy_url"]) + } +} + +func TestSaveTokenToFile_DoesNotRestoreClearedCredentialFields(t *testing.T) { + tempDir := t.TempDir() + authFilePath := filepath.Join(tempDir, "meta-test.json") + + // Pre-populate an auth file that had expired DCA credentials and custom configuration + initial := map[string]any{ + "type": "meta", + "auth_kind": "oauth", + "access_token": "old-dca-token", + "dca_token": "old-dca-token", + "expired": "2026-01-01T00:00:00Z", + "dca_expired": "2026-01-01T00:00:00Z", + "dca_expires_at": float64(1767225600), + "priority": float64(42), + "models": []any{"muse-latest"}, + "disable-cooling": true, + } + raw, err := json.Marshal(initial) + if err != nil { + t.Fatalf("json.Marshal error = %v", err) + } + if err := os.WriteFile(authFilePath, raw, 0600); err != nil { + t.Fatalf("WriteFile error = %v", err) + } + + // Token storage after minting an API key: Expired is explicitly empty, new APIKey set + storage := &MetaTokenStorage{ + Type: "meta", + AuthKind: "oauth", + AccessToken: "new-minted-key", + APIKey: "new-minted-key", + DCAToken: "new-dca-token", + Expired: "", // Deliberately cleared on refresh + Email: "user@example.com", + } + + if errSave := storage.SaveTokenToFile(authFilePath); errSave != nil { + t.Fatalf("SaveTokenToFile error = %v", errSave) + } + + savedRaw, errRead := os.ReadFile(authFilePath) + if errRead != nil { + t.Fatalf("os.ReadFile error = %v", errRead) + } + + var saved map[string]any + if errUnmarshal := json.Unmarshal(savedRaw, &saved); errUnmarshal != nil { + t.Fatalf("json.Unmarshal error = %v", errUnmarshal) + } + + // Verify cleared credential fields were NOT restored from disk + if val, exists := saved["expired"]; exists && val != "" { + t.Errorf("SaveTokenToFile restored expired timestamp: %v", val) + } + if val, exists := saved["dca_expired"]; exists && val != "" { + t.Errorf("SaveTokenToFile restored dca_expired timestamp: %v", val) + } + if _, exists := saved["dca_expires_at"]; exists { + t.Errorf("SaveTokenToFile restored dca_expires_at timestamp") + } + + // Verify new credentials were written + if saved["api_key"] != "new-minted-key" { + t.Errorf("api_key = %v, want new-minted-key", saved["api_key"]) + } + if saved["access_token"] != "new-minted-key" { + t.Errorf("access_token = %v, want new-minted-key", saved["access_token"]) + } + + // Verify non-credential configuration from disk was preserved + if saved["priority"] != float64(42) { + t.Errorf("priority = %v, want 42", saved["priority"]) + } + if saved["disable-cooling"] != true { + t.Errorf("disable-cooling = %v, want true", saved["disable-cooling"]) + } + if models, ok := saved["models"].([]any); !ok || len(models) != 1 || models[0] != "muse-latest" { + t.Errorf("models = %v, want [muse-latest]", saved["models"]) + } +} diff --git a/internal/runtime/executor/meta_executor_test.go b/internal/runtime/executor/meta_executor_test.go index a896be43f..55d9027df 100644 --- a/internal/runtime/executor/meta_executor_test.go +++ b/internal/runtime/executor/meta_executor_test.go @@ -306,7 +306,7 @@ func TestMetaExecutor_Refresh_SingleflightAndMultiAccount(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - res, err := exec.Refresh(context.Background(), auth1) + res, err := exec.Refresh(context.Background(), auth1.Clone()) if err != nil { t.Errorf("Refresh acct1 error: %v", err) } @@ -326,7 +326,7 @@ func TestMetaExecutor_Refresh_SingleflightAndMultiAccount(t *testing.T) { Provider: "meta", Metadata: map[string]any{"dca_token": "dca:acct2"}, } - res2, err2 := exec.Refresh(context.Background(), auth2) + res2, err2 := exec.Refresh(context.Background(), auth2.Clone()) if err2 != nil { t.Fatalf("Refresh acct2 error: %v", err2) } diff --git a/sdk/cliproxy/auth/api_key_model_alias_test.go b/sdk/cliproxy/auth/api_key_model_alias_test.go index a05bf6230..c5d78699c 100644 --- a/sdk/cliproxy/auth/api_key_model_alias_test.go +++ b/sdk/cliproxy/auth/api_key_model_alias_test.go @@ -291,3 +291,69 @@ func TestResolveAPIKeyModelAliasWithResult_ForceMappingUsesConfigAliasNotRequest t.Fatalf("OriginalAlias = %q want claude-sonnet-4-5", result.OriginalAlias) } } + +func TestLookupAPIKeyUpstreamModel_MetaKey(t *testing.T) { + cfg := &internalconfig.Config{ + MetaKey: []internalconfig.MetaKey{ + { + APIKey: "meta-key", + BaseURL: "https://api.meta.ai/v1", + Models: []internalconfig.CodexModel{ + {Name: "muse-spark-1.3", Alias: "muse-latest"}, + }, + }, + }, + } + + mgr := NewManager(nil, nil, nil) + mgr.SetConfig(cfg) + + ctx := context.Background() + auth := &Auth{ + ID: "meta-auth-1", + Provider: "meta", + Attributes: map[string]string{ + "api_key": "meta-key", + "base_url": "https://api.meta.ai/v1", + "auth_kind": "apikey", + }, + } + if _, err := mgr.Register(ctx, auth); err != nil { + t.Fatalf("register auth: %v", err) + } + + // 1. Fast path: lookup per-auth mapping table compiled during register. + resolved := mgr.lookupAPIKeyUpstreamModel("meta-auth-1", "muse-latest") + if resolved != "muse-spark-1.3" { + t.Fatalf("lookupAPIKeyUpstreamModel() = %q, want muse-spark-1.3", resolved) + } + + // 2. Slow path: directly call mgr.applyAPIKeyModelAliasWithRouting with an empty alias table to exercise config resolution fallback. + slowRouting := &apiKeyModelRoutingSnapshot{ + config: cfg, + aliases: make(apiKeyModelAliasTable), + } + slowResolved := mgr.applyAPIKeyModelAliasWithRouting(slowRouting, auth, "muse-latest") + if slowResolved != "muse-spark-1.3" { + t.Fatalf("applyAPIKeyModelAliasWithRouting(slow) = %q, want muse-spark-1.3", slowResolved) + } + + // 3. Model alias result with force mapping / alias metadata + aliasResult := mgr.resolveAPIKeyModelAliasWithResult(auth, "muse-latest") + if aliasResult.UpstreamModel != "muse-spark-1.3" { + t.Fatalf("resolveAPIKeyModelAliasWithResult() upstream = %q, want muse-spark-1.3", aliasResult.UpstreamModel) + } + + // 4. Configured alias entries helper + entries := configuredModelAliasEntries(cfg, auth) + found := false + for _, e := range entries { + if e.GetAlias() == "muse-latest" && e.GetName() == "muse-spark-1.3" { + found = true + break + } + } + if !found { + t.Fatalf("configuredModelAliasEntries did not contain muse-latest -> muse-spark-1.3: %+v", entries) + } +} diff --git a/sdk/cliproxy/auth/api_key_model_capabilities.go b/sdk/cliproxy/auth/api_key_model_capabilities.go index 48e08ebef..2574ad413 100644 --- a/sdk/cliproxy/auth/api_key_model_capabilities.go +++ b/sdk/cliproxy/auth/api_key_model_capabilities.go @@ -202,6 +202,10 @@ func compileAPIKeyModelCapabilitiesForAuth(cfg *internalconfig.Config, auth *Aut if entry := resolveVertexAPIKeyConfig(cfg, auth); entry != nil { compileConfiguredModelCapabilities(out, entry.Models, "gemini") } + case "meta": + if entry := resolveMetaAPIKeyConfig(cfg, auth); entry != nil { + compileConfiguredModelCapabilities(out, entry.Models, "meta") + } default: providerKey, compatName := "", "" if auth.Attributes != nil { diff --git a/sdk/cliproxy/auth/conductor_execution.go b/sdk/cliproxy/auth/conductor_execution.go index e2ad8514c..2be0a2e71 100644 --- a/sdk/cliproxy/auth/conductor_execution.go +++ b/sdk/cliproxy/auth/conductor_execution.go @@ -378,7 +378,7 @@ func requestToFormat(provider string, executor ProviderExecutor, req cliproxyexe return sdktranslator.FormatClaude case "gemini", "vertex", "aistudio": return sdktranslator.FormatGemini - case "kimi": + case "kimi", "meta": return sdktranslator.FormatOpenAI case "antigravity": return sdktranslator.FormatAntigravity diff --git a/sdk/cliproxy/auth/conductor_models.go b/sdk/cliproxy/auth/conductor_models.go index c39454ec2..b236af35f 100644 --- a/sdk/cliproxy/auth/conductor_models.go +++ b/sdk/cliproxy/auth/conductor_models.go @@ -465,6 +465,10 @@ func configuredModelAliasEntries(cfg *internalconfig.Config, auth *Auth) []model if entry := resolveVertexAPIKeyConfig(cfg, auth); entry != nil { models = asModelAliasEntries(entry.Models) } + case "meta": + if entry := resolveMetaAPIKeyConfig(cfg, auth); entry != nil { + models = asModelAliasEntries(entry.Models) + } default: providerKey := "" compatName := "" @@ -621,6 +625,10 @@ func (m *Manager) rebuildAPIKeyModelAliasLocked(cfg *internalconfig.Config) { if entry := resolveVertexAPIKeyConfig(cfg, auth); entry != nil { compileAPIKeyModelAliasForModels(byAlias, entry.Models) } + case "meta": + if entry := resolveMetaAPIKeyConfig(cfg, auth); entry != nil { + compileAPIKeyModelAliasForModels(byAlias, entry.Models) + } default: // OpenAI-compat uses config selection from auth.Attributes. providerKey := "" @@ -741,6 +749,8 @@ func (m *Manager) applyAPIKeyModelAliasWithRouting(routing *apiKeyModelRoutingSn upstreamModel = resolveUpstreamModelForXAIAPIKey(cfg, auth, requestedModel) case "vertex": upstreamModel = resolveUpstreamModelForVertexAPIKey(cfg, auth, requestedModel) + case "meta": + upstreamModel = resolveUpstreamModelForMetaAPIKey(cfg, auth, requestedModel) default: upstreamModel = resolveUpstreamModelForOpenAICompatAPIKey(cfg, auth, requestedModel) } @@ -848,6 +858,13 @@ func resolveVertexAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internal return resolveAPIKeyConfig(cfg.VertexCompatAPIKey, auth) } +func resolveMetaAPIKeyConfig(cfg *internalconfig.Config, auth *Auth) *internalconfig.MetaKey { + if cfg == nil { + return nil + } + return resolveAPIKeyConfig(cfg.MetaKey, auth) +} + func resolveUpstreamModelForGeminiAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string { entry := resolveGeminiAPIKeyConfig(cfg, auth) if entry == nil { @@ -896,6 +913,14 @@ func resolveUpstreamModelForVertexAPIKey(cfg *internalconfig.Config, auth *Auth, return resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models)) } +func resolveUpstreamModelForMetaAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string { + entry := resolveMetaAPIKeyConfig(cfg, auth) + if entry == nil { + return "" + } + return resolveModelAliasFromConfigModels(requestedModel, asModelAliasEntries(entry.Models)) +} + func resolveUpstreamModelForOpenAICompatAPIKey(cfg *internalconfig.Config, auth *Auth, requestedModel string) string { providerKey := "" compatName := "" diff --git a/sdk/cliproxy/auth/conductor_request_scoped_errors.go b/sdk/cliproxy/auth/conductor_request_scoped_errors.go index b82817123..5e21eea35 100644 --- a/sdk/cliproxy/auth/conductor_request_scoped_errors.go +++ b/sdk/cliproxy/auth/conductor_request_scoped_errors.go @@ -143,6 +143,10 @@ func extractRequestScopedErrorRules(auth *Auth, cfg *internalconfig.Config) []in if index >= 0 && index < len(cfg.XAIKey) { return cfg.XAIKey[index].RequestScopedErrors } + case "meta": + if index >= 0 && index < len(cfg.MetaKey) { + return cfg.MetaKey[index].RequestScopedErrors + } case "gemini": if index >= 0 && index < len(cfg.GeminiKey) { return cfg.GeminiKey[index].RequestScopedErrors