From 15ff353b1c9ccb8ab7c3995570e0810c64a1aefd Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 30 Sep 2026 16:18:32 +0000 Subject: [PATCH 1/2] Add the modelconfiguration domain and its PostgreSQL adapter modelconfiguration owns each Harness's deployment default model configuration: validation through the Harness declaration, sealing the complete bundle, resolving it for Session creation, and the rule for which committed root Turn outcomes say something about the provider. modelconfigurationpg stores the sealed bundle under a new revision, audits each change in the same transaction and runs the fenced last-use observation in its own pooled transaction with server-side timeouts. --- .../modelconfiguration/configuration.go | 57 +++ .../core/internal/modelconfiguration/doc.go | 5 + .../internal/modelconfiguration/errors.go | 16 + .../modelconfiguration/observation.go | 34 ++ .../modelconfiguration/observation_test.go | 30 ++ .../internal/modelconfiguration/service.go | 79 ++++ .../modelconfiguration/service_test.go | 172 ++++++++ .../internal/modelconfiguration/storage.go | 33 ++ .../testdata/observation_cases.json | 20 + .../modelconfigurationpg/fixture_test.go | 155 +++++++ .../modelconfigurationpg/observation_test.go | 392 ++++++++++++++++++ .../postgres/modelconfigurationpg/store.go | 181 ++++++++ .../modelconfigurationpg/store_test.go | 87 ++++ 13 files changed, 1261 insertions(+) create mode 100644 services/core/internal/modelconfiguration/configuration.go create mode 100644 services/core/internal/modelconfiguration/doc.go create mode 100644 services/core/internal/modelconfiguration/errors.go create mode 100644 services/core/internal/modelconfiguration/observation.go create mode 100644 services/core/internal/modelconfiguration/observation_test.go create mode 100644 services/core/internal/modelconfiguration/service.go create mode 100644 services/core/internal/modelconfiguration/service_test.go create mode 100644 services/core/internal/modelconfiguration/storage.go create mode 100644 services/core/internal/modelconfiguration/testdata/observation_cases.json create mode 100644 services/core/internal/persistence/postgres/modelconfigurationpg/fixture_test.go create mode 100644 services/core/internal/persistence/postgres/modelconfigurationpg/observation_test.go create mode 100644 services/core/internal/persistence/postgres/modelconfigurationpg/store.go create mode 100644 services/core/internal/persistence/postgres/modelconfigurationpg/store_test.go diff --git a/services/core/internal/modelconfiguration/configuration.go b/services/core/internal/modelconfiguration/configuration.go new file mode 100644 index 00000000..8ad3066e --- /dev/null +++ b/services/core/internal/modelconfiguration/configuration.go @@ -0,0 +1,57 @@ +package modelconfiguration + +import ( + "encoding/json" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/google/uuid" +) + +// Configuration is the safe view of one Harness's deployment default. The +// provider key is write-only; Provider reports only that one is configured. +// LastUsedAt, LastErrorCode and LastErrorAt are best-effort observations of +// committed root Turns that used this exact revision. +type Configuration struct { + Harness string + Provider v1.ModelProviderView + Model string + HarnessConfig json.RawMessage + UpdatedAt time.Time + LastUsedAt *time.Time + LastErrorCode *string + LastErrorAt *time.Time +} + +// Replacement is one complete deployment default for a Harness, including its +// write-only provider key. +type Replacement struct { + Harness string + Configuration v1.ModelConfigurationInput +} + +// Snapshot is a decrypted deployment default for Session creation, paired with +// the private revision read with it. It never enters a public projection. +type Snapshot struct { + Provider *v1.ModelProviderInput + Model string + HarnessConfig json.RawMessage + Revision uuid.UUID +} + +// Record is a validated deployment default ready to store: its safe columns +// and the sealed complete bundle. Storage assigns a new revision to each +// Record it stores. +type Record struct { + Harness string + Provider v1.ModelProviderView + Model string + HarnessConfig json.RawMessage + Sealed []byte +} + +// Sealed is a stored bundle and the revision read with it. +type Sealed struct { + Bundle []byte + Revision uuid.UUID +} diff --git a/services/core/internal/modelconfiguration/doc.go b/services/core/internal/modelconfiguration/doc.go new file mode 100644 index 00000000..3fadaa78 --- /dev/null +++ b/services/core/internal/modelconfiguration/doc.go @@ -0,0 +1,5 @@ +// Package modelconfiguration owns each Harness's deployment default model +// configuration: its safe view, the encrypted complete bundle that Session +// creation freezes, the private revision that pairs with it, and the rules for +// replacing, removing and resolving it. +package modelconfiguration diff --git a/services/core/internal/modelconfiguration/errors.go b/services/core/internal/modelconfiguration/errors.go new file mode 100644 index 00000000..ef2aa95d --- /dev/null +++ b/services/core/internal/modelconfiguration/errors.go @@ -0,0 +1,16 @@ +package modelconfiguration + +import "errors" + +// Replace and Resolve report a configuration the Harness declaration rejects +// with the contract's *v1.ModelProviderError, which names the field, and a +// bundle they cannot seal or open with credentialcrypto.ErrUnavailable. Storage +// passes textvalue.ErrUnstorable and adminaudit.ErrInvalidSource through +// unchanged. +var ( + // ErrNotFound reports a Harness without a deployment default. + ErrNotFound = errors.New("the harness has no deployment default model configuration") + // ErrInvalidObservation reports an Observation whose identifiers are not + // nonzero UUIDs. + ErrInvalidObservation = errors.New("an observation requires nonzero tenant, Session and Turn UUIDs") +) diff --git a/services/core/internal/modelconfiguration/observation.go b/services/core/internal/modelconfiguration/observation.go new file mode 100644 index 00000000..8d200c38 --- /dev/null +++ b/services/core/internal/modelconfiguration/observation.go @@ -0,0 +1,34 @@ +package modelconfiguration + +import "slices" + +// Observation names a root Turn whose committed outcome may update the +// last-use observations of the deployment default its Session froze. +type Observation struct { + TenantID, SessionID, TurnID string +} + +// providerErrorCodes are the engine failures that describe the model provider +// itself. Context-length and cyber-policy failures describe the request. The +// observation statement's fence lists the same codes; testdata holds the cases +// both are checked against. +var providerErrorCodes = []string{ + "authentication_error", "connection_failed", "rate_limit_exceeded", + "usage_limit_exceeded", "server_overloaded", "server_error", + "resource_not_found", "request_timeout", "invalid_request", +} + +// ShouldObserveProvider reports whether a committed root Turn outcome says +// something about its model provider: every completed Turn, and a failed Turn +// whose engine reported a provider error. It only spares a database round trip; +// the observation statement re-checks the committed outcome. +func ShouldObserveProvider(status, errorCode, engineErrorCode string) bool { + switch status { + case "completed": + return true + case "failed": + return errorCode == "engine_failed" && slices.Contains(providerErrorCodes, engineErrorCode) + default: + return false + } +} diff --git a/services/core/internal/modelconfiguration/observation_test.go b/services/core/internal/modelconfiguration/observation_test.go new file mode 100644 index 00000000..56840649 --- /dev/null +++ b/services/core/internal/modelconfiguration/observation_test.go @@ -0,0 +1,30 @@ +package modelconfiguration + +import ( + "encoding/json" + "os" + "testing" +) + +// testdata/observation_cases.json is shared with the adapter's tests, which run +// the same cases through the observation statement. +func TestShouldObserveProvider(t *testing.T) { + raw, err := os.ReadFile("testdata/observation_cases.json") + if err != nil { + t.Fatal(err) + } + var cases []struct { + Status string `json:"status"` + ErrorCode string `json:"error_code"` + EngineErrorCode string `json:"engine_error_code"` + Observed bool `json:"observed"` + } + if err := json.Unmarshal(raw, &cases); err != nil || len(cases) == 0 { + t.Fatal("observation cases unreadable", err) + } + for _, tc := range cases { + if got := ShouldObserveProvider(tc.Status, tc.ErrorCode, tc.EngineErrorCode); got != tc.Observed { + t.Errorf("%+v: got %v", tc, got) + } + } +} diff --git a/services/core/internal/modelconfiguration/service.go b/services/core/internal/modelconfiguration/service.go new file mode 100644 index 00000000..2925873f --- /dev/null +++ b/services/core/internal/modelconfiguration/service.go @@ -0,0 +1,79 @@ +package modelconfiguration + +import ( + "context" + "encoding/json" + "errors" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" +) + +// Service replaces, removes and resolves deployment defaults. The complete +// bundle, key included, is sealed to its Harness before it reaches storage. +type Service struct { + storage Storage + cipher *credentialcrypto.Cipher +} + +// NewService requires storage. A nil cipher means credential encryption is not +// configured: Replace and Resolve then return credentialcrypto.ErrUnavailable. +func NewService(storage Storage, cipher *credentialcrypto.Cipher) (*Service, error) { + if storage == nil { + return nil, errors.New("model configuration requires storage") + } + return &Service{storage: storage, cipher: cipher}, nil +} + +// Replace validates the complete configuration through the Harness +// declaration, seals it and stores it under a new revision. Sessions that +// already froze a default keep theirs. +func (s *Service) Replace(ctx context.Context, replacement Replacement) (Configuration, error) { + configuration := replacement.Configuration + if err := configuration.ValidateHarness(replacement.Harness); err != nil { + return Configuration{}, err + } + raw, err := json.Marshal(configuration) + if err != nil { + return Configuration{}, err + } + sealed, err := s.cipher.SealDeploymentModelProvider(raw, replacement.Harness) + if err != nil { + return Configuration{}, credentialcrypto.ErrUnavailable + } + view := configuration.SafeView() + return s.storage.Replace(ctx, Record{Harness: replacement.Harness, Provider: *view.ModelProvider, Model: view.Model, HarnessConfig: view.HarnessConfig, Sealed: sealed}) +} + +// Delete removes the Harness's default. It is idempotent. Sessions that +// already froze the default keep it. +func (s *Service) Delete(ctx context.Context, harness string) error { + return s.storage.Delete(ctx, harness) +} + +// Resolve opens the Harness's default for Session creation. It returns nil +// when the Harness has none, and credentialcrypto.ErrUnavailable when the +// bundle does not open. +func (s *Service) Resolve(ctx context.Context, harness string) (*Snapshot, error) { + sealed, err := s.storage.LoadSealed(ctx, harness) + if errors.Is(err, ErrNotFound) { + return nil, nil + } + if err != nil { + return nil, err + } + raw, err := s.cipher.OpenDeploymentModelProvider(sealed.Bundle, harness) + if err != nil { + return nil, credentialcrypto.ErrUnavailable + } + var configuration v1.ModelConfigurationInput + if json.Unmarshal(raw, &configuration) != nil { + return nil, credentialcrypto.ErrUnavailable + } + // A bundle that opens but no longer validates is not a credential failure. + // Its stored row stays intact so the operator can inspect and replace it. + if err := configuration.ValidateHarness(harness); err != nil { + return nil, err + } + return &Snapshot{Provider: &configuration.ModelProvider, Model: configuration.Model, HarnessConfig: v1.ResolvedHarnessConfig(configuration.HarnessConfig), Revision: sealed.Revision}, nil +} diff --git a/services/core/internal/modelconfiguration/service_test.go b/services/core/internal/modelconfiguration/service_test.go new file mode 100644 index 00000000..7ec0988f --- /dev/null +++ b/services/core/internal/modelconfiguration/service_test.go @@ -0,0 +1,172 @@ +package modelconfiguration + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/google/uuid" +) + +// fakeStorage fails the test on any call whose func is not set. +type fakeStorage struct { + t *testing.T + replace func(context.Context, Record) (Configuration, error) + delete func(context.Context, string) error + loadSealed func(context.Context, string) (Sealed, error) +} + +func (f *fakeStorage) Replace(ctx context.Context, record Record) (Configuration, error) { + if f.replace == nil { + f.t.Fatal("unexpected call to Replace") + } + return f.replace(ctx, record) +} + +func (f *fakeStorage) Delete(ctx context.Context, harness string) error { + if f.delete == nil { + f.t.Fatal("unexpected call to Delete") + } + return f.delete(ctx, harness) +} + +func (f *fakeStorage) LoadSealed(ctx context.Context, harness string) (Sealed, error) { + if f.loadSealed == nil { + f.t.Fatal("unexpected call to LoadSealed") + } + return f.loadSealed(ctx, harness) +} + +func testCipher(t *testing.T, fill byte) *credentialcrypto.Cipher { + t.Helper() + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{fill}, 32)) + if err != nil { + t.Fatal(err) + } + return cipher +} + +func validConfiguration() v1.ModelConfigurationInput { + return v1.ModelConfigurationInput{ModelProvider: v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://model.example/v1", APIKey: "secret-key"}, Model: "fixture-model"} +} + +func newService(t *testing.T, storage Storage, cipher *credentialcrypto.Cipher) *Service { + t.Helper() + service, err := NewService(storage, cipher) + if err != nil { + t.Fatal(err) + } + return service +} + +func TestNewServiceRequiresStorage(t *testing.T) { + if _, err := NewService(nil, testCipher(t, 1)); err == nil { + t.Fatal("nil storage accepted") + } +} + +func TestReplaceSealsTheCompleteBundleForItsHarness(t *testing.T) { + cipher := testCipher(t, 1) + stored := Configuration{Harness: "codex", Model: "fixture-model"} + var record Record + storage := &fakeStorage{t: t, replace: func(_ context.Context, r Record) (Configuration, error) { + record = r + return stored, nil + }} + result, err := newService(t, storage, cipher).Replace(t.Context(), Replacement{Harness: "codex", Configuration: validConfiguration()}) + if err != nil || result.Harness != stored.Harness || result.Model != stored.Model { + t.Fatal(result, err) + } + if record.Harness != "codex" || record.Model != "fixture-model" || string(record.HarnessConfig) != "{}" || + record.Provider != (v1.ModelProviderView{Protocol: "responses", BaseURL: "https://model.example/v1", APIKeyConfigured: true}) { + t.Fatalf("safe columns: %+v", record) + } + if bytes.Contains(record.Sealed, []byte("secret-key")) { + t.Fatal("key stored in plaintext") + } + if _, err := cipher.OpenDeploymentModelProvider(record.Sealed, "claude_code"); err == nil { + t.Fatal("bundle opens for another harness") + } + raw, err := cipher.OpenDeploymentModelProvider(record.Sealed, "codex") + var opened v1.ModelConfigurationInput + if err != nil || json.Unmarshal(raw, &opened) != nil || opened.ModelProvider.APIKey != "secret-key" || opened.Model != "fixture-model" { + t.Fatal("sealed bundle incomplete", err) + } +} + +func TestReplaceRejectsBeforeStorage(t *testing.T) { + invalid := validConfiguration() + invalid.ModelProvider.Protocol = "anthropic-unknown" + var field *v1.ModelProviderError + if _, err := newService(t, &fakeStorage{t: t}, testCipher(t, 1)).Replace(t.Context(), Replacement{Harness: "codex", Configuration: invalid}); !errors.As(err, &field) || field.Param != "protocol" { + t.Fatal("invalid configuration", err) + } + if _, err := newService(t, &fakeStorage{t: t}, nil).Replace(t.Context(), Replacement{Harness: "codex", Configuration: validConfiguration()}); !errors.Is(err, credentialcrypto.ErrUnavailable) { + t.Fatal("missing credential key", err) + } +} + +func TestDeletePassesTheHarness(t *testing.T) { + failure := errors.New("storage failed") + storage := &fakeStorage{t: t, delete: func(_ context.Context, harness string) error { + if harness != "codex" { + t.Fatal(harness) + } + return failure + }} + if err := newService(t, storage, nil).Delete(t.Context(), "codex"); !errors.Is(err, failure) { + t.Fatal(err) + } +} + +func TestResolveOpensTheStoredBundle(t *testing.T) { + cipher := testCipher(t, 1) + seal := func(harness string, configuration any) []byte { + raw, _ := json.Marshal(configuration) + sealed, err := cipher.SealDeploymentModelProvider(raw, harness) + if err != nil { + t.Fatal(err) + } + return sealed + } + revision := uuid.New() + loaded := func(bundle []byte, err error) *fakeStorage { + return &fakeStorage{t: t, loadSealed: func(_ context.Context, harness string) (Sealed, error) { + if harness != "codex" { + t.Fatal(harness) + } + return Sealed{Bundle: bundle, Revision: revision}, err + }} + } + snapshot, err := newService(t, loaded(seal("codex", validConfiguration()), nil), cipher).Resolve(t.Context(), "codex") + if err != nil || snapshot == nil || snapshot.Revision != revision || snapshot.Provider.APIKey != "secret-key" || snapshot.Model != "fixture-model" || string(snapshot.HarnessConfig) != "{}" { + t.Fatal("snapshot", snapshot, err) + } + if snapshot, err := newService(t, loaded(nil, ErrNotFound), cipher).Resolve(t.Context(), "codex"); snapshot != nil || err != nil { + t.Fatal("missing default", snapshot, err) + } + failure := errors.New("storage failed") + if _, err := newService(t, loaded(nil, failure), cipher).Resolve(t.Context(), "codex"); !errors.Is(err, failure) { + t.Fatal("storage failure", err) + } + invalid := validConfiguration() + invalid.Model = "" + var field *v1.ModelProviderError + if _, err := newService(t, loaded(seal("codex", invalid), nil), cipher).Resolve(t.Context(), "codex"); !errors.As(err, &field) { + t.Fatal("unsupported stored configuration", err) + } + for name, service := range map[string]*Service{ + "no key": newService(t, loaded(seal("codex", validConfiguration()), nil), nil), + "wrong key": newService(t, loaded(seal("codex", validConfiguration()), nil), testCipher(t, 2)), + "other harness": newService(t, loaded(seal("claude_code", validConfiguration()), nil), cipher), + "not JSON": newService(t, loaded(seal("codex", "not an object"), nil), cipher), + } { + if _, err := service.Resolve(t.Context(), "codex"); !errors.Is(err, credentialcrypto.ErrUnavailable) { + t.Fatal(name, err) + } + } +} diff --git a/services/core/internal/modelconfiguration/storage.go b/services/core/internal/modelconfiguration/storage.go new file mode 100644 index 00000000..fcddf24c --- /dev/null +++ b/services/core/internal/modelconfiguration/storage.go @@ -0,0 +1,33 @@ +package modelconfiguration + +import "context" + +// Storage keeps one deployment default per Harness. Replace and Delete record +// the administrator mutation in the same transaction, from the provenance the +// context carries; an audit failure aborts the change. +type Storage interface { + // Replace stores record under a new revision and clears the observations + // of the revision it replaces. + Replace(ctx context.Context, record Record) (Configuration, error) + // Delete removes the Harness's default. Removing a missing default succeeds + // and is audited too. + Delete(ctx context.Context, harness string) error + // LoadSealed returns the Harness's sealed bundle and its revision, or + // ErrNotFound. + LoadSealed(ctx context.Context, harness string) (Sealed, error) +} + +// Reader lists the configured defaults without opening any bundle. +type Reader interface { + List(ctx context.Context) ([]Configuration, error) +} + +// Observer records a committed root Turn's outcome against the deployment +// default revision its Session froze. It runs after the Turn's commit, outside +// the execution lease, and the statement itself re-checks the committed +// outcome, the tenant, the provider source and the exact revision. It returns +// the number of defaults updated: zero when the Turn does not qualify, the +// default was replaced or removed, or the throttle skips the update. +type Observer interface { + ObserveDeploymentModelProvider(ctx context.Context, observation Observation) (int64, error) +} diff --git a/services/core/internal/modelconfiguration/testdata/observation_cases.json b/services/core/internal/modelconfiguration/testdata/observation_cases.json new file mode 100644 index 00000000..0794fd47 --- /dev/null +++ b/services/core/internal/modelconfiguration/testdata/observation_cases.json @@ -0,0 +1,20 @@ +[ + {"status": "completed", "observed": true}, + {"status": "failed", "error_code": "engine_failed", "engine_error_code": "authentication_error", "observed": true}, + {"status": "failed", "error_code": "engine_failed", "engine_error_code": "connection_failed", "observed": true}, + {"status": "failed", "error_code": "engine_failed", "engine_error_code": "rate_limit_exceeded", "observed": true}, + {"status": "failed", "error_code": "engine_failed", "engine_error_code": "usage_limit_exceeded", "observed": true}, + {"status": "failed", "error_code": "engine_failed", "engine_error_code": "server_overloaded", "observed": true}, + {"status": "failed", "error_code": "engine_failed", "engine_error_code": "server_error", "observed": true}, + {"status": "failed", "error_code": "engine_failed", "engine_error_code": "resource_not_found", "observed": true}, + {"status": "failed", "error_code": "engine_failed", "engine_error_code": "request_timeout", "observed": true}, + {"status": "failed", "error_code": "engine_failed", "engine_error_code": "invalid_request", "observed": true}, + {"status": "failed", "error_code": "engine_failed", "engine_error_code": "context_length_exceeded", "observed": false}, + {"status": "failed", "error_code": "engine_failed", "engine_error_code": "cyber_policy", "observed": false}, + {"status": "failed", "error_code": "engine_failed", "engine_error_code": "harness_error", "observed": false}, + {"status": "failed", "error_code": "engine_failed", "engine_error_code": "untrusted raw text", "observed": false}, + {"status": "failed", "error_code": "engine_failed", "observed": false}, + {"status": "failed", "error_code": "input_not_applied", "engine_error_code": "authentication_error", "observed": false}, + {"status": "failed", "error_code": "device_disconnected", "engine_error_code": "authentication_error", "observed": false}, + {"status": "cancelled", "error_code": "engine_failed", "engine_error_code": "authentication_error", "observed": false} +] diff --git a/services/core/internal/persistence/postgres/modelconfigurationpg/fixture_test.go b/services/core/internal/persistence/postgres/modelconfigurationpg/fixture_test.go new file mode 100644 index 00000000..699df747 --- /dev/null +++ b/services/core/internal/persistence/postgres/modelconfigurationpg/fixture_test.go @@ -0,0 +1,155 @@ +package modelconfigurationpg_test + +import ( + "bytes" + "context" + "encoding/json" + "testing" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgtest" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" +) + +// Deployment defaults are deployment-wide, so every test opens its own +// database. +type fixture struct { + pool *pgxpool.Pool + cipher *credentialcrypto.Cipher + adapter *modelconfigurationpg.Store + service *modelconfiguration.Service +} + +func newFixture(t *testing.T) fixture { + t.Helper() + pool := pgtest.OpenIsolated(t, nil) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{47}, 32)) + if err != nil { + t.Fatal(err) + } + adapter := modelconfigurationpg.New(pgunit.NewPool(pool)) + service, err := modelconfiguration.NewService(adapter, cipher) + if err != nil { + t.Fatal(err) + } + return fixture{pool, cipher, adapter, service} +} + +func admin(t *testing.T) context.Context { + return adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", RequestID: uuid.NewString(), TraceID: uuid.NewString()}) +} + +var fixtureProvider = v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://model.fixture.example/v1", APIKey: "fixture-model-key"} + +func (f fixture) replace(t *testing.T, provider v1.ModelProviderInput) modelconfiguration.Configuration { + t.Helper() + configuration, err := f.service.Replace(admin(t), modelconfiguration.Replacement{Harness: "codex", Configuration: v1.ModelConfigurationInput{ModelProvider: provider, Model: "fixture"}}) + if err != nil { + t.Fatal(err) + } + return configuration +} + +func (f fixture) resolve(t *testing.T) *modelconfiguration.Snapshot { + t.Helper() + snapshot, err := f.service.Resolve(t.Context(), "codex") + if err != nil { + t.Fatal(err) + } + return snapshot +} + +func (f fixture) list(t *testing.T) []modelconfiguration.Configuration { + t.Helper() + configurations, err := f.adapter.List(t.Context()) + if err != nil { + t.Fatal(err) + } + return configurations +} + +// observed is a codex default and one Session that froze it. Store builds the +// Session; Turns are committed rows written directly. +type observed struct { + fixture + sessions *store.Store + tenant string + input store.CreateSessionInput + session store.Session +} + +func newObserved(t *testing.T) observed { + t.Helper() + f := newFixture(t) + f.replace(t, fixtureProvider) + snapshot := f.resolve(t) + model, harness := "frozen-model", "codex" + input := store.CreateSessionInput{ + Creator: identity.Subject{Kind: "service_account", ID: "fixture"}, Engine: harness, IdempotencyKey: uuid.NewString(), + Configuration: json.RawMessage(`{"agent":{"model":"frozen-model"},"environment":{"type":"none"}}`), + ModelProvider: snapshot.Provider, ModelProviderSource: "deployment", DeploymentProviderRevision: snapshot.Revision, + ExecutionConfiguration: &v1.SessionExecutionConfiguration{ + Model: v1.ExecutionSelection{Value: &model, Source: "deployment"}, Harness: v1.ExecutionSelection{Value: &harness, Source: "deployment"}, + ModelProvider: v1.ExecutionProviderSelection{Source: "deployment"}, + }, + } + sessions := store.NewWithCredentialCipher(f.pool, f.cipher) + tenant := uuid.NewString() + session, err := sessions.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + return observed{f, sessions, tenant, input, session} +} + +// terminal commits a root Turn with the outcome fields the observation reads. +func (o observed) terminal(t *testing.T, sessionID, status, errorCode, engineErrorCode string) string { + t.Helper() + outcome, _ := json.Marshal(map[string]string{"error_code": errorCode, "engine_error_code": engineErrorCode}) + id := uuid.NewString() + if _, err := o.pool.Exec(t.Context(), "INSERT INTO turns(id,session_id,status,outcome,completed_at) VALUES($1,$2,$3,$4,clock_timestamp())", id, sessionID, status, outcome); err != nil { + t.Fatal(err) + } + return id +} + +func (o observed) observe(t *testing.T, turnID string, want int64) { + t.Helper() + n, err := o.adapter.ObserveDeploymentModelProvider(t.Context(), modelconfiguration.Observation{TenantID: o.tenant, SessionID: o.session.ID, TurnID: turnID}) + if err != nil || n != want { + t.Fatalf("observation writes=%d want=%d err=%v", n, want, err) + } +} + +func (o observed) resetObservations(t *testing.T) { + t.Helper() + if _, err := o.pool.Exec(t.Context(), "UPDATE deployment_model_providers SET last_used_at=NULL,last_error_at=NULL,last_error_code=NULL,recovery_pending=false"); err != nil { + t.Fatal(err) + } +} + +// waitForLock waits until a statement matching query waits on a row lock. +func waitForLock(t *testing.T, pool *pgxpool.Pool, query string) { + t.Helper() + for range 1000 { + var waiting bool + if err := pool.QueryRow(t.Context(), "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND query LIKE $1)", "%"+query+"%").Scan(&waiting); err != nil { + t.Fatal(err) + } + if waiting { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal(query, "never waited on a row lock") +} diff --git a/services/core/internal/persistence/postgres/modelconfigurationpg/observation_test.go b/services/core/internal/persistence/postgres/modelconfigurationpg/observation_test.go new file mode 100644 index 00000000..3e9047b7 --- /dev/null +++ b/services/core/internal/persistence/postgres/modelconfigurationpg/observation_test.go @@ -0,0 +1,392 @@ +package modelconfigurationpg_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "sync" + "testing" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" +) + +// The statement and ShouldObserveProvider classify the shared cases the same +// way; the domain's test checks the rule against the same file. +func TestObservationEligibilityMatchesTheRuleAndResetsOnReplace(t *testing.T) { + raw, err := os.ReadFile("../../../modelconfiguration/testdata/observation_cases.json") + if err != nil { + t.Fatal(err) + } + var cases []struct { + Status string `json:"status"` + ErrorCode string `json:"error_code"` + EngineErrorCode string `json:"engine_error_code"` + Observed bool `json:"observed"` + } + if err := json.Unmarshal(raw, &cases); err != nil || len(cases) == 0 { + t.Fatal("observation cases unreadable", err) + } + o := newObserved(t) + for _, tc := range cases { + o.resetObservations(t) + want := int64(0) + if tc.Observed { + want = 1 + } + if modelconfiguration.ShouldObserveProvider(tc.Status, tc.ErrorCode, tc.EngineErrorCode) != tc.Observed { + t.Fatalf("rule disagrees with %+v", tc) + } + o.observe(t, o.terminal(t, o.session.ID, tc.Status, tc.ErrorCode, tc.EngineErrorCode), want) + } + o.resetObservations(t) + success := o.terminal(t, o.session.ID, "completed", "", "") + failure := o.terminal(t, o.session.ID, "failed", "engine_failed", "authentication_error") + o.observe(t, success, 1) + o.observe(t, failure, 1) + n, err := o.adapter.ObserveDeploymentModelProvider(t.Context(), modelconfiguration.Observation{TenantID: uuid.NewString(), SessionID: o.session.ID, TurnID: success}) + if n != 0 || err != nil { + t.Fatal("foreign tenant observed", err) + } + if view := o.list(t); view[0].LastUsedAt == nil || view[0].LastErrorAt == nil || view[0].LastErrorCode == nil || *view[0].LastErrorCode != "authentication_error" { + t.Fatal("safe observations missing", view) + } + old := o.resolve(t).Revision + reset := o.replace(t, *o.input.ModelProvider) + if o.resolve(t).Revision == old || reset.LastUsedAt != nil || reset.LastErrorAt != nil || reset.LastErrorCode != nil { + t.Fatal("identical replacement did not reset") + } + o.observe(t, success, 0) + if _, err := o.adapter.ObserveDeploymentModelProvider(t.Context(), modelconfiguration.Observation{TenantID: o.tenant, SessionID: o.session.ID, TurnID: "not-a-uuid"}); !errors.Is(err, modelconfiguration.ErrInvalidObservation) { + t.Fatal("invalid identifier", err) + } +} + +func TestObservationExcludesOtherSourcesAndHistoricalSessions(t *testing.T) { + o := newObserved(t) + for _, source := range []string{"session", "agent", "unknown", "deployment"} { + input := o.input + input.IdempotencyKey = uuid.NewString() + projection := *input.ExecutionConfiguration + input.ExecutionConfiguration = &projection + input.ModelProviderSource = source + // Historical metadata may name deployment but has no frozen private UUID. + if source == "deployment" { + input.DeploymentProviderRevision = uuid.Nil + } else { + input.Configuration = json.RawMessage(`{"agent":{"model":"frozen-model"},"environment":{"type":"openai_hosted"}}`) + if source == "unknown" { + input.ModelProviderSource = "session" + } + } + projection.ModelProvider = v1.ExecutionProviderSelection{Source: source, Status: "available", Configuration: input.ModelProvider.SafeView()} + session, err := o.sessions.CreateSession(t.Context(), o.tenant, input) + if err != nil { + t.Fatal(source, err) + } + var revision pgtype.UUID + if err = o.pool.QueryRow(t.Context(), "SELECT deployment_provider_revision FROM session_execution_configuration WHERE session_id=$1", session.ID).Scan(&revision); err != nil || revision.Valid { + t.Fatal("non-deployment or historical revision persisted", source, err) + } + turn := o.terminal(t, session.ID, "completed", "", "") + n, err := o.adapter.ObserveDeploymentModelProvider(t.Context(), modelconfiguration.Observation{TenantID: o.tenant, SessionID: session.ID, TurnID: turn}) + if err != nil || n != 0 { + t.Fatal("ineligible source observed", source, err) + } + } +} + +func TestObservationIgnoresActiveAndNonRootTurns(t *testing.T) { + o := newObserved(t) + id := uuid.NewString() + if _, err := o.pool.Exec(t.Context(), "INSERT INTO turns(id,session_id,status) VALUES($1,$2,'queued')", id, o.session.ID); err != nil { + t.Fatal(err) + } + o.observe(t, id, 0) + if _, err := o.pool.Exec(t.Context(), "UPDATE turns SET status='waiting', started_at=clock_timestamp() WHERE id=$1", id); err != nil { + t.Fatal(err) + } + o.observe(t, id, 0) + // Child Turn identifiers live outside turns. An absent root identifier is + // rejected by the same ownership join, without a child-history lookup. + o.observe(t, "ffffffff-ffff-4fff-bfff-ffffffffffff", 0) +} + +func TestObservationConcurrentThrottleAndRecovery(t *testing.T) { + o := newObserved(t) + var successes, failures []string + for range 8 { + successes = append(successes, o.terminal(t, o.session.ID, "completed", "", "")) + } + for i := range 8 { + failures = append(failures, o.terminal(t, o.session.ID, "failed", "engine_failed", []string{"authentication_error", "rate_limit_exceeded"}[i%2])) + } + concurrent := func(turns []string, want int64) { + t.Helper() + var wg sync.WaitGroup + counts := make(chan int64, len(turns)) + errs := make(chan error, len(turns)) + for _, turn := range turns { + wg.Go(func() { + n, err := o.adapter.ObserveDeploymentModelProvider(t.Context(), modelconfiguration.Observation{TenantID: o.tenant, SessionID: o.session.ID, TurnID: turn}) + counts <- n + errs <- err + }) + } + wg.Wait() + close(counts) + close(errs) + var n int64 + for v := range counts { + n += v + } + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if n != want { + t.Fatalf("concurrent writes=%d want=%d", n, want) + } + } + concurrent(successes, 1) + concurrent(failures, 1) + concurrent(successes, 1) + concurrent(successes, 0) + concurrent(failures, 0) +} + +// Only the database clock sample is controlled; the generated statement runs +// with its locking, predicates, constraints and write count. +type observationClockDB struct { + *pgxpool.Pool + at time.Time + query string +} + +func (db *observationClockDB) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) { + db.query = sql + sql = strings.ReplaceAll(sql, "clock_timestamp()", "'"+db.at.Format(time.RFC3339Nano)+"'::timestamptz") + return db.Pool.Exec(ctx, sql, args...) +} + +func observationParams(t *testing.T, o observed, turn string) sqlc.ObserveDeploymentModelProviderParams { + t.Helper() + id := func(value string) pgtype.UUID { return pgtype.UUID{Bytes: uuid.MustParse(value), Valid: true} } + return sqlc.ObserveDeploymentModelProviderParams{TenantID: id(o.tenant), SessionID: id(o.session.ID), TurnID: id(turn)} +} + +func TestObservationClockBoundariesAndPlan(t *testing.T) { + o := newObserved(t) + success := o.terminal(t, o.session.ID, "completed", "", "") + failure := o.terminal(t, o.session.ID, "failed", "engine_failed", "authentication_error") + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + db := &observationClockDB{Pool: o.pool} + q := sqlc.New(db) + observe := func(turn string, offset time.Duration, want int64) { + t.Helper() + db.at = base.Add(offset) + n, err := q.ObserveDeploymentModelProvider(t.Context(), observationParams(t, o, turn)) + if err != nil || n != want { + t.Fatalf("at %s got %d want %d: %v", offset, n, want, err) + } + } + observe(success, 0, 1) + observe(failure, time.Second, 1) + observe(success, 2*time.Second, 1) + observe(failure, 30*time.Second, 0) + observe(failure, 31*time.Second, 1) + observe(success, 31*time.Second, 1) + observe(success, 31*time.Second, 0) + // A new accepted error can recover once even if the clock moves backwards. + observe(failure, 61*time.Second, 1) + observe(success, -time.Second, 1) + observe(success, -time.Second, 0) + observe(success, 29*time.Second, 1) + observe(success, 59*time.Second-time.Microsecond, 0) + observe(success, 59*time.Second, 1) + var actual time.Time + if err := o.pool.QueryRow(t.Context(), "SELECT last_used_at FROM deployment_model_providers").Scan(&actual); err != nil || !actual.Equal(base.Add(59*time.Second)) { + t.Fatal("timestamp was clamped", err) + } + params := observationParams(t, o, success) + // EXPLAIN uses the normal planner and does not execute the update. The + // tenant, Session and root Turn lookups must constrain the default lookup. + tx, err := o.pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + rows, err := tx.Query(t.Context(), "EXPLAIN "+db.query, params.TenantID, params.SessionID, params.TurnID) + if err != nil { + t.Fatal(err) + } + var plan strings.Builder + for rows.Next() { + var line string + _ = rows.Scan(&line) + plan.WriteString(line) + } + rows.Close() + if rows.Err() != nil || !strings.Contains(plan.String(), "deployment_model_providers_pkey") || (!strings.Contains(plan.String(), "turns_pkey") && !strings.Contains(plan.String(), "turns_session_id_id_key")) { + t.Fatal("bounded lookup indexes absent", rows.Err(), plan.String()) + } +} + +func TestObservationRechecksTheRevisionAfterItsLockAndTimesOut(t *testing.T) { + for _, remove := range []bool{false, true} { + t.Run(fmt.Sprint("delete=", remove), func(t *testing.T) { + o := newObserved(t) + turn := o.terminal(t, o.session.ID, "completed", "", "") + tx, err := o.pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + if _, err = tx.Exec(t.Context(), "SELECT harness FROM deployment_model_providers FOR UPDATE"); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 80*time.Millisecond) + defer cancel() + if n, err := o.adapter.ObserveDeploymentModelProvider(ctx, modelconfiguration.Observation{TenantID: o.tenant, SessionID: o.session.ID, TurnID: turn}); err == nil || n != 0 { + t.Fatal("locked observation did not time out") + } + type result struct { + n int64 + err error + } + done := make(chan result, 1) + go func() { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + n, err := o.adapter.ObserveDeploymentModelProvider(ctx, modelconfiguration.Observation{TenantID: o.tenant, SessionID: o.session.ID, TurnID: turn}) + done <- result{n, err} + }() + // Replace or delete the selected revision only once the observation + // waits on its row lock. + waitForLock(t, o.pool, "ObserveDeploymentModelProvider") + if remove { + _, err = tx.Exec(t.Context(), "DELETE FROM deployment_model_providers") + } else { + _, err = tx.Exec(t.Context(), "UPDATE deployment_model_providers SET revision=$1", uuid.New()) + } + if err != nil { + t.Fatal(err) + } + if err = tx.Commit(t.Context()); err != nil { + t.Fatal(err) + } + if got := <-done; got.n != 0 || got.err != nil { + t.Fatal("stale revision updated its replacement", got.n, got.err) + } + }) + } +} + +func TestObservationSamplesItsClockAfterTheRowLock(t *testing.T) { + o := newObserved(t) + turn := o.terminal(t, o.session.ID, "completed", "", "") + tx, err := o.pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + if _, err = tx.Exec(t.Context(), "SELECT harness FROM deployment_model_providers FOR UPDATE"); err != nil { + t.Fatal(err) + } + result := make(chan error, 1) + go func() { + _, err := o.adapter.ObserveDeploymentModelProvider(t.Context(), modelconfiguration.Observation{TenantID: o.tenant, SessionID: o.session.ID, TurnID: turn}) + result <- err + }() + waitForLock(t, o.pool, "ObserveDeploymentModelProvider") + var beforeUnlock time.Time + if err = tx.QueryRow(t.Context(), "SELECT clock_timestamp()").Scan(&beforeUnlock); err != nil { + t.Fatal(err) + } + if err = tx.Commit(t.Context()); err != nil { + t.Fatal(err) + } + if err = <-result; err != nil { + t.Fatal(err) + } + if rows := o.list(t); rows[0].LastUsedAt == nil || rows[0].LastUsedAt.Before(beforeUnlock) { + t.Fatal("receipt clock sampled before the row lock") + } +} + +func TestReplacementWaitsForAWinningObservationAndClearsIt(t *testing.T) { + o := newObserved(t) + turn := o.terminal(t, o.session.ID, "completed", "", "") + tx, err := o.pool.Begin(t.Context()) + if err != nil { + t.Fatal(err) + } + defer tx.Rollback(context.Background()) + n, err := sqlc.New(tx).ObserveDeploymentModelProvider(t.Context(), observationParams(t, o, turn)) + if err != nil || n != 1 { + t.Fatal("observation did not win the row lock", n, err) + } + result := make(chan error, 1) + go func() { + _, err := o.service.Replace(admin(t), modelconfiguration.Replacement{Harness: "codex", Configuration: v1.ModelConfigurationInput{ModelProvider: *o.input.ModelProvider, Model: "fixture"}}) + result <- err + }() + waitForLock(t, o.pool, "UpsertDeploymentModelProvider") + if err = tx.Commit(t.Context()); err != nil { + t.Fatal(err) + } + if err = <-result; err != nil { + t.Fatal(err) + } + if rows := o.list(t); rows[0].LastUsedAt != nil || rows[0].LastErrorCode != nil || rows[0].LastErrorAt != nil { + t.Fatal("replacement kept the old revision's observation") + } + o.observe(t, turn, 0) +} + +func TestObservationMigrationRoundTrip(t *testing.T) { + o := newObserved(t) + old := o.resolve(t).Revision + var secret, sessionBundle []byte + if err := o.pool.QueryRow(t.Context(), "SELECT (SELECT encrypted_config FROM deployment_model_providers), (SELECT encrypted_config FROM session_model_execution WHERE session_id=$1)", o.session.ID).Scan(&secret, &sessionBundle); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile("../../../../migrations/000084_deployment_provider_observations.sql") + if err != nil { + t.Fatal(err) + } + parts := strings.Split(string(raw), "-- +goose Down") + if _, err = o.pool.Exec(t.Context(), parts[1]); err != nil { + t.Fatal(err) + } + if _, err = o.pool.Exec(t.Context(), parts[0]); err != nil { + t.Fatal(err) + } + var revision uuid.UUID + var frozen pgtype.UUID + var preserved, preservedSession []byte + if err = o.pool.QueryRow(t.Context(), "SELECT revision, encrypted_config FROM deployment_model_providers").Scan(&revision, &preserved); err != nil { + t.Fatal(err) + } + if err = o.pool.QueryRow(t.Context(), "SELECT c.deployment_provider_revision, e.encrypted_config FROM session_execution_configuration c JOIN session_model_execution e USING (session_id) WHERE c.session_id=$1", o.session.ID).Scan(&frozen, &preservedSession); err != nil { + t.Fatal(err) + } + if revision == old || frozen.Valid || string(secret) != string(preserved) { + t.Fatal("migration recreated historical identity or changed ciphertext") + } + if string(sessionBundle) != string(preservedSession) { + t.Fatal("migration changed the Session bundle") + } + o.observe(t, o.terminal(t, o.session.ID, "completed", "", ""), 0) +} diff --git a/services/core/internal/persistence/postgres/modelconfigurationpg/store.go b/services/core/internal/persistence/postgres/modelconfigurationpg/store.go new file mode 100644 index 00000000..74909c6c --- /dev/null +++ b/services/core/internal/persistence/postgres/modelconfigurationpg/store.go @@ -0,0 +1,181 @@ +// Package modelconfigurationpg stores deployment default model configurations +// in PostgreSQL. +package modelconfigurationpg + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "time" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue" +) + +// auditResource is the administrator audit resource type of a deployment +// default; its ID is the Harness. +const auditResource = "deployment_model_provider" + +// observationBudget bounds one observation, including its wait for a pooled +// connection and for the default's row lock. +const observationBudget = time.Second + +// Store keeps deployment defaults on pooled connections. It never uses the +// execution lease. +type Store struct{ pool *pgunit.Pool } + +var ( + _ modelconfiguration.Storage = (*Store)(nil) + _ modelconfiguration.Reader = (*Store)(nil) + _ modelconfiguration.Observer = (*Store)(nil) +) + +func New(pool *pgunit.Pool) *Store { return &Store{pool: pool} } + +// List reads every default in Harness order without its sealed bundle. +func (s *Store) List(ctx context.Context) ([]modelconfiguration.Configuration, error) { + rows, err := s.pool.Queries().ListDeploymentModelProviders(ctx) + if err != nil { + return nil, translate(err) + } + result := make([]modelconfiguration.Configuration, 0, len(rows)) + for _, row := range rows { + result = append(result, configuration(row)) + } + return result, nil +} + +// Replace upserts the record under a new revision, which clears the replaced +// revision's observations, and audits the write in the same transaction. +func (s *Store) Replace(ctx context.Context, record modelconfiguration.Record) (modelconfiguration.Configuration, error) { + var result modelconfiguration.Configuration + err := s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + row, err := q.UpsertDeploymentModelProvider(ctx, sqlc.UpsertDeploymentModelProviderParams{ + Harness: record.Harness, Protocol: record.Provider.Protocol, BaseUrl: record.Provider.BaseURL, + ContextWindow: record.Provider.ContextWindow, MaxOutputTokens: record.Provider.MaxOutputTokens, + Model: record.Model, HarnessConfig: record.HarnessConfig, EncryptedConfig: record.Sealed, + Revision: pgtype.UUID{Bytes: uuid.New(), Valid: true}, + }) + if err != nil { + return err + } + result = configuration(sqlc.ListDeploymentModelProvidersRow(row)) + return auditpg.RecordDeploymentMutation(ctx, q, "set", auditResource, record.Harness) + }) + if err != nil { + return modelconfiguration.Configuration{}, translate(err) + } + return result, nil +} + +// Delete removes the Harness's default, if any, and audits the request in the +// same transaction. +func (s *Store) Delete(ctx context.Context, harness string) error { + return translate(s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + q := sqlc.New(tx) + if _, err := q.DeleteDeploymentModelProvider(ctx, harness); err != nil { + return err + } + return auditpg.RecordDeploymentMutation(ctx, q, "delete", auditResource, harness) + })) +} + +// LoadSealed reads the sealed bundle and its revision in one statement, so the +// pair always belongs to the same replacement. +func (s *Store) LoadSealed(ctx context.Context, harness string) (modelconfiguration.Sealed, error) { + row, err := s.pool.Queries().GetDeploymentModelProviderSecret(ctx, harness) + if errors.Is(err, pgx.ErrNoRows) { + return modelconfiguration.Sealed{}, modelconfiguration.ErrNotFound + } + if err != nil { + return modelconfiguration.Sealed{}, translate(err) + } + return modelconfiguration.Sealed{Bundle: row.EncryptedConfig, Revision: uuid.UUID(row.Revision.Bytes)}, nil +} + +// ObserveDeploymentModelProvider runs one metadata UPDATE in its own pooled +// transaction. The statement reads only committed state: the tenant's root +// Turn, its outcome, the Session's deployment source and the exact frozen +// revision. It locks only the matching default and samples the receipt time +// after that lock. The transaction sets server-side statement and lock +// timeouts, because client cancellation alone can leave PostgreSQL executing +// briefly after pgx has returned a deadline error. +func (s *Store) ObserveDeploymentModelProvider(ctx context.Context, observation modelconfiguration.Observation) (int64, error) { + params, err := observationParams(observation) + if err != nil { + return 0, err + } + ctx, cancel := context.WithTimeout(ctx, observationBudget) + defer cancel() + var count int64 + err = s.pool.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { + deadline, _ := ctx.Deadline() + // Leave a small part of the budget for returning the server error and + // releasing the transaction before the client deadline. + timeout := time.Until(deadline).Milliseconds() - 25 + if timeout <= 0 { + return context.DeadlineExceeded + } + setting := strconv.FormatInt(timeout, 10) + "ms" + if _, err := tx.Exec(ctx, "SELECT set_config('statement_timeout', $1, true), set_config('lock_timeout', $1, true)", setting); err != nil { + return err + } + var err error + count, err = sqlc.New(tx).ObserveDeploymentModelProvider(ctx, params) + return err + }) + if err != nil { + return 0, translate(err) + } + return count, nil +} + +func observationParams(observation modelconfiguration.Observation) (sqlc.ObserveDeploymentModelProviderParams, error) { + tenant, tenantErr := pgunit.ParseID(observation.TenantID) + session, sessionErr := pgunit.ParseID(observation.SessionID) + turn, turnErr := pgunit.ParseID(observation.TurnID) + if tenantErr != nil || sessionErr != nil || turnErr != nil { + return sqlc.ObserveDeploymentModelProviderParams{}, modelconfiguration.ErrInvalidObservation + } + return sqlc.ObserveDeploymentModelProviderParams{TenantID: tenant, SessionID: session, TurnID: turn}, nil +} + +// configuration maps a stored row to the safe view. A stored default always +// holds a provider key. +func configuration(row sqlc.ListDeploymentModelProvidersRow) modelconfiguration.Configuration { + result := modelconfiguration.Configuration{ + Harness: row.Harness, Model: row.Model, HarnessConfig: json.RawMessage(row.HarnessConfig), UpdatedAt: row.UpdatedAt.Time, + LastUsedAt: timestamp(row.LastUsedAt), LastErrorAt: timestamp(row.LastErrorAt), + Provider: v1.ModelProviderView{Protocol: row.Protocol, BaseURL: row.BaseUrl, ContextWindow: row.ContextWindow, MaxOutputTokens: row.MaxOutputTokens, APIKeyConfigured: true}, + } + if row.LastErrorCode.Valid { + result.LastErrorCode = &row.LastErrorCode.String + } + return result +} + +func timestamp(value pgtype.Timestamptz) *time.Time { + if !value.Valid { + return nil + } + return &value.Time +} + +// translate maps text PostgreSQL cannot store to the shared error. Every +// other failure, including an invalid audit source, passes through unchanged. +func translate(err error) error { + if pgunit.IsUnstorableText(err) { + return textvalue.ErrUnstorable + } + return err +} diff --git a/services/core/internal/persistence/postgres/modelconfigurationpg/store_test.go b/services/core/internal/persistence/postgres/modelconfigurationpg/store_test.go new file mode 100644 index 00000000..685e3e72 --- /dev/null +++ b/services/core/internal/persistence/postgres/modelconfigurationpg/store_test.go @@ -0,0 +1,87 @@ +package modelconfigurationpg_test + +import ( + "bytes" + "context" + "errors" + "testing" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/google/uuid" + + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/textvalue" +) + +func TestDefaultsAreEncryptedAuditedAndReplaced(t *testing.T) { + f := newFixture(t) + provider := v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://deployment.example/v1", APIKey: "deployment-key-canary"} + saved := f.replace(t, provider) + if saved.Harness != "codex" || saved.Provider != *provider.SafeView() || saved.UpdatedAt.IsZero() { + t.Fatal("default not saved", saved) + } + var row []byte + if err := f.pool.QueryRow(t.Context(), "SELECT to_jsonb(p)::text FROM deployment_model_providers p WHERE harness='codex'").Scan(&row); err != nil || bytes.Contains(row, []byte("deployment-key-canary")) { + t.Fatal("key stored in plaintext", err) + } + if listed := f.list(t); len(listed) != 1 || listed[0].Provider != *provider.SafeView() { + t.Fatal("safe fields not listed", listed) + } + if got := f.resolve(t); got == nil || *got.Provider != provider { + t.Fatal("default did not open", got) + } + if got, err := f.service.Resolve(t.Context(), "mcode"); err != nil || got != nil { + t.Fatal("unset harness returned a default", err) + } + before := f.resolve(t).Revision + replacement := v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://replacement.example/v1", APIKey: "replacement-key"} + f.replace(t, replacement) + if got := f.resolve(t); *got.Provider != replacement || got.Revision == before { + t.Fatal("replacement not complete", got) + } + for range 2 { + if err := f.service.Delete(admin(t), "codex"); err != nil { + t.Fatal("delete is not idempotent", err) + } + } + if got := f.resolve(t); got != nil { + t.Fatal("deleted default remained") + } + rows, err := f.pool.Query(t.Context(), "SELECT action, project_id IS NULL AND tenant_id IS NULL, resource_id FROM admin_audit_log WHERE resource_type='deployment_model_provider' ORDER BY created_at") + if err != nil { + t.Fatal(err) + } + var actions []string + for rows.Next() { + var action, resource string + var deploymentWide bool + if err := rows.Scan(&action, &deploymentWide, &resource); err != nil || !deploymentWide || resource != "codex" { + t.Fatal("unexpected deployment audit entry", action, resource, err) + } + actions = append(actions, action) + } + if rows.Err() != nil || len(actions) != 4 || actions[0] != "set" || actions[3] != "delete" { + t.Fatal("deployment writes not audited", actions, rows.Err()) + } +} + +func TestRejectedWritesStoreNothing(t *testing.T) { + f := newFixture(t) + scoped := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "abcd1234", RequestID: "r", TraceID: "t", ProjectID: uuid.NewString()}) + configuration := v1.ModelConfigurationInput{ModelProvider: fixtureProvider, Model: "fixture"} + for name, ctx := range map[string]context.Context{"project-scoped source": scoped, "no source": t.Context()} { + if _, err := f.service.Replace(ctx, modelconfiguration.Replacement{Harness: "codex", Configuration: configuration}); !errors.Is(err, adminaudit.ErrInvalidSource) { + t.Fatal(name, "accepted for a deployment write", err) + } + } + unstorable := configuration + unstorable.Model = "fixture\x00model" + if _, err := f.service.Replace(admin(t), modelconfiguration.Replacement{Harness: "codex", Configuration: unstorable}); !errors.Is(err, textvalue.ErrUnstorable) { + t.Fatal("unstorable text", err) + } + var stored, audited int + if err := f.pool.QueryRow(t.Context(), "SELECT (SELECT count(*) FROM deployment_model_providers), (SELECT count(*) FROM admin_audit_log)").Scan(&stored, &audited); err != nil || stored != 0 || audited != 0 { + t.Fatal("rejected write committed", stored, audited, err) + } +} From 5b9998d5c2de69deb05b6bffb48a50c82ca6aa08 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 30 Sep 2026 16:21:01 +0000 Subject: [PATCH 2/2] Serve deployment default model configuration from its domain The /core/v1 harness model-configuration routes and Session creation use the modelconfiguration service and reader, and the Dispatcher records provider observations through a required modelconfiguration.Observer. ErrModelProviderRequired moves to execution, which raises it. Store no longer owns deployment defaults or their observations. --- services/core/IMPLEMENTATION.md | 3 +- services/core/cmd/server/http_routes_test.go | 3 +- services/core/cmd/server/main.go | 13 +- .../core_model_provider_validation_test.go | 37 +- services/core/internal/api/dependencies.go | 4 +- .../core/internal/api/dependencies_test.go | 11 +- .../internal/api/environment_creation_test.go | 2 +- .../api/environment_installation_test.go | 2 +- services/core/internal/api/errors.go | 2 +- .../internal/api/errors_modelconfiguration.go | 26 ++ services/core/internal/api/fakes_test.go | 47 +- services/core/internal/api/handler.go | 2 +- services/core/internal/api/handler_test.go | 2 +- .../internal/api/harness_model_providers.go | 49 +- .../api/model_configuration_route_test.go | 2 +- .../api/model_provider_fixture_test.go | 10 +- .../internal/api/session_admission_test.go | 2 +- .../api/session_creation_stream_test.go | 2 +- .../api/session_model_configuration.go | 2 +- .../api/session_model_configuration_test.go | 6 +- services/core/internal/api/session_request.go | 3 +- .../deployment_provider_observations.go | 27 +- .../deployment_provider_observations_test.go | 25 +- .../core/internal/execution/dispatcher.go | 4 + .../execution/environment_admission.go | 2 +- .../execution/model_execution_test.go | 2 +- .../core/internal/execution/owner_test.go | 24 +- .../internal/execution/prepared_dispatch.go | 2 +- services/core/internal/execution/request.go | 6 +- services/core/internal/execution/worker.go | 5 +- .../internal/execution/worker_schedule.go | 2 +- .../providers/configuration_flow_test.go | 3 +- .../agent_execution_defaults_http_test.go | 5 +- .../store/archive_cancellation_test.go | 4 +- .../store/deployment_model_providers.go | 136 ------ .../deployment_model_providers_http_test.go | 39 +- .../store/deployment_model_providers_test.go | 131 ------ ...ployment_provider_observation_lock_test.go | 120 ----- .../store/deployment_provider_observations.go | 48 -- .../deployment_provider_observations_test.go | 425 ------------------ services/core/internal/store/dispatch_test.go | 4 +- .../core/internal/store/fixture_db_test.go | 5 +- .../store/function_images_native_test.go | 2 +- .../store/function_public_native_test.go | 2 +- .../internal/store/initial_files_http_test.go | 2 +- .../store/mcode_public_native_test.go | 2 +- .../store/message_images_native_test.go | 2 +- .../store/model_protocol_native_test.go | 6 +- .../store/model_provider_fixture_test.go | 7 +- .../core/internal/store/native_daemon_test.go | 8 +- .../store/native_public_execution_test.go | 2 +- .../store/public_handler_fixture_test.go | 24 +- .../store/session_creation_identity_test.go | 152 +++++++ .../store/structured_output_native_test.go | 2 +- .../store/template_composition_public_test.go | 2 +- .../template_null_selection_public_test.go | 2 +- .../internal/store/tool_policy_native_test.go | 2 +- .../internal/store/tool_search_native_test.go | 2 +- 58 files changed, 449 insertions(+), 1019 deletions(-) create mode 100644 services/core/internal/api/errors_modelconfiguration.go delete mode 100644 services/core/internal/store/deployment_model_providers.go delete mode 100644 services/core/internal/store/deployment_model_providers_test.go delete mode 100644 services/core/internal/store/deployment_provider_observation_lock_test.go delete mode 100644 services/core/internal/store/deployment_provider_observations.go delete mode 100644 services/core/internal/store/deployment_provider_observations_test.go diff --git a/services/core/IMPLEMENTATION.md b/services/core/IMPLEMENTATION.md index e9f2ffc5..f4380606 100644 --- a/services/core/IMPLEMENTATION.md +++ b/services/core/IMPLEMENTATION.md @@ -26,6 +26,7 @@ Domain owners, each with its PostgreSQL adapter under `internal/persistence/post - `files` (`filepg`): source Files. - `vaults` (`vaultpg`): Vaults and Credentials, the encryption of Credential secrets, OAuth access-token refresh, and the MCP credential selection that Session creation freezes and the Dispatcher's `Credentials` resolves into a bearer token. - `environmenttemplates` (`templatepg`): Environment Templates, their validation and default network, their sealed setup, initial files, Skills and Plugins, and the resolved Template that Session creation composes into its Environment. +- `modelconfiguration` (`modelconfigurationpg`): each Harness's deployment default model configuration and its last-use observations. ## Request handling @@ -105,7 +106,7 @@ Reusable Agents are tenant-scoped rows independent of Session snapshots and engi Saved execution defaults keep a model-provider bundle whole at every replacement boundary: endpoint, key, protocol and limits are never inherited separately. Agent JSON holds only the safe provider fields and an output-only configured flag; the complete bundle is encrypted separately with a tenant and Agent binding and its own purpose, and configuration and secret changes commit together under the Agent row lock. Model-only edits need no key. Merged harness, protocol and limits are validated without reading keys. Session creation reads safe defaults and ciphertext in one snapshot, and a complete Session override does not decrypt the inherited bundle. The resolved bundle is frozen in an encrypted Session-owned row, and dispatch fails closed when that snapshot is missing or cannot be decrypted; later Agent edits, default changes, restarts and suspension never resolve it again. -Deployment-default observations use a private revision UUID generated on every PUT, identical replacements included. Session creation reads the ciphertext and revision together and freezes them; retries and older Sessions never gain or replace revision metadata. After a successful root terminal commit, one independent pool operation has at most one second to update the matching current revision. SQL verifies the tenant, root Turn and committed outcome, and only completed Turns and native provider failures with `engine_failed` count; cancelled work, Core or Runtime errors and input-policy classifications never do. The metadata-only transaction sets statement and lock timeouts within the remaining budget and issues one UPDATE that locks only the default and samples database time after the lock. Errors throttle for 30 seconds, ordinary successes throttle for 30 seconds with one immediate recovery write after each accepted error, and an unchanged revision has at most three effective writes in any 30-second window of nondecreasing database time. Observations never change `updated_at`, readiness or execution truth, and can be lost or stale; there is no queue, retry, probe or backfill. +`modelconfiguration` owns each Harness's deployment default: its service validates a replacement through the Harness declaration and seals the complete bundle, and `modelconfigurationpg` stores it under a private revision UUID generated on every PUT, identical replacements included, with its audit row in the same transaction. Session creation resolves the ciphertext and revision together and freezes them; retries and older Sessions never gain or replace revision metadata. After a successful root terminal commit, the Dispatcher's required `modelconfiguration.Observer` runs one independent pool operation with at most one second to update the matching current revision. Only completed Turns and native provider failures with `engine_failed` count; cancelled work, Core or Runtime errors and input-policy classifications never do. `ShouldObserveProvider` skips the round trip for outcomes that cannot count, and the SQL stays authoritative: it verifies the tenant, root Turn and committed outcome. Both check the same shared cases. The metadata-only transaction sets statement and lock timeouts within the remaining budget and issues one UPDATE that locks only the default and samples database time after the lock. Errors throttle for 30 seconds, ordinary successes throttle for 30 seconds with one immediate recovery write after each accepted error, and an unchanged revision has at most three effective writes in any 30-second window of nondecreasing database time. Observations never change `updated_at`, readiness or execution truth, and can be lost or stale; there is no queue, retry, probe or backfill. Session execution-configuration reads use a separate immutable safe projection written with its provenance in the Session's creation transaction. It reads no ciphertext, never recomputes sources from current Agents or defaults, never touches activity or wakes a sandbox, and does not affect retry identity. diff --git a/services/core/cmd/server/http_routes_test.go b/services/core/cmd/server/http_routes_test.go index c9babc8a..7dfa926d 100644 --- a/services/core/cmd/server/http_routes_test.go +++ b/services/core/cmd/server/http_routes_test.go @@ -87,7 +87,8 @@ func daemonComposition(t testing.TB) http.Handler { } apiHandler, err := api.NewHandler(api.Dependencies{ Engine: "codex", CoreKeys: admin, InstallationBindings: struct{ api.InstallationBindings }{}, - Projects: trapProjects{keys: keys}, ModelProviders: struct{ api.ModelProviders }{}, + Projects: trapProjects{keys: keys}, + ModelProviders: struct{ api.ModelProviders }{}, ModelProvidersReader: struct{ api.ModelProvidersReader }{}, Vaults: struct{ api.Vaults }{}, VaultsReader: struct{ api.VaultsReader }{}, Files: struct{ api.Files }{}, FilesReader: struct{ api.FilesReader }{}, Skills: struct{ api.Skills }{}, Agents: struct{ api.Agents }{}, AgentsReader: struct{ api.AgentsReader }{}, Sessions: struct{ api.Sessions }{}, SessionEvents: struct{ api.SessionEvents }{}, diff --git a/services/core/cmd/server/main.go b/services/core/cmd/server/main.go index 397f9ea6..49d64358 100644 --- a/services/core/cmd/server/main.go +++ b/services/core/cmd/server/main.go @@ -38,10 +38,12 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/files" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/nativeinstaller" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/agentpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/filepg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/templatepg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/vaultpg" @@ -132,6 +134,11 @@ func run() error { if err != nil { return err } + modelConfigurationStore := modelconfigurationpg.New(units) + modelConfigurationService, err := modelconfiguration.NewService(modelConfigurationStore, credentialKey) + if err != nil { + return err + } installation, err := installationFacts(public) if err != nil { return err @@ -251,7 +258,8 @@ func run() error { } } if registry != nil { - dispatcher := &execution.Dispatcher{Store: executionStore, Registry: registry, Credentials: vaultService, + dispatcher := &execution.Dispatcher{Store: executionStore, Registry: registry, + Credentials: vaultService, Observer: modelConfigurationStore, ManagedRuntimes: managed, MaxConcurrentExecutions: concurrency} lease, err := pgunit.AcquireLease(ctx, pool) if err != nil { @@ -323,7 +331,8 @@ func run() error { deps := api.Dependencies{ Engine: engine, Harnesses: kinds, CoreKeys: keyAdmin, Installation: installation, InstallationBindings: executionStore, - Projects: executionStore, ModelProviders: executionStore, + Projects: executionStore, + ModelProviders: modelConfigurationService, ModelProvidersReader: modelConfigurationStore, Vaults: vaultService, VaultsReader: vaultStore, Skills: executionStore, EnvironmentTemplates: environmentTemplates, EnvironmentTemplatesReader: templateStore, diff --git a/services/core/internal/api/core_model_provider_validation_test.go b/services/core/internal/api/core_model_provider_validation_test.go index d16cda70..d059dc24 100644 --- a/services/core/internal/api/core_model_provider_validation_test.go +++ b/services/core/internal/api/core_model_provider_validation_test.go @@ -1,6 +1,7 @@ package api import ( + "bytes" "context" "encoding/json" "net/http" @@ -8,26 +9,46 @@ import ( "strings" "testing" - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" ) -// coreProviderValidationStore counts deployment model provider writes. +// coreProviderValidationStore serves the real model configuration rules over +// storage that counts the deployment defaults reaching it. type coreProviderValidationStore struct { + t testing.TB writes int } -func (s *coreProviderValidationStore) SetDeploymentModelProvider(context.Context, string, v1.ModelConfigurationInput) (store.DeploymentModelProvider, error) { +func (s *coreProviderValidationStore) Replace(context.Context, modelconfiguration.Record) (modelconfiguration.Configuration, error) { s.writes++ - return store.DeploymentModelProvider{}, nil + return modelconfiguration.Configuration{}, nil } -func (s *coreProviderValidationStore) configure(_ *Dependencies, f *testFakes) { - f.modelProviders.setDeploymentModelProvider = s.SetDeploymentModelProvider +func (s *coreProviderValidationStore) Delete(context.Context, string) error { + unexpectedCall(s.t, "Delete") + return nil +} + +func (s *coreProviderValidationStore) LoadSealed(context.Context, string) (modelconfiguration.Sealed, error) { + unexpectedCall(s.t, "LoadSealed") + return modelconfiguration.Sealed{}, nil +} + +func (s *coreProviderValidationStore) configure(d *Dependencies, _ *testFakes) { + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{3}, 32)) + if err != nil { + s.t.Fatal(err) + } + service, err := modelconfiguration.NewService(s, cipher) + if err != nil { + s.t.Fatal(err) + } + d.ModelProviders = service } func TestCoreModelProviderValidationFields(t *testing.T) { - s := &coreProviderValidationStore{} + s := &coreProviderValidationStore{t: t} h, _, _ := adminTestHandler(t, s.configure) for _, tc := range []struct { name, harness, body, code, param string diff --git a/services/core/internal/api/dependencies.go b/services/core/internal/api/dependencies.go index 942f3a86..2b3e18ef 100644 --- a/services/core/internal/api/dependencies.go +++ b/services/core/internal/api/dependencies.go @@ -33,6 +33,7 @@ type Dependencies struct { Vaults Vaults VaultsReader VaultsReader ModelProviders ModelProviders + ModelProvidersReader ModelProvidersReader Files Files FilesReader FilesReader Skills Skills @@ -116,7 +117,8 @@ func (d Dependencies) validate() error { if err := required( field{"InstallationBindings", d.InstallationBindings}, field{"Projects", d.Projects}, field{"Vaults", d.Vaults}, field{"VaultsReader", d.VaultsReader}, - field{"ModelProviders", d.ModelProviders}, field{"Skills", d.Skills}, + field{"ModelProviders", d.ModelProviders}, field{"ModelProvidersReader", d.ModelProvidersReader}, + field{"Skills", d.Skills}, field{"Files", d.Files}, field{"FilesReader", d.FilesReader}, field{"EnvironmentTemplates", d.EnvironmentTemplates}, field{"EnvironmentTemplatesReader", d.EnvironmentTemplatesReader}, field{"Agents", d.Agents}, field{"AgentsReader", d.AgentsReader}, diff --git a/services/core/internal/api/dependencies_test.go b/services/core/internal/api/dependencies_test.go index 45a53e55..476f3a63 100644 --- a/services/core/internal/api/dependencies_test.go +++ b/services/core/internal/api/dependencies_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) // testExecutorURL is the daemon URL self-hosted Sessions report in tests. @@ -19,6 +19,7 @@ type testFakes struct { vaults *fakeVaults vaultsReader *fakeVaultsReader modelProviders *fakeModelProviders + modelProvidersReader *fakeModelProvidersReader files *fakeFiles filesReader *fakeFilesReader skills *fakeSkills @@ -58,7 +59,8 @@ type testFakes struct { func testDependencies(t testing.TB) (Dependencies, *testFakes) { t.Helper() f := &testFakes{ - projects: &fakeProjects{t: t}, modelProviders: &fakeModelProviders{t: t}, + projects: &fakeProjects{t: t}, + modelProviders: &fakeModelProviders{t: t}, modelProvidersReader: &fakeModelProvidersReader{t: t}, vaults: &fakeVaults{t: t}, vaultsReader: &fakeVaultsReader{t: t}, skills: &fakeSkills{t: t}, environmentTemplates: &fakeEnvironmentTemplates{t: t}, environmentTemplatesReader: &fakeEnvironmentTemplatesReader{t: t}, @@ -74,7 +76,8 @@ func testDependencies(t testing.TB) (Dependencies, *testFakes) { } return Dependencies{ Engine: "codex", CoreKeys: coreKeys(t, "admin"), InstallationBindings: f.installationBindings, - Projects: f.projects, ModelProviders: f.modelProviders, Skills: f.skills, + Projects: f.projects, Skills: f.skills, + ModelProviders: f.modelProviders, ModelProvidersReader: f.modelProvidersReader, Vaults: f.vaults, VaultsReader: f.vaultsReader, Files: f.files, FilesReader: f.filesReader, EnvironmentTemplates: f.environmentTemplates, EnvironmentTemplatesReader: f.environmentTemplatesReader, @@ -124,7 +127,7 @@ func newTestHandler(t testing.TB, deps Dependencies) http.Handler { } // noDeploymentModelProvider is a deployment without a default model provider. -func noDeploymentModelProvider(context.Context, string) (*store.DeploymentModelProviderSnapshot, error) { +func noDeploymentModelProvider(context.Context, string) (*modelconfiguration.Snapshot, error) { return nil, nil } diff --git a/services/core/internal/api/environment_creation_test.go b/services/core/internal/api/environment_creation_test.go index d14c2d0a..c25f654c 100644 --- a/services/core/internal/api/environment_creation_test.go +++ b/services/core/internal/api/environment_creation_test.go @@ -64,7 +64,7 @@ func environmentCreationHandler(t *testing.T, engine string, configure ...func(* }).ResolveProjectAPIKey fixture.serve(fakes) fakes.sessions.findSessionCreation, fakes.sessions.createSession, fakes.sessions.createSessionStream = fixture.FindSessionCreation, fixture.CreateSession, fixture.CreateSessionStream - fakes.modelProviders.deploymentModelProvider = fixtureDeploymentProvider + fakes.modelProviders.resolve = fixtureDeploymentProvider for _, c := range configure { c(&deps, fakes) } diff --git a/services/core/internal/api/environment_installation_test.go b/services/core/internal/api/environment_installation_test.go index 122ecb31..afcebc53 100644 --- a/services/core/internal/api/environment_installation_test.go +++ b/services/core/internal/api/environment_installation_test.go @@ -37,7 +37,7 @@ func TestSelfHostedCreationReturnsInstallationWithoutWebCredential(t *testing.T) deps, fakes := testDependencies(t) fakes.projects.resolveProjectAPIKey = projectKeys(t, APIKey{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential("project-key"), TenantID: uuid.NewString()}).ResolveProjectAPIKey fakes.sessions.findSessionCreation, fakes.sessions.createSession = f.FindSessionCreation, f.CreateSession - fakes.modelProviders.deploymentModelProvider = fixtureDeploymentProvider + fakes.modelProviders.resolve = fixtureDeploymentProvider fakes.environments.authorizeEnvironmentInstallation, fakes.environments.validateEnvironmentInstallation = f.AuthorizeEnvironmentInstallation, f.ValidateEnvironmentInstallation deps.Execution = fakes.execution() deps.Execution.NativeInstaller = &NativeInstaller{Version: "build", Catalog: &nativeinstaller.Catalog{Version: "build"}} diff --git a/services/core/internal/api/errors.go b/services/core/internal/api/errors.go index 5f1ba4ea..198695f7 100644 --- a/services/core/internal/api/errors.go +++ b/services/core/internal/api/errors.go @@ -190,7 +190,7 @@ func writeStoreError(w http.ResponseWriter, r *http.Request, err error, notFound case errors.Is(err, store.ErrDefaultSkillVersion): writeError(w, http.StatusBadRequest, "invalid_value", "Cannot delete the default skill version.", "version") - case errors.Is(err, store.ErrModelProviderRequired): + case errors.Is(err, execution.ErrModelProviderRequired): writeError(w, http.StatusBadRequest, "model_provider_required", "This Session was created without a model provider and cannot run. Create a new Session with x_agents_core.model_provider or an Agent that has one saved.") case errors.Is(err, store.ErrHostedEnvironmentFailed): // Observed official status, type, code, null param and message. diff --git a/services/core/internal/api/errors_modelconfiguration.go b/services/core/internal/api/errors_modelconfiguration.go new file mode 100644 index 00000000..88af07ce --- /dev/null +++ b/services/core/internal/api/errors_modelconfiguration.go @@ -0,0 +1,26 @@ +package api + +import ( + "errors" + "net/http" + + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/go-chi/chi/v5" +) + +// writeModelConfigurationError reports a failure from the deployment default +// model configuration operations. A configuration the Harness declaration +// rejects names its field; anything unmapped is an internal error. +func writeModelConfigurationError(w http.ResponseWriter, r *http.Request, err error) { + var field *v1.ModelProviderError + if errors.As(err, &field) { + if !writeCoreModelProviderError(w, err, chi.URLParam(r, "harness")) { + writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error()) + } + return + } + if writeAuditSourceError(w, r, err) || writeTextValueError(w, r, err) || writeCredentialUnavailableError(w, r, err) { + return + } + writeInternalError(w, r) +} diff --git a/services/core/internal/api/fakes_test.go b/services/core/internal/api/fakes_test.go index 6ce3bfe2..16fb637f 100644 --- a/services/core/internal/api/fakes_test.go +++ b/services/core/internal/api/fakes_test.go @@ -14,6 +14,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/files" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimehistory" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimeobs" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox" @@ -548,39 +549,43 @@ func (f *fakeMetrics) RecordUnavailable() { } type fakeModelProviders struct { - t testing.TB - listDeploymentModelProviders func(context.Context) ([]store.DeploymentModelProvider, error) - setDeploymentModelProvider func(context.Context, string, v1.ModelConfigurationInput) (store.DeploymentModelProvider, error) - deleteDeploymentModelProvider func(context.Context, string) error - deploymentModelProvider func(context.Context, string) (*store.DeploymentModelProviderSnapshot, error) + t testing.TB + replace func(context.Context, modelconfiguration.Replacement) (modelconfiguration.Configuration, error) + delete func(context.Context, string) error + resolve func(context.Context, string) (*modelconfiguration.Snapshot, error) } -func (f *fakeModelProviders) ListDeploymentModelProviders(a0 context.Context) ([]store.DeploymentModelProvider, error) { - if f.listDeploymentModelProviders == nil { - unexpectedCall(f.t, "ListDeploymentModelProviders") +func (f *fakeModelProviders) Replace(a0 context.Context, a1 modelconfiguration.Replacement) (modelconfiguration.Configuration, error) { + if f.replace == nil { + unexpectedCall(f.t, "Replace") } - return f.listDeploymentModelProviders(a0) + return f.replace(a0, a1) } -func (f *fakeModelProviders) SetDeploymentModelProvider(a0 context.Context, a1 string, a2 v1.ModelConfigurationInput) (store.DeploymentModelProvider, error) { - if f.setDeploymentModelProvider == nil { - unexpectedCall(f.t, "SetDeploymentModelProvider") +func (f *fakeModelProviders) Delete(a0 context.Context, a1 string) error { + if f.delete == nil { + unexpectedCall(f.t, "Delete") } - return f.setDeploymentModelProvider(a0, a1, a2) + return f.delete(a0, a1) } -func (f *fakeModelProviders) DeleteDeploymentModelProvider(a0 context.Context, a1 string) error { - if f.deleteDeploymentModelProvider == nil { - unexpectedCall(f.t, "DeleteDeploymentModelProvider") +func (f *fakeModelProviders) Resolve(a0 context.Context, a1 string) (*modelconfiguration.Snapshot, error) { + if f.resolve == nil { + unexpectedCall(f.t, "Resolve") } - return f.deleteDeploymentModelProvider(a0, a1) + return f.resolve(a0, a1) } -func (f *fakeModelProviders) DeploymentModelProvider(a0 context.Context, a1 string) (*store.DeploymentModelProviderSnapshot, error) { - if f.deploymentModelProvider == nil { - unexpectedCall(f.t, "DeploymentModelProvider") +type fakeModelProvidersReader struct { + t testing.TB + list func(context.Context) ([]modelconfiguration.Configuration, error) +} + +func (f *fakeModelProvidersReader) List(a0 context.Context) ([]modelconfiguration.Configuration, error) { + if f.list == nil { + unexpectedCall(f.t, "List") } - return f.deploymentModelProvider(a0, a1) + return f.list(a0) } type fakeProjects struct { diff --git a/services/core/internal/api/handler.go b/services/core/internal/api/handler.go index 08ab2ef0..e4d98de1 100644 --- a/services/core/internal/api/handler.go +++ b/services/core/internal/api/handler.go @@ -235,7 +235,7 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { case errors.As(err, &required): writeError(w, http.StatusBadRequest, "model_provider_required", required.message, "x_agents_core.model_provider") case errors.As(err, &defaults): - writeStoreError(w, r, defaults.err) + writeModelConfigurationError(w, r, defaults.err) case !writeFieldError(w, err): writeError(w, http.StatusBadRequest, "unsupported_or_invalid_configuration", err.Error()) } diff --git a/services/core/internal/api/handler_test.go b/services/core/internal/api/handler_test.go index 330dcd6a..6082f21b 100644 --- a/services/core/internal/api/handler_test.go +++ b/services/core/internal/api/handler_test.go @@ -67,7 +67,7 @@ func testHandler(t *testing.T, configure ...func(*Dependencies, *testFakes)) (ht fakes.projects.resolveProjectAPIKey = projectKeys(t, APIKey{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: hex.EncodeToString(hash[:]), TenantID: tenant}).ResolveProjectAPIKey s := &recordingStore{} s.record(fakes) - fakes.modelProviders.deploymentModelProvider = noDeploymentModelProvider + fakes.modelProviders.resolve = noDeploymentModelProvider for _, c := range configure { c(&deps, fakes) } diff --git a/services/core/internal/api/harness_model_providers.go b/services/core/internal/api/harness_model_providers.go index 6ab5f40a..958d1148 100644 --- a/services/core/internal/api/harness_model_providers.go +++ b/services/core/internal/api/harness_model_providers.go @@ -9,19 +9,23 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/harnessconfig/builtin" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/engine" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/go-chi/chi/v5" ) -// ModelProviders holds one deployment default model provider per harness. Keys -// are write-only and encrypted. DeploymentModelProvider decrypts a harness's -// default at Session creation, before the encrypted Session snapshot is -// committed; it returns nil when the harness has no default. +// ModelProviders replaces and removes each harness's deployment default +// model configuration. Resolve opens a harness's default at Session creation, +// before the encrypted Session snapshot is committed; it returns nil when the +// harness has none. type ModelProviders interface { - ListDeploymentModelProviders(context.Context) ([]store.DeploymentModelProvider, error) - SetDeploymentModelProvider(context.Context, string, v1.ModelConfigurationInput) (store.DeploymentModelProvider, error) - DeleteDeploymentModelProvider(context.Context, string) error - DeploymentModelProvider(context.Context, string) (*store.DeploymentModelProviderSnapshot, error) + Replace(context.Context, modelconfiguration.Replacement) (modelconfiguration.Configuration, error) + Delete(context.Context, string) error + Resolve(context.Context, string) (*modelconfiguration.Snapshot, error) +} + +// ModelProvidersReader lists the deployment defaults' safe views. +type ModelProvidersReader interface { + List(context.Context) ([]modelconfiguration.Configuration, error) } // HarnessModelConfiguration is a harness's deployment default model provider. It @@ -53,7 +57,7 @@ type CoreHarnessList struct { Data []CoreHarness `json:"data" binding:"required"` } -func harnessModelConfiguration(value store.DeploymentModelProvider) *HarnessModelConfiguration { +func harnessModelConfiguration(value modelconfiguration.Configuration) *HarnessModelConfiguration { return &HarnessModelConfiguration{Object: "core.model_configuration", Harness: value.Harness, ModelConfigurationView: v1.ModelConfigurationView{ModelProvider: &value.Provider, Model: value.Model, HarnessConfig: value.HarnessConfig}, UpdatedAt: value.UpdatedAt.UTC(), LastUsedAt: value.LastUsedAt, LastErrorCode: value.LastErrorCode, LastErrorAt: value.LastErrorAt} } @@ -86,9 +90,9 @@ func knownHarness(w http.ResponseWriter, r *http.Request) (string, bool) { // @Failure 401,500 {object} CoreErrorResponse // @Router /core/v1/harnesses [get] func (h *Handler) listHarnesses(w http.ResponseWriter, r *http.Request) { - providers, err := h.ModelProviders.ListDeploymentModelProviders(r.Context()) + providers, err := h.ModelProvidersReader.List(r.Context()) if err != nil { - writeStoreError(w, r, err) + writeModelConfigurationError(w, r, err) return } list := CoreHarnessList{Object: "list", Data: []CoreHarness{}} @@ -124,9 +128,9 @@ func (h *Handler) getHarnessModelConfiguration(w http.ResponseWriter, r *http.Re if !ok { return } - providers, err := h.ModelProviders.ListDeploymentModelProviders(r.Context()) + providers, err := h.ModelProvidersReader.List(r.Context()) if err != nil { - writeStoreError(w, r, err) + writeModelConfigurationError(w, r, err) return } for _, provider := range providers { @@ -181,20 +185,13 @@ func (h *Handler) setHarnessModelConfiguration(w http.ResponseWriter, r *http.Re writeError(w, http.StatusBadRequest, "invalid_model_provider", "The body requires model_provider, model and optional harness_config.") return } - if err := input.ValidateHarness(harness); err != nil { - if writeCoreModelProviderError(w, err, harness) { - return - } - writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error()) - return - } setAdminAuditSource(r, "") - provider, err := h.ModelProviders.SetDeploymentModelProvider(r.Context(), harness, input) + configuration, err := h.ModelProviders.Replace(r.Context(), modelconfiguration.Replacement{Harness: harness, Configuration: input}) if err != nil { - writeStoreError(w, r, err) + writeModelConfigurationError(w, r, err) return } - writeJSON(w, http.StatusOK, harnessModelConfiguration(provider)) + writeJSON(w, http.StatusOK, harnessModelConfiguration(configuration)) } // @Summary Remove a harness's deployment default model provider @@ -211,8 +208,8 @@ func (h *Handler) deleteHarnessModelConfiguration(w http.ResponseWriter, r *http return } setAdminAuditSource(r, "") - if err := h.ModelProviders.DeleteDeploymentModelProvider(r.Context(), harness); err != nil { - writeStoreError(w, r, err) + if err := h.ModelProviders.Delete(r.Context(), harness); err != nil { + writeModelConfigurationError(w, r, err) return } w.Header().Set("Cache-Control", "no-store") diff --git a/services/core/internal/api/model_configuration_route_test.go b/services/core/internal/api/model_configuration_route_test.go index e8356171..88203214 100644 --- a/services/core/internal/api/model_configuration_route_test.go +++ b/services/core/internal/api/model_configuration_route_test.go @@ -16,7 +16,7 @@ func TestModelConfigurationRouteAdmission(t *testing.T) { {"null", "responses", "null", "model", "invalid_model_provider", ""}, } { t.Run(tc.name, func(t *testing.T) { - s := &coreProviderValidationStore{} + s := &coreProviderValidationStore{t: t} h, _, _ := adminTestHandler(t, s.configure) body := `{"model":` + mustJSONForTest(tc.model) + `,"model_provider":{"protocol":"` + tc.protocol + `","base_url":"https://example.test","api_key":"provider-secret"},"harness_config":` + tc.native + `}` out := projectKeyHTTP(h, http.MethodPut, "/core/v1/harnesses/codex/model-configuration", "admin", body) diff --git a/services/core/internal/api/model_provider_fixture_test.go b/services/core/internal/api/model_provider_fixture_test.go index 4de3f94a..84521840 100644 --- a/services/core/internal/api/model_provider_fixture_test.go +++ b/services/core/internal/api/model_provider_fixture_test.go @@ -2,10 +2,10 @@ package api import ( "context" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" - "github.com/google/uuid" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" + "github.com/google/uuid" ) // Hosted and self-hosted Sessions must freeze a model provider. Tests that @@ -20,9 +20,9 @@ func fixtureModelProvider(harness string) *v1.ModelProviderInput { } // fixtureDeploymentProvider is a deployment default for every harness, for -// fakeModelProviders.deploymentModelProvider. -func fixtureDeploymentProvider(_ context.Context, harness string) (*store.DeploymentModelProviderSnapshot, error) { - return &store.DeploymentModelProviderSnapshot{Model: "fixture", Provider: fixtureModelProvider(harness), Revision: uuid.New()}, nil +// fakeModelProviders.resolve. +func fixtureDeploymentProvider(_ context.Context, harness string) (*modelconfiguration.Snapshot, error) { + return &modelconfiguration.Snapshot{Model: "fixture", Provider: fixtureModelProvider(harness), Revision: uuid.New()}, nil } // fixtureSessionProvider is a top-level Session request member for Codex. diff --git a/services/core/internal/api/session_admission_test.go b/services/core/internal/api/session_admission_test.go index c27c322d..f9a238ab 100644 --- a/services/core/internal/api/session_admission_test.go +++ b/services/core/internal/api/session_admission_test.go @@ -15,7 +15,7 @@ import ( // testHandler serves, so any access fails the test. func forbidSessionAccess(_ *Dependencies, f *testFakes) { f.sessions.createSession, f.sessions.getSession, f.sessions.findSessionCreation, f.sessions.listSessions = nil, nil, nil, nil - f.modelProviders.deploymentModelProvider = nil + f.modelProviders.resolve = nil } func TestSessionAdmissionRejectsBeforeResourceOrExecutionAccess(t *testing.T) { diff --git a/services/core/internal/api/session_creation_stream_test.go b/services/core/internal/api/session_creation_stream_test.go index 994992b3..69dba858 100644 --- a/services/core/internal/api/session_creation_stream_test.go +++ b/services/core/internal/api/session_creation_stream_test.go @@ -149,7 +149,7 @@ func newCreationStreamHarness(t *testing.T) *creationStreamHarness { }).ResolveProjectAPIKey fakes.sessions.getSession, fakes.sessions.findSessionCreation, fakes.sessions.auditSessionOperation = fixture.GetSession, fixture.FindSessionCreation, fixture.AuditSessionOperation fakes.sessionEvents.sessionEventCursor, fakes.sessionEvents.sessionStreamSnapshot, fakes.sessionEvents.listSessionEvents = fixture.SessionEventCursor, fixture.SessionStreamSnapshot, fixture.ListSessionEvents - fakes.modelProviders.deploymentModelProvider = noDeploymentModelProvider + fakes.modelProviders.resolve = noDeploymentModelProvider deps.Execution = fakes.execution() fakes.admission.createSessionStream = fixture.CreateSessionStream handler := newTestHandler(t, deps) diff --git a/services/core/internal/api/session_model_configuration.go b/services/core/internal/api/session_model_configuration.go index 08d89b79..4cde53a6 100644 --- a/services/core/internal/api/session_model_configuration.go +++ b/services/core/internal/api/session_model_configuration.go @@ -35,7 +35,7 @@ func (h *Handler) prepareSessionModelConfiguration(ctx context.Context, input *s explicitProvider := input.XAgentsCore != nil && input.XAgentsCore.ModelProvider != nil needsModel := !explicitModel && saved == nil if v1.ModelProviderAllowed(input.Environment.Type, v1.ModelProviderSourceDeployment) && ((inherited == nil && !explicitProvider) || needsModel) { - input.deploymentDefaults, err = h.ModelProviders.DeploymentModelProvider(ctx, engine) + input.deploymentDefaults, err = h.ModelProviders.Resolve(ctx, engine) if err != nil { var configurationError *v1.ModelProviderError if errors.As(err, &configurationError) { diff --git a/services/core/internal/api/session_model_configuration_test.go b/services/core/internal/api/session_model_configuration_test.go index 3bc18cd2..bda85d06 100644 --- a/services/core/internal/api/session_model_configuration_test.go +++ b/services/core/internal/api/session_model_configuration_test.go @@ -6,7 +6,7 @@ import ( "testing" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" ) func TestSessionNativeConfigurationSources(t *testing.T) { @@ -41,9 +41,9 @@ func TestSessionNativeConfigurationSources(t *testing.T) { } calls := 0 deps, fakes := testDependencies(t) - fakes.modelProviders.deploymentModelProvider = func(context.Context, string) (*store.DeploymentModelProviderSnapshot, error) { + fakes.modelProviders.resolve = func(context.Context, string) (*modelconfiguration.Snapshot, error) { calls++ - return &store.DeploymentModelProviderSnapshot{Provider: provider, Model: "deployment-model", HarnessConfig: json.RawMessage(`{"model_reasoning_effort":"high"}`)}, nil + return &modelconfiguration.Snapshot{Provider: provider, Model: "deployment-model", HarnessConfig: json.RawMessage(`{"model_reasoning_effort":"high"}`)}, nil } h := &Handler{Dependencies: deps} err = h.prepareSessionModelConfiguration(t.Context(), &input, tc.saved, nil) diff --git a/services/core/internal/api/session_request.go b/services/core/internal/api/session_request.go index 52213f81..28a3da04 100644 --- a/services/core/internal/api/session_request.go +++ b/services/core/internal/api/session_request.go @@ -6,6 +6,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -28,7 +29,7 @@ type sessionRequest struct { initialization environmentconfig.Setup originalEnvironment json.RawMessage modelProviderNull bool - deploymentDefaults *store.DeploymentModelProviderSnapshot + deploymentDefaults *modelconfiguration.Snapshot modelSource string harnessConfigSource string resolvedHarnessConfig json.RawMessage diff --git a/services/core/internal/execution/deployment_provider_observations.go b/services/core/internal/execution/deployment_provider_observations.go index 672ec93c..43a10985 100644 --- a/services/core/internal/execution/deployment_provider_observations.go +++ b/services/core/internal/execution/deployment_provider_observations.go @@ -5,30 +5,25 @@ import ( "encoding/json" "time" - "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/obs/log" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" ) -// Observation is strictly after terminal commit. Its pool/lock timeout cannot -// cancel the execution lease or change the already committed public outcome. +// observeDeploymentProvider runs strictly after the terminal commit. Its pool +// and lock timeouts cannot cancel the execution lease or change the already +// committed public outcome. func (d *Dispatcher) observeDeploymentProvider(tenantID, sessionID string, turn sessions.Turn) { - if turn.Status != sessions.TurnCompleted { - if turn.Status != sessions.TurnFailed { - return - } - var result Result - if json.Unmarshal(turn.Outcome, &result) != nil || result.ErrorCode != "engine_failed" { - return - } - code, _ := proto.NormalizeEngineFailure(result.EngineErrorCode, nil) - if code == "" || code == "context_length_exceeded" || code == "cyber_policy" { - return - } + var result Result + if turn.Status == sessions.TurnFailed && json.Unmarshal(turn.Outcome, &result) != nil { + return + } + if !modelconfiguration.ShouldObserveProvider(turn.Status, result.ErrorCode, result.EngineErrorCode) { + return } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() - if _, err := d.Store.ObserveDeploymentModelProvider(ctx, tenantID, sessionID, turn.ID); err != nil { + if _, err := d.Observer.ObserveDeploymentModelProvider(ctx, modelconfiguration.Observation{TenantID: tenantID, SessionID: sessionID, TurnID: turn.ID}); err != nil { log.Warn(ctx, "Deployment model provider observation unavailable") } } diff --git a/services/core/internal/execution/deployment_provider_observations_test.go b/services/core/internal/execution/deployment_provider_observations_test.go index e19a8415..f16ed807 100644 --- a/services/core/internal/execution/deployment_provider_observations_test.go +++ b/services/core/internal/execution/deployment_provider_observations_test.go @@ -1,6 +1,7 @@ package execution import ( + "bytes" "context" "encoding/json" "errors" @@ -10,7 +11,11 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/identity" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" @@ -22,6 +27,7 @@ type finishObservationFixture struct { writer *store.Store lease Ownership pool *pgxpool.Pool + defaults *modelconfigurationpg.Store tenant string session store.Session dispatcher Dispatcher @@ -41,12 +47,21 @@ func newFinishObservationFixture(t *testing.T, maxConnections int32) finishObser t.Fatal(err) } t.Cleanup(pool.Close) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{8}, 32)) + if err != nil { + t.Fatal(err) + } + defaults := modelconfigurationpg.New(pgunit.NewPool(pool)) + service, err := modelconfiguration.NewService(defaults, cipher) + if err != nil { + t.Fatal(err) + } admin := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", RequestID: uuid.NewString(), TraceID: uuid.NewString()}) provider := v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://fixture.example/v1", APIKey: "fixture-only"} - if _, err = s.SetDeploymentModelProvider(admin, "codex", v1.ModelConfigurationInput{ModelProvider: provider, Model: "fixture"}); err != nil { + if _, err = service.Replace(admin, modelconfiguration.Replacement{Harness: "codex", Configuration: v1.ModelConfigurationInput{ModelProvider: provider, Model: "fixture"}}); err != nil { t.Fatal(err) } - snapshot, err := s.DeploymentModelProvider(t.Context(), "codex") + snapshot, err := service.Resolve(t.Context(), "codex") if err != nil { t.Fatal(err) } @@ -58,7 +73,7 @@ func newFinishObservationFixture(t *testing.T, maxConnections int32) finishObser if err != nil { t.Fatal(err) } - return finishObservationFixture{s, owner.Store, owner.Lease, pool, tenant, session, Dispatcher{Store: owner.Store}} + return finishObservationFixture{s, owner.Store, owner.Lease, pool, defaults, tenant, session, Dispatcher{Store: owner.Store, Observer: defaults}} } func (f finishObservationFixture) start(t *testing.T) store.InputReceipt { t.Helper() @@ -73,7 +88,7 @@ func (f finishObservationFixture) start(t *testing.T) store.InputReceipt { } func (f finishObservationFixture) fields(t *testing.T) (*time.Time, *string) { t.Helper() - rows, err := f.s.ListDeploymentModelProviders(t.Context()) + rows, err := f.defaults.List(t.Context()) if err != nil || len(rows) != 1 { t.Fatal(err) } @@ -179,7 +194,7 @@ func TestFinishRunObservationLockTimeoutAndFailureKeepLease(t *testing.T) { if err != nil { t.Fatal(err) } - d := Dispatcher{Store: store.New(pool)} + d := Dispatcher{Observer: modelconfigurationpg.New(pgunit.NewPool(pool))} started := time.Now() d.observeDeploymentProvider(f.tenant, f.session.ID, turn) if elapsed := time.Since(started); elapsed < 900*time.Millisecond || elapsed > 2*time.Second { diff --git a/services/core/internal/execution/dispatcher.go b/services/core/internal/execution/dispatcher.go index 6bf2faa8..ba5292ad 100644 --- a/services/core/internal/execution/dispatcher.go +++ b/services/core/internal/execution/dispatcher.go @@ -10,6 +10,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" @@ -32,6 +33,9 @@ type Dispatcher struct { Registry *runtimegateway.Registry // Credentials opens the bearer tokens of authenticated MCP servers. Credentials Credentials + // Observer records which deployment default model configurations committed + // root Turns used. It is required. + Observer modelconfiguration.Observer // ManagedRuntimes is optional internal provisioning; it does not admit hosted API requests. ManagedRuntimes *RuntimeProvider // MaxConcurrentExecutions bounds work admitted by this Core execution owner. diff --git a/services/core/internal/execution/environment_admission.go b/services/core/internal/execution/environment_admission.go index 47ca15d7..38def27b 100644 --- a/services/core/internal/execution/environment_admission.go +++ b/services/core/internal/execution/environment_admission.go @@ -92,7 +92,7 @@ func (w *Worker) submitEnvironmentInputs(ctx context.Context, session store.Sess // its harness cannot run. Cancellation and results above stay available. var snapshot Snapshot if json.Unmarshal(session.Configuration, &snapshot) != nil || !snapshot.ModelProviderConfigured { - return nil, store.ErrModelProviderRequired + return nil, ErrModelProviderRequired } changed, unsubscribe := w.dispatcher.notifications.subscribe(session.TenantID, session.ID) defer unsubscribe() diff --git a/services/core/internal/execution/model_execution_test.go b/services/core/internal/execution/model_execution_test.go index c456865e..6ca2e635 100644 --- a/services/core/internal/execution/model_execution_test.go +++ b/services/core/internal/execution/model_execution_test.go @@ -19,7 +19,7 @@ func TestSessionModelExecutionNeverFallsBack(t *testing.T) { // Hosted and self-hosted Runtimes have no model configuration of their own. for _, environment := range []string{"openai_hosted", "self_hosted"} { snapshot := Snapshot{Environment: &v1.Environment{Type: environment}} - if _, err := d.executionRequest(t.Context(), store.Session{Engine: "codex"}, snapshot, runtimedevice.KindCapabilities{}, store.SessionExecutionBinding{}); !errors.Is(err, store.ErrModelProviderRequired) { + if _, err := d.executionRequest(t.Context(), store.Session{Engine: "codex"}, snapshot, runtimedevice.KindCapabilities{}, store.SessionExecutionBinding{}); !errors.Is(err, ErrModelProviderRequired) { t.Fatal("provider-free Session dispatched", environment, err) } } diff --git a/services/core/internal/execution/owner_test.go b/services/core/internal/execution/owner_test.go index d68a749b..1cea828f 100644 --- a/services/core/internal/execution/owner_test.go +++ b/services/core/internal/execution/owner_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" "github.com/google/uuid" @@ -51,6 +52,15 @@ func (l *closeCountingLease) Close(ctx context.Context) error { return l.inner.Close(ctx) } +// unusedObserver fails the test on any observation. The Workers it serves run +// no Turn. +type unusedObserver struct{ t *testing.T } + +func (o unusedObserver) ObserveDeploymentModelProvider(context.Context, modelconfiguration.Observation) (int64, error) { + o.t.Error("unexpected call to ObserveDeploymentModelProvider") + return 0, errors.New("unexpected call to ObserveDeploymentModelProvider") +} + func TestStartWorkerFailureClosesLeaseOnce(t *testing.T) { if _, err := StartWorker(t.Context(), &Dispatcher{}, Owner{}); err == nil { t.Fatal("worker started without an execution lease") @@ -67,19 +77,23 @@ func TestStartWorkerFailureClosesLeaseOnce(t *testing.T) { _, err := StartWorker(canceled, &Dispatcher{MaxConcurrentExecutions: 1025}, Owner{Lease: lease}) return err }, - "missing Store": func(t *testing.T, lease *closeCountingLease) error { + "missing Credentials": func(t *testing.T, lease *closeCountingLease) error { + _, err := StartWorker(canceled, &Dispatcher{Observer: unusedObserver{t}}, Owner{Lease: lease}) + return err + }, + "missing observer": func(t *testing.T, lease *closeCountingLease) error { _, err := StartWorker(canceled, &Dispatcher{Credentials: &recordingCredentials{}}, Owner{Lease: lease}) return err }, - "missing Credentials": func(t *testing.T, lease *closeCountingLease) error { - _, err := StartWorker(canceled, &Dispatcher{}, Owner{Lease: lease}) + "missing Store": func(t *testing.T, lease *closeCountingLease) error { + _, err := StartWorker(canceled, &Dispatcher{Credentials: &recordingCredentials{}, Observer: unusedObserver{t}}, Owner{Lease: lease}) return err }, "deployment claim": func(t *testing.T, lease *closeCountingLease) error { s, owner := resetManagerStore(t) lease.inner = owner.Lease id := uuid.NewString() - dispatcher := &Dispatcher{Store: s, Registry: runtimegateway.NewRegistry(), Credentials: &recordingCredentials{}, ManagedRuntimes: NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return nil, nil })} + dispatcher := &Dispatcher{Store: s, Registry: runtimegateway.NewRegistry(), Credentials: &recordingCredentials{}, Observer: unusedObserver{t}, ManagedRuntimes: NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return nil, nil })} _, err := StartWorker(canceled, dispatcher, Owner{Lease: lease, Store: owner.Store}) if ping := owner.Lease.CheckOwnership(t.Context()); !errors.Is(ping, pgunit.ErrLeaseClosed) { t.Error("failed start kept the database lease", ping) @@ -104,7 +118,7 @@ func TestWorkerRunClosesLeaseAfterDrain(t *testing.T) { s, owner := resetManagerStore(t) lease := &closeCountingLease{t: t, inner: owner.Lease} id := uuid.NewString() - dispatcher := &Dispatcher{Store: s, Registry: runtimegateway.NewRegistry(), Credentials: &recordingCredentials{}, ManagedRuntimes: NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return nil, nil })} + dispatcher := &Dispatcher{Store: s, Registry: runtimegateway.NewRegistry(), Credentials: &recordingCredentials{}, Observer: unusedObserver{t}, ManagedRuntimes: NewDeferredRuntimeProvider(id, func(context.Context) (*RuntimeProvider, error) { return nil, nil })} worker, err := StartWorker(t.Context(), dispatcher, Owner{Lease: lease, Store: owner.Store}) if err != nil { t.Fatal(err) diff --git a/services/core/internal/execution/prepared_dispatch.go b/services/core/internal/execution/prepared_dispatch.go index ac3fa0d4..28def944 100644 --- a/services/core/internal/execution/prepared_dispatch.go +++ b/services/core/internal/execution/prepared_dispatch.go @@ -41,7 +41,7 @@ func (d *Dispatcher) RunEnvironmentInput(ctx context.Context, lease Ownership, t } if !snapshot.ModelProviderConfigured && snapshot.Environment != nil && v1.ModelProviderRequired(snapshot.Environment.Type) { // Reserved before providers were required; the caller settles it as failed. - return run, store.ErrModelProviderRequired + return run, ErrModelProviderRequired } bound, err := d.Store.GetSessionExecutionBinding(ctx, tenantID, sessionID) if err != nil { diff --git a/services/core/internal/execution/request.go b/services/core/internal/execution/request.go index dcd23cac..4d1a2e8d 100644 --- a/services/core/internal/execution/request.go +++ b/services/core/internal/execution/request.go @@ -12,6 +12,10 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/vaults" ) +// ErrModelProviderRequired reports a hosted or self-hosted Session that has no +// frozen model provider and therefore cannot run. +var ErrModelProviderRequired = errors.New("the Session has no model provider") + func (d *Dispatcher) executionRequest(ctx context.Context, session store.Session, snapshot Snapshot, caps runtimedevice.KindCapabilities, bound store.SessionExecutionBinding) (proto.PromptRequestPayload, error) { recoverNativeSession := bound.HasStartedTurn && bound.NativeSessionID == "" if recoverNativeSession && !caps.NativeSessionRecovery { @@ -30,7 +34,7 @@ func (d *Dispatcher) executionRequest(ctx context.Context, session store.Session } else if snapshot.Environment != nil && v1.ModelProviderRequired(snapshot.Environment.Type) { // Require the frozen bundle before dispatch so the harness cannot // select an implicit provider endpoint. - return proto.PromptRequestPayload{}, store.ErrModelProviderRequired + return proto.PromptRequestPayload{}, ErrModelProviderRequired } options["model"], options["system_prompt"] = snapshot.Agent.Model, snapshot.Agent.Instructions if snapshot.Agent.XAgentsCore != nil && len(snapshot.Agent.XAgentsCore.HarnessConfig) > 0 { diff --git a/services/core/internal/execution/worker.go b/services/core/internal/execution/worker.go index fd9b6a42..e0e57680 100644 --- a/services/core/internal/execution/worker.go +++ b/services/core/internal/execution/worker.go @@ -52,6 +52,9 @@ func StartWorker(ctx context.Context, dispatcher *Dispatcher, owner Owner) (_ *W if dispatcher.Credentials == nil { return nil, errors.New("execution worker requires MCP Credentials") } + if dispatcher.Observer == nil { + return nil, errors.New("execution requires a model configuration observer") + } if owner.Store == nil { return nil, errors.New("execution worker requires the execution Store") } @@ -359,7 +362,7 @@ func (w *Worker) runClaim(ctx context.Context, item store.ExecutionWork) error { var rejection *preparationRejection capacityRejected := errors.As(err, &rejection) && rejection.operation == proto.TypeExecutionPrepare && rejection.code == "preparation_capacity" outcome := json.RawMessage(`{"error_code":"execution_unavailable"}`) - if errors.Is(err, store.ErrModelProviderRequired) { + if errors.Is(err, ErrModelProviderRequired) { outcome = json.RawMessage(`{"error_code":"model_provider_required"}`) } finish, cancel := context.WithTimeout(context.Background(), 10*time.Second) diff --git a/services/core/internal/execution/worker_schedule.go b/services/core/internal/execution/worker_schedule.go index db0ac4c0..80534e7d 100644 --- a/services/core/internal/execution/worker_schedule.go +++ b/services/core/internal/execution/worker_schedule.go @@ -94,7 +94,7 @@ func (w *Worker) runEnvironmentInput(ctx context.Context, item scheduledWork) er if err == nil { return nil } - if errors.Is(err, store.ErrModelProviderRequired) { + if errors.Is(err, ErrModelProviderRequired) { return w.dispatcher.Store.FailEnvironmentInput(ctx, item.TenantID, item.SessionID, item.reservationID, "model_provider_required") } if errors.Is(err, errPreparationFailed) && run.Reservation.State == store.EnvironmentInputPending { diff --git a/services/core/internal/sandbox/providers/configuration_flow_test.go b/services/core/internal/sandbox/providers/configuration_flow_test.go index 7648fc87..97a05cf4 100644 --- a/services/core/internal/sandbox/providers/configuration_flow_test.go +++ b/services/core/internal/sandbox/providers/configuration_flow_test.go @@ -103,7 +103,8 @@ func TestAdditionalConfigurationProviderUsesCommonAPIAndStore(t *testing.T) { // The flow reaches only the store areas and the deployment setup; every // other dependency panics if called. h, err := api.NewHandler(api.Dependencies{ - Engine: "codex", CoreKeys: auth, InstallationBindings: s, Projects: s, ModelProviders: s, Skills: s, + Engine: "codex", CoreKeys: auth, InstallationBindings: s, Projects: s, Skills: s, + ModelProviders: struct{ api.ModelProviders }{}, ModelProvidersReader: struct{ api.ModelProvidersReader }{}, Vaults: struct{ api.Vaults }{}, VaultsReader: struct{ api.VaultsReader }{}, Files: struct{ api.Files }{}, FilesReader: struct{ api.FilesReader }{}, EnvironmentTemplates: struct{ api.EnvironmentTemplates }{}, EnvironmentTemplatesReader: struct{ api.EnvironmentTemplatesReader }{}, diff --git a/services/core/internal/store/agent_execution_defaults_http_test.go b/services/core/internal/store/agent_execution_defaults_http_test.go index d7084c4f..38d97377 100644 --- a/services/core/internal/store/agent_execution_defaults_http_test.go +++ b/services/core/internal/store/agent_execution_defaults_http_test.go @@ -10,6 +10,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" @@ -23,10 +24,10 @@ func TestAgentExecutionDefaultsPublicSnapshotAndPrecedence(t *testing.T) { auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "defaults-test", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}}) deployment := &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://deployment.example/v1", APIKey: "deployment-canary"} defaultsCalls := 0 - handler, err := publicHandler(t, st, db, auth, "codex", storeExecution(t, st), managedSandboxes(t, st), withHarnesses([]string{"codex", "claude_sdk", "mcode"}), modelProviderDefaults(st, func(context.Context, string) (*store.DeploymentModelProviderSnapshot, error) { + handler, err := publicHandler(t, st, db, auth, "codex", storeExecution(t, st), managedSandboxes(t, st), withHarnesses([]string{"codex", "claude_sdk", "mcode"}), modelProviderDefaults(func(context.Context, string) (*modelconfiguration.Snapshot, error) { defaultsCalls++ copy := *deployment - return &store.DeploymentModelProviderSnapshot{Model: "fixture", Provider: ©, Revision: uuid.New()}, nil + return &modelconfiguration.Snapshot{Model: "fixture", Provider: ©, Revision: uuid.New()}, nil })) if err != nil { t.Fatal(err) diff --git a/services/core/internal/store/archive_cancellation_test.go b/services/core/internal/store/archive_cancellation_test.go index a9159227..da12554a 100644 --- a/services/core/internal/store/archive_cancellation_test.go +++ b/services/core/internal/store/archive_cancellation_test.go @@ -11,6 +11,8 @@ import ( "testing" "time" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/e2b" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" @@ -95,7 +97,7 @@ func TestArchiveWaitingCancellationReceipts(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { conn.Close() }) - h := &dispatchHarness{t: t, s: s, db: db, lease: leased.Lease, tenant: project.TenantID, session: session, conn: conn, registry: registry, d: &execution.Dispatcher{Store: writer, Registry: registry}} + h := &dispatchHarness{t: t, s: s, db: db, lease: leased.Lease, tenant: project.TenantID, session: session, conn: conn, registry: registry, d: &execution.Dispatcher{Store: writer, Registry: registry, Observer: modelconfigurationpg.New(pgunit.NewPool(db.pool))}} capabilities := workerEnvironmentCapabilities() capabilities.FunctionTools = proto.CapabilitySupported h.write("", proto.TypeHeartbeat, proto.HeartbeatPayload{SupportedAgentKinds: []proto.SupportedAgentKind{{Kind: "codex", Available: true, Capabilities: capabilities}}}) diff --git a/services/core/internal/store/deployment_model_providers.go b/services/core/internal/store/deployment_model_providers.go deleted file mode 100644 index 32f38518..00000000 --- a/services/core/internal/store/deployment_model_providers.go +++ /dev/null @@ -1,136 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "time" - - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" - "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgtype" -) - -// ErrModelProviderRequired reports a hosted or self-hosted Session that has no -// frozen model provider and therefore cannot run. -var ErrModelProviderRequired = errors.New("the Session has no model provider") - -// DeploymentModelProvider is the safe view of one harness's deployment default. -// The API key is write-only; only its presence is reported. -type DeploymentModelProvider struct { - Harness string - Provider v1.ModelProviderView - Model string - HarnessConfig json.RawMessage - UpdatedAt time.Time - LastUsedAt *time.Time - LastErrorCode *string - LastErrorAt *time.Time -} - -func deploymentModelProvider(harness, protocol, baseURL string, contextWindow, maxOutputTokens int32, model string, harnessConfig json.RawMessage, updatedAt time.Time, lastUsedAt, lastErrorAt pgtype.Timestamptz, lastErrorCode pgtype.Text) DeploymentModelProvider { - var code *string - if lastErrorCode.Valid { - code = &lastErrorCode.String - } - return DeploymentModelProvider{Harness: harness, Model: model, HarnessConfig: harnessConfig, UpdatedAt: updatedAt, LastUsedAt: resetTimestamp(lastUsedAt), LastErrorAt: resetTimestamp(lastErrorAt), LastErrorCode: code, Provider: v1.ModelProviderView{ - Protocol: protocol, BaseURL: baseURL, ContextWindow: contextWindow, MaxOutputTokens: maxOutputTokens, APIKeyConfigured: true, - }} -} - -// ListDeploymentModelProviders reads the configured defaults without decryption. -func (s *Store) ListDeploymentModelProviders(ctx context.Context) ([]DeploymentModelProvider, error) { - rows, err := s.queries.ListDeploymentModelProviders(ctx) - if err != nil { - return nil, err - } - result := make([]DeploymentModelProvider, 0, len(rows)) - for _, row := range rows { - result = append(result, deploymentModelProvider(row.Harness, row.Protocol, row.BaseUrl, row.ContextWindow, row.MaxOutputTokens, row.Model, row.HarnessConfig, row.UpdatedAt.Time, row.LastUsedAt, row.LastErrorAt, row.LastErrorCode)) - } - return result, nil -} - -// SetDeploymentModelProvider replaces a harness's complete default bundle and -// audits the write in the same transaction, without the key. -func (s *Store) SetDeploymentModelProvider(ctx context.Context, harness string, configuration v1.ModelConfigurationInput) (DeploymentModelProvider, error) { - if err := configuration.ValidateHarness(harness); err != nil { - return DeploymentModelProvider{}, fmt.Errorf("%w: %s", ErrInvalidInput, err) - } - provider := configuration.ModelProvider - raw, err := json.Marshal(configuration) - if err != nil { - return DeploymentModelProvider{}, err - } - encrypted, err := s.credentialCipher.SealDeploymentModelProvider(raw, harness) - if err != nil { - return DeploymentModelProvider{}, credentialcrypto.ErrUnavailable - } - var result DeploymentModelProvider - err = s.pooled.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := s.queries.WithTx(tx) - row, err := q.UpsertDeploymentModelProvider(ctx, sqlc.UpsertDeploymentModelProviderParams{ - Harness: harness, Protocol: provider.Protocol, BaseUrl: provider.BaseURL, Model: configuration.Model, HarnessConfig: v1.ResolvedHarnessConfig(configuration.HarnessConfig), - ContextWindow: provider.ContextWindow, MaxOutputTokens: provider.MaxOutputTokens, EncryptedConfig: encrypted, Revision: pgtype.UUID{Bytes: uuid.New(), Valid: true}, - }) - if err != nil { - return err - } - result = deploymentModelProvider(row.Harness, row.Protocol, row.BaseUrl, row.ContextWindow, row.MaxOutputTokens, row.Model, row.HarnessConfig, row.UpdatedAt.Time, row.LastUsedAt, row.LastErrorAt, row.LastErrorCode) - return auditpg.RecordDeploymentMutation(ctx, q, "set", "deployment_model_provider", harness) - }) - return result, err -} - -// DeleteDeploymentModelProvider is idempotent; each successful call is audited. -// Sessions that already froze the default keep their snapshot. -func (s *Store) DeleteDeploymentModelProvider(ctx context.Context, harness string) error { - return s.pooled.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - q := s.queries.WithTx(tx) - if _, err := q.DeleteDeploymentModelProvider(ctx, harness); err != nil { - return err - } - return auditpg.RecordDeploymentMutation(ctx, q, "delete", "deployment_model_provider", harness) - }) -} - -// DeploymentModelProvider decrypts a harness's default for Session creation. It -// returns nil when none is configured and fails closed when the bundle cannot -// be decrypted. -func (s *Store) DeploymentModelProvider(ctx context.Context, harness string) (*DeploymentModelProviderSnapshot, error) { - snapshot, err := s.queries.GetDeploymentModelProviderSecret(ctx, harness) - if errors.Is(err, pgx.ErrNoRows) { - return nil, nil - } - if err != nil { - return nil, err - } - raw, err := s.credentialCipher.OpenDeploymentModelProvider(snapshot.EncryptedConfig, harness) - if err != nil { - return nil, credentialcrypto.ErrUnavailable - } - var configuration v1.ModelConfigurationInput - if json.Unmarshal(raw, &configuration) != nil { - return nil, credentialcrypto.ErrUnavailable - } - // A decrypted but unsupported configuration is not a credential failure. - // Keep its stored snapshot intact so the operator can inspect and replace it. - if err := configuration.ValidateHarness(harness); err != nil { - return nil, err - } - return &DeploymentModelProviderSnapshot{Provider: &configuration.ModelProvider, Model: configuration.Model, HarnessConfig: v1.ResolvedHarnessConfig(configuration.HarnessConfig), Revision: uuid.UUID(snapshot.Revision.Bytes)}, nil -} - -// DeploymentModelProviderSnapshot pairs one decrypted bundle with its private -// revision from the same database read. It never enters a public projection. -type DeploymentModelProviderSnapshot struct { - Provider *v1.ModelProviderInput - Model string - HarnessConfig json.RawMessage - Revision uuid.UUID `json:"-"` -} diff --git a/services/core/internal/store/deployment_model_providers_http_test.go b/services/core/internal/store/deployment_model_providers_http_test.go index 993508dc..b8c3b417 100644 --- a/services/core/internal/store/deployment_model_providers_http_test.go +++ b/services/core/internal/store/deployment_model_providers_http_test.go @@ -15,7 +15,10 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" @@ -42,6 +45,7 @@ func TestDeploymentModelProvidersHTTP(t *testing.T) { if err != nil { t.Fatal(err) } + defaults := deploymentDefaults(t, db) call := func(method, path, key, body string, status int) map[string]json.RawMessage { t.Helper() r := httptest.NewRequest(method, path, strings.NewReader(body)) @@ -153,7 +157,7 @@ func TestDeploymentModelProvidersHTTP(t *testing.T) { if !strings.Contains(string(failure["error"]), "model_provider_protocol_unsupported") { t.Fatal("unsupported native protocol not explained") } - provider, err := st.DeploymentModelProvider(t.Context(), "codex") + provider, err := defaults.Resolve(t.Context(), "codex") if err != nil || provider == nil || provider.Provider.Protocol != "responses" || provider.Provider.APIKey != "deployment-canary" { t.Fatal("rejected protocol changed the stored default", err) } @@ -255,7 +259,7 @@ func TestLegacySessionWithoutProviderCannotStartWork(t *testing.T) { } before := reservations() message := []store.Input{{Kind: "message", Payload: json.RawMessage(`{"text":"start"}`)}} - if _, err := worker.SubmitInputs(t.Context(), h.tenant, legacy.ID, uuid.NewString(), message); !errors.Is(err, store.ErrModelProviderRequired) { + if _, err := worker.SubmitInputs(t.Context(), h.tenant, legacy.ID, uuid.NewString(), message); !errors.Is(err, execution.ErrModelProviderRequired) { t.Fatal("provider-free Session accepted work", err) } if after := reservations(); after != before { @@ -296,9 +300,10 @@ func TestNoneSessionRetryAfterDeploymentDefaultChanges(t *testing.T) { t.Fatal(err) } admin := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "abcd1234", RequestID: "none-retry", TraceID: "none-retry"}) + defaults := deploymentDefaults(t, db) setDefault := func(key string) { t.Helper() - if _, err := st.SetDeploymentModelProvider(admin, "codex", v1.ModelConfigurationInput{ModelProvider: v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://deployment.example/v1", APIKey: key}, Model: "fixture"}); err != nil { + if _, err := defaults.Replace(admin, modelconfiguration.Replacement{Harness: "codex", Configuration: v1.ModelConfigurationInput{ModelProvider: v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://deployment.example/v1", APIKey: key}, Model: "fixture"}}); err != nil { t.Fatal(err) } } @@ -322,7 +327,7 @@ func TestNoneSessionRetryAfterDeploymentDefaultChanges(t *testing.T) { return session.ID } setDefault("first-default-key") - snapshot, err := st.DeploymentModelProvider(t.Context(), "codex") + snapshot, err := defaults.Resolve(t.Context(), "codex") if err != nil { t.Fatal(err) } @@ -335,7 +340,7 @@ func TestNoneSessionRetryAfterDeploymentDefaultChanges(t *testing.T) { if create(key) != original || create(key, `{"model":"m","text":{"verbosity":"medium"}}`) != original { t.Fatal("retry after rotation created another Session") } - if err := st.DeleteDeploymentModelProvider(admin, "codex"); err != nil { + if err := defaults.Delete(admin, "codex"); err != nil { t.Fatal(err) } if create(key) != original { @@ -356,26 +361,27 @@ func TestDeploymentProviderResolutionPairsRevisionDuringReplacement(t *testing.T auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "tuple-test", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}}) admin := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", RequestID: uuid.NewString(), TraceID: uuid.NewString()}) provider := v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://original.example/v1", APIKey: "original-fixture-key"} - if _, err := st.SetDeploymentModelProvider(admin, "codex", v1.ModelConfigurationInput{ModelProvider: provider, Model: "fixture"}); err != nil { + defaults := deploymentDefaults(t, db) + if _, err := defaults.Replace(admin, modelconfiguration.Replacement{Harness: "codex", Configuration: v1.ModelConfigurationInput{ModelProvider: provider, Model: "fixture"}}); err != nil { t.Fatal(err) } - original, err := st.DeploymentModelProvider(t.Context(), "codex") + original, err := defaults.Resolve(t.Context(), "codex") if err != nil { t.Fatal(err) } // Replacement after the atomic tuple read but before API resolution returns // must never pair the old ciphertext with the replacement's revision. - resolver := func(ctx context.Context, harness string) (*store.DeploymentModelProviderSnapshot, error) { - snapshot, err := st.DeploymentModelProvider(ctx, harness) + resolver := func(ctx context.Context, harness string) (*modelconfiguration.Snapshot, error) { + snapshot, err := defaults.Resolve(ctx, harness) if err != nil { return nil, err } replacement := provider replacement.APIKey = "replacement-fixture-key" - _, err = st.SetDeploymentModelProvider(admin, harness, v1.ModelConfigurationInput{ModelProvider: replacement, Model: "fixture"}) + _, err = defaults.Replace(admin, modelconfiguration.Replacement{Harness: harness, Configuration: v1.ModelConfigurationInput{ModelProvider: replacement, Model: "fixture"}}) return snapshot, err } - handler, err := publicHandler(t, st, db, auth, "codex", storeExecution(t, st), modelProviderDefaults(st, resolver)) + handler, err := publicHandler(t, st, db, auth, "codex", storeExecution(t, st), modelProviderDefaults(resolver)) if err != nil { t.Fatal(err) } @@ -422,3 +428,14 @@ func TestDeploymentProviderResolutionFixtureIsolation(t *testing.T) { t.Fatal("deployment provider fixture changed the shared test database") } } + +// deploymentDefaults serves deployment default model configurations from db, +// as the Core routes do. +func deploymentDefaults(t *testing.T, db fixtureDB) *modelconfiguration.Service { + t.Helper() + service, err := modelconfiguration.NewService(modelconfigurationpg.New(pgunit.NewPool(db.pool)), db.cipher) + if err != nil { + t.Fatal(err) + } + return service +} diff --git a/services/core/internal/store/deployment_model_providers_test.go b/services/core/internal/store/deployment_model_providers_test.go deleted file mode 100644 index 3251c7eb..00000000 --- a/services/core/internal/store/deployment_model_providers_test.go +++ /dev/null @@ -1,131 +0,0 @@ -package store - -import ( - "bytes" - "encoding/json" - "errors" - "testing" - "time" - - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" - "github.com/google/uuid" -) - -func TestDeploymentModelProviderEncryptedAuditedAndReplaced(t *testing.T) { - _, pool := testStore(t) - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{47}, 32)) - if err != nil { - t.Fatal(err) - } - s := NewWithCredentialCipher(pool, cipher) - // The table is deployment-wide; start from no defaults. - if _, err := pool.Exec(t.Context(), "DELETE FROM deployment_model_providers"); err != nil { - t.Fatal(err) - } - started := time.Now() - ctx := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "abcd1234", ActorLabel: "console", RequestID: "request-set", TraceID: "trace-set"}) - provider := v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://deployment.example/v1", APIKey: "deployment-key-canary"} - if _, err := s.SetDeploymentModelProvider(ctx, "codex", v1.ModelConfigurationInput{ModelProvider: v1.ModelProviderInput{Protocol: "unknown", BaseURL: provider.BaseURL, APIKey: "k"}, Model: "fixture"}); !errors.Is(err, ErrInvalidInput) { - t.Fatal("unknown upstream protocol accepted", err) - } - if _, err := New(pool).SetDeploymentModelProvider(ctx, "codex", v1.ModelConfigurationInput{ModelProvider: provider, Model: "fixture"}); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("key stored without encryption", err) - } - saved, err := s.SetDeploymentModelProvider(ctx, "codex", v1.ModelConfigurationInput{ModelProvider: provider, Model: "fixture"}) - if err != nil || saved.Harness != "codex" || saved.Provider != *provider.SafeView() || saved.UpdatedAt.IsZero() { - t.Fatal("default not saved", saved, err) - } - var row []byte - if err := pool.QueryRow(ctx, "SELECT to_jsonb(p)::text FROM deployment_model_providers p WHERE harness='codex'").Scan(&row); err != nil || bytes.Contains(row, []byte("deployment-key-canary")) { - t.Fatal("key stored in plaintext", err) - } - listed, err := New(pool).ListDeploymentModelProviders(ctx) - if err != nil || len(listed) != 1 || listed[0].Provider != *provider.SafeView() { - t.Fatal("reader without the key could not list safe fields", listed, err) - } - if got, err := s.DeploymentModelProvider(ctx, "codex"); err != nil || got == nil || *got.Provider != provider { - t.Fatal("default did not decrypt", err) - } - if got, err := s.DeploymentModelProvider(ctx, "mcode"); err != nil || got != nil { - t.Fatal("unset harness returned a default", err) - } - other, _ := credentialcrypto.New(bytes.Repeat([]byte{48}, 32)) - if _, err := NewWithCredentialCipher(pool, other).DeploymentModelProvider(ctx, "codex"); !errors.Is(err, credentialcrypto.ErrUnavailable) { - t.Fatal("wrong encryption key did not fail closed", err) - } - replacement := v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://replacement.example/v1", APIKey: "replacement-key"} - if _, err := s.SetDeploymentModelProvider(ctx, "codex", v1.ModelConfigurationInput{ModelProvider: replacement, Model: "fixture"}); err != nil { - t.Fatal(err) - } - if got, err := s.DeploymentModelProvider(ctx, "codex"); err != nil || *got.Provider != replacement { - t.Fatal("replacement not complete", err) - } - deleteCtx := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "abcd1234", RequestID: "request-delete", TraceID: "trace-delete"}) - for range 2 { - if err := s.DeleteDeploymentModelProvider(deleteCtx, "codex"); err != nil { - t.Fatal("delete is not idempotent", err) - } - } - if got, err := s.DeploymentModelProvider(ctx, "codex"); err != nil || got != nil { - t.Fatal("deleted default remained", err) - } - page, err := auditpg.New(pgunit.NewPool(pool)).ListAdminAudit(ctx, adminaudit.Filter{ResourceType: "deployment_model_provider", CreatedAfter: &started}) - if err != nil || len(page.Data) != 4 { - t.Fatal("deployment writes not audited", len(page.Data), err) - } - for _, entry := range page.Data { - if entry.ProjectID != nil || entry.ResourceID != "codex" || (entry.Action != "set" && entry.Action != "delete") { - t.Fatal("unexpected deployment audit entry", entry) - } - } - if scoped, err := auditpg.New(pgunit.NewPool(pool)).ListAdminAudit(ctx, adminaudit.Filter{ProjectID: "00000000-0000-4000-8000-000000000001"}); err != nil || len(scoped.Data) != 0 { - t.Fatal("Project filter returned deployment entries", err) - } - if _, err := s.SetDeploymentModelProvider(adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "abcd1234", RequestID: "r", TraceID: "t", ProjectID: "00000000-0000-4000-8000-000000000001"}), "codex", v1.ModelConfigurationInput{ModelProvider: provider, Model: "fixture"}); !errors.Is(err, adminaudit.ErrInvalidSource) { - t.Fatal("Project-scoped audit source accepted for a deployment write", err) - } - if got, _ := s.DeploymentModelProvider(ctx, "codex"); got != nil { - t.Fatal("failed audit left the write committed") - } -} - -// A provider key enters the retry hashes only through a fingerprint keyed by the -// credential key: the same request under two credential keys hashes differently, -// and an intent whose provider cannot be read is rejected, not hashed raw. -func TestProviderKeyEntersRetryHashesOnlyAsKeyedFingerprint(t *testing.T) { - _, pool := testStore(t) - provider := &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://example.com/v1", APIKey: "hash-key-canary"} - intent, err := json.Marshal(map[string]any{"agent": map[string]string{"model": "m"}, "environment": map[string]string{"type": "openai_hosted"}, "x_agents_core": map[string]any{"model_provider": provider}}) - if err != nil { - t.Fatal(err) - } - input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "same-request", ModelProvider: provider, ModelProviderSource: v1.ModelProviderSourceSession, - Configuration: []byte(`{"agent":{"model":"m"},"environment":{"type":"openai_hosted"}}`), CreationRequest: intent} - var hashes [2][2]string - for index, seed := range []byte{71, 72} { - cipher, err := credentialcrypto.New(bytes.Repeat([]byte{seed}, 32)) - if err != nil { - t.Fatal(err) - } - session, err := NewWithCredentialCipher(pool, cipher).CreateSession(t.Context(), uuid.NewString(), input) - if err != nil { - t.Fatal(err) - } - if err := pool.QueryRow(t.Context(), "SELECT request_hash, creation_request_hash FROM sessions WHERE id=$1", session.ID).Scan(&hashes[index][0], &hashes[index][1]); err != nil { - t.Fatal(err) - } - } - if hashes[0][0] == hashes[1][0] || hashes[0][1] == hashes[1][1] { - t.Fatal("retry hashes do not depend on the credential key", hashes) - } - cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{71}, 32)) - unreadable := input - unreadable.IdempotencyKey, unreadable.CreationRequest = "unreadable", json.RawMessage(`{"x_agents_core":{"model_provider":"hash-key-canary"}}`) - if _, err := NewWithCredentialCipher(pool, cipher).CreateSession(t.Context(), uuid.NewString(), unreadable); !errors.Is(err, ErrInvalidInput) { - t.Fatal("unreadable provider intent was hashed", err) - } -} diff --git a/services/core/internal/store/deployment_provider_observation_lock_test.go b/services/core/internal/store/deployment_provider_observation_lock_test.go deleted file mode 100644 index 39d3ef4b..00000000 --- a/services/core/internal/store/deployment_provider_observation_lock_test.go +++ /dev/null @@ -1,120 +0,0 @@ -package store - -import ( - "context" - "testing" - "time" - - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" -) - -func TestDeploymentObservationClockSampleFollowsRowLock(t *testing.T) { - f := newProviderObservationFixture(t) - turn := f.terminal(t, sessions.TurnCompleted, "", "") - tx, err := f.pool.Begin(t.Context()) - if err != nil { - t.Fatal(err) - } - defer tx.Rollback(context.Background()) - if _, err = tx.Exec(t.Context(), "SELECT harness FROM deployment_model_providers FOR UPDATE"); err != nil { - t.Fatal(err) - } - result := make(chan error, 1) - go func() { - _, err := f.s.ObserveDeploymentModelProvider(t.Context(), f.tenant, f.session.ID, turn.ID) - result <- err - }() - deadline := time.Now().Add(500 * time.Millisecond) - waiting := false - for time.Now().Before(deadline) { - if err = f.pool.QueryRow(t.Context(), "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND query LIKE '%ObserveDeploymentModelProvider%')").Scan(&waiting); err != nil { - t.Fatal(err) - } - if waiting { - break - } - time.Sleep(time.Millisecond) - } - if !waiting { - t.Fatal("observation did not acquire a lock wait") - } - var beforeUnlock time.Time - if err = tx.QueryRow(t.Context(), "SELECT clock_timestamp()").Scan(&beforeUnlock); err != nil { - t.Fatal(err) - } - if err = tx.Commit(t.Context()); err != nil { - t.Fatal(err) - } - if err = <-result; err != nil { - t.Fatal(err) - } - rows, err := f.s.ListDeploymentModelProviders(t.Context()) - if err != nil { - t.Fatal(err) - } - if rows[0].LastUsedAt == nil || rows[0].LastUsedAt.Before(beforeUnlock) { - t.Fatal("receipt clock sampled before row lock") - } -} - -func TestDeploymentObservationWinningLockIsClearedByPUT(t *testing.T) { - f := newProviderObservationFixture(t) - turn := f.terminal(t, sessions.TurnCompleted, "", "") - tx, err := f.pool.Begin(t.Context()) - if err != nil { - t.Fatal(err) - } - defer tx.Rollback(context.Background()) - lookup, _ := turnLookup(f.tenant, f.session.ID, turn.ID) - n, err := sqlc.New(tx).ObserveDeploymentModelProvider(t.Context(), sqlc.ObserveDeploymentModelProviderParams{TenantID: lookup.TenantID, SessionID: lookup.SessionID, TurnID: lookup.ID}) - if err != nil || n != 1 { - t.Fatal("observation did not win row lock", n, err) - } - result := make(chan error, 1) - go func() { - _, err := f.s.SetDeploymentModelProvider(observationAdmin(t), "codex", v1.ModelConfigurationInput{ModelProvider: *f.input.ModelProvider, Model: "fixture"}) - result <- err - }() - deadline := time.Now().Add(time.Second) - waiting := false - for time.Now().Before(deadline) { - if err = f.pool.QueryRow(t.Context(), "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND query LIKE '%UpsertDeploymentModelProvider%')").Scan(&waiting); err != nil { - t.Fatal(err) - } - if waiting { - break - } - time.Sleep(time.Millisecond) - } - if !waiting { - t.Fatal("PUT did not wait for observation") - } - if err = tx.Commit(t.Context()); err != nil { - t.Fatal(err) - } - if err = <-result; err != nil { - t.Fatal(err) - } - rows, err := f.s.ListDeploymentModelProviders(t.Context()) - if err != nil { - t.Fatal(err) - } - if rows[0].LastUsedAt != nil || rows[0].LastErrorCode != nil || rows[0].LastErrorAt != nil { - t.Fatal("PUT retained old revision observation") - } - f.observe(t, turn, 0) -} - -func TestDeploymentObservationWaitingAndNonRootTurnCannotWrite(t *testing.T) { - f := newProviderObservationFixture(t) - receipt := submitMessage(t, f.s, f.tenant, f.session.ID, "waiting") - f.observe(t, sessions.Turn{ID: receipt.TurnID}, 0) - transition(t, f.s, f.tenant, f.session.ID, receipt.TurnID, sessions.TurnQueued, sessions.TurnInProgress) - transition(t, f.s, f.tenant, f.session.ID, receipt.TurnID, sessions.TurnInProgress, sessions.TurnWaiting) - f.observe(t, sessions.Turn{ID: receipt.TurnID}, 0) - // Child Turn identifiers live outside turns. An absent root identifier is - // rejected by the same SQL ownership join, without a child-history lookup. - f.observe(t, sessions.Turn{ID: "ffffffff-ffff-4fff-bfff-ffffffffffff"}, 0) -} diff --git a/services/core/internal/store/deployment_provider_observations.go b/services/core/internal/store/deployment_provider_observations.go deleted file mode 100644 index 0aa88138..00000000 --- a/services/core/internal/store/deployment_provider_observations.go +++ /dev/null @@ -1,48 +0,0 @@ -package store - -import ( - "context" - "strconv" - "time" - - "github.com/jackc/pgx/v5" - - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" -) - -// ObserveDeploymentModelProvider attempts one metadata UPDATE through the pool, -// never the leased execution connection. SQL verifies the committed root outcome -// and exact frozen revision without loading credentials. The metadata transaction -// installs server-side timeouts: client cancellation alone can leave PostgreSQL -// executing briefly after pgx has returned a deadline error. -func (s *Store) ObserveDeploymentModelProvider(ctx context.Context, tenantID, sessionID, turnID string) (int64, error) { - lookup, err := turnLookup(tenantID, sessionID, turnID) - if err != nil { - return 0, err - } - ctx, cancel := context.WithTimeout(ctx, time.Second) - defer cancel() - var count int64 - err = s.pooled.Transaction(ctx, func(ctx context.Context, tx pgx.Tx) error { - deadline, _ := ctx.Deadline() - // Leave a small part of the overall budget for returning the server error - // and releasing this metadata-only transaction before the client deadline. - timeout := time.Until(deadline).Milliseconds() - 25 - if timeout <= 0 { - return context.DeadlineExceeded - } - setting := strconv.FormatInt(timeout, 10) + "ms" - if _, err := tx.Exec(ctx, "SELECT set_config('statement_timeout', $1, true), set_config('lock_timeout', $1, true)", setting); err != nil { - return err - } - var err error - count, err = sqlc.New(tx).ObserveDeploymentModelProvider(ctx, sqlc.ObserveDeploymentModelProviderParams{ - TenantID: lookup.TenantID, SessionID: lookup.SessionID, TurnID: lookup.ID, - }) - return err - }) - if err != nil { - return 0, err - } - return count, nil -} diff --git a/services/core/internal/store/deployment_provider_observations_test.go b/services/core/internal/store/deployment_provider_observations_test.go deleted file mode 100644 index 4cd42f0b..00000000 --- a/services/core/internal/store/deployment_provider_observations_test.go +++ /dev/null @@ -1,425 +0,0 @@ -package store - -import ( - "context" - "encoding/json" - "fmt" - "os" - "strings" - "sync" - "testing" - "time" - - v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/db/sqlc" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgconn" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" -) - -type providerObservationFixture struct { - s *Store - pool *pgxpool.Pool - tenant string - input CreateSessionInput - session Session -} - -func observationAdmin(t *testing.T) context.Context { - return adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", RequestID: uuid.NewString(), TraceID: uuid.NewString()}) -} -func newProviderObservationFixture(t *testing.T) providerObservationFixture { - t.Helper() - s, pool := newManagedTestStore(t) - provider := FixtureModelProvider("codex") - if _, err := s.SetDeploymentModelProvider(observationAdmin(t), "codex", v1.ModelConfigurationInput{ModelProvider: *provider, Model: "fixture"}); err != nil { - t.Fatal(err) - } - snapshot, err := s.DeploymentModelProvider(t.Context(), "codex") - if err != nil { - t.Fatal(err) - } - input := executionProjectionInput("deployment") - input.Configuration = json.RawMessage(`{"agent":{"model":"frozen-model"},"environment":{"type":"none"}}`) - input.ModelProvider, input.ModelProviderSource, input.DeploymentProviderRevision = snapshot.Provider, "deployment", snapshot.Revision - input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: "deployment"} - tenant := uuid.NewString() - session, err := s.CreateSession(t.Context(), tenant, input) - if err != nil { - t.Fatal(err) - } - return providerObservationFixture{s, pool, tenant, input, session} -} -func (f providerObservationFixture) terminal(t *testing.T, status, coreCode, nativeCode string) sessions.Turn { - t.Helper() - receipt := submitMessage(t, f.s, f.tenant, f.session.ID, uuid.NewString()) - transition(t, f.s, f.tenant, f.session.ID, receipt.TurnID, sessions.TurnQueued, sessions.TurnInProgress) - outcome, _ := json.Marshal(map[string]string{"error_code": coreCode, "engine_error_code": nativeCode}) - turn, err := f.s.CompleteExecution(t.Context(), f.tenant, f.session.ID, receipt.TurnID, status, outcome, "", receipt.Sequence) - if err != nil { - t.Fatal(err) - } - return turn -} -func (f providerObservationFixture) observe(t *testing.T, turn sessions.Turn, want int64) { - t.Helper() - n, err := f.s.ObserveDeploymentModelProvider(t.Context(), f.tenant, f.session.ID, turn.ID) - if err != nil || n != want { - t.Fatalf("observation writes=%d want=%d err=%v", n, want, err) - } -} -func (f providerObservationFixture) revision(t *testing.T) uuid.UUID { - t.Helper() - var rev uuid.UUID - if err := f.pool.QueryRow(t.Context(), "SELECT deployment_provider_revision FROM session_execution_configuration WHERE session_id=$1", f.session.ID).Scan(&rev); err != nil { - t.Fatal(err) - } - return rev -} -func TestDeploymentObservationSnapshotReplacementAndRetry(t *testing.T) { - f := newProviderObservationFixture(t) - original := f.revision(t) - // Resolution and insertion have independent boundaries: retain the tuple while - // a PUT replaces the default, then create with that exact earlier tuple. - before, err := f.s.DeploymentModelProvider(t.Context(), "codex") - if err != nil { - t.Fatal(err) - } - replacement := *before.Provider - replacement.APIKey = "different-fixture-key" - if _, err = f.s.SetDeploymentModelProvider(observationAdmin(t), "codex", v1.ModelConfigurationInput{ModelProvider: replacement, Model: "fixture"}); err != nil { - t.Fatal(err) - } - if _, err = f.pool.Exec(t.Context(), "UPDATE deployment_model_providers SET updated_at='2000-01-01'"); err != nil { - t.Fatal(err) - } - input := f.input - input.IdempotencyKey = uuid.NewString() - stale, err := f.s.CreateSession(t.Context(), f.tenant, input) - if err != nil { - t.Fatal(err) - } - staleFixture := f - staleFixture.session = stale - if staleFixture.revision(t) != original { - t.Fatal("tuple revision changed during insertion") - } - frozen, err := f.s.SessionModelExecution(t.Context(), f.tenant, stale.ID) - if err != nil || frozen == nil || *frozen != *before.Provider { - t.Fatal("frozen tuple bundle changed", err) - } - staleFixture.observe(t, staleFixture.terminal(t, sessions.TurnFailed, "engine_failed", "authentication_error"), 0) - current, _ := f.s.DeploymentModelProvider(t.Context(), "codex") - retry := f.input - retry.ModelProvider = current.Provider - retry.DeploymentProviderRevision = current.Revision - replay, err := f.s.CreateSession(t.Context(), f.tenant, retry) - if err != nil || replay.ID != f.session.ID || f.revision(t) != original { - t.Fatal("retry replaced frozen metadata", err) - } - if err = f.s.DeleteDeploymentModelProvider(observationAdmin(t), "codex"); err != nil { - t.Fatal(err) - } - if _, err = f.s.SetDeploymentModelProvider(observationAdmin(t), "codex", v1.ModelConfigurationInput{ModelProvider: *before.Provider, Model: "fixture"}); err != nil { - t.Fatal(err) - } - recreated, _ := f.s.DeploymentModelProvider(t.Context(), "codex") - if recreated.Revision == original || recreated.Revision == current.Revision { - t.Fatal("revision reused") - } - frozenProvider, err := f.s.SessionModelExecution(t.Context(), f.tenant, f.session.ID) - if err != nil || frozenProvider == nil || *frozenProvider != *f.input.ModelProvider { - t.Fatal("migration/replacement changed Session bundle", err) - } - f.observe(t, f.terminal(t, sessions.TurnCompleted, "", ""), 0) -} -func TestDeploymentObservationEligibilityAndReset(t *testing.T) { - allowed := []string{"authentication_error", "connection_failed", "rate_limit_exceeded", "usage_limit_exceeded", "server_overloaded", "server_error", "resource_not_found", "request_timeout", "invalid_request"} - f := newProviderObservationFixture(t) - for _, code := range allowed { - if _, err := f.pool.Exec(t.Context(), "UPDATE deployment_model_providers SET last_error_at=NULL,last_error_code=NULL,recovery_pending=false"); err != nil { - t.Fatal(err) - } - f.observe(t, f.terminal(t, sessions.TurnFailed, "engine_failed", code), 1) - } - for _, tc := range []struct{ status, core, code string }{{sessions.TurnFailed, "engine_failed", "context_length_exceeded"}, {sessions.TurnFailed, "engine_failed", "cyber_policy"}, {sessions.TurnFailed, "engine_failed", "harness_error"}, {sessions.TurnFailed, "engine_failed", "untrusted raw text"}, {sessions.TurnFailed, "input_not_applied", "authentication_error"}, {sessions.TurnFailed, "device_disconnected", "authentication_error"}, {sessions.TurnCancelled, "engine_failed", "authentication_error"}} { - f.observe(t, f.terminal(t, tc.status, tc.core, tc.code), 0) - } - success := f.terminal(t, sessions.TurnCompleted, "", "") - f.observe(t, success, 1) - n, err := f.s.ObserveDeploymentModelProvider(t.Context(), uuid.NewString(), f.session.ID, success.ID) - if n != 0 || err != nil { - t.Fatal("foreign tenant observed", err) - } - view, _ := f.s.ListDeploymentModelProviders(t.Context()) - if view[0].LastUsedAt == nil || view[0].LastErrorAt == nil { - t.Fatal("safe observations missing") - } - old := f.revision(t) - reset, err := f.s.SetDeploymentModelProvider(observationAdmin(t), "codex", v1.ModelConfigurationInput{ModelProvider: *f.input.ModelProvider, Model: "fixture"}) - if err != nil { - t.Fatal(err) - } - snapshot, _ := f.s.DeploymentModelProvider(t.Context(), "codex") - if snapshot.Revision == old || reset.LastUsedAt != nil || reset.LastErrorAt != nil || reset.LastErrorCode != nil { - t.Fatal("identical PUT did not reset") - } - f.observe(t, success, 0) -} -func TestDeploymentObservationSourceAndHistoricalExclusion(t *testing.T) { - f := newProviderObservationFixture(t) - for _, source := range []string{"session", "agent", "unknown", "deployment"} { - input := f.input - input.IdempotencyKey = uuid.NewString() - projection := *input.ExecutionConfiguration - input.ExecutionConfiguration = &projection - input.ModelProviderSource = source - // Historical metadata may name deployment but has no frozen private UUID. - if source == "deployment" { - input.DeploymentProviderRevision = uuid.Nil - } else { - input.Configuration = json.RawMessage(`{"agent":{"model":"frozen-model"},"environment":{"type":"openai_hosted"}}`) - if source == "unknown" { - input.ModelProviderSource = "session" - } - } - projection.ModelProvider = v1.ExecutionProviderSelection{Source: source, Status: "available", Configuration: input.ModelProvider.SafeView()} - session, err := f.s.CreateSession(t.Context(), f.tenant, input) - if err != nil { - t.Fatal(source, err) - } - var revision pgtype.UUID - if err = f.pool.QueryRow(t.Context(), "SELECT deployment_provider_revision FROM session_execution_configuration WHERE session_id=$1", session.ID).Scan(&revision); err != nil || revision.Valid { - t.Fatal("non-deployment/historical revision persisted", source, err) - } - // Use a committed fixture outcome directly: hosted dispatch is a separate gate. - id := uuid.NewString() - if _, err = f.pool.Exec(t.Context(), "INSERT INTO turns(id,session_id,status,outcome,completed_at) VALUES($1,$2,'completed','{}',clock_timestamp())", id, session.ID); err != nil { - t.Fatal(err) - } - n, err := f.s.ObserveDeploymentModelProvider(t.Context(), f.tenant, session.ID, id) - if err != nil || n != 0 { - t.Fatal("ineligible source observed", source, err) - } - } -} -func TestDeploymentObservationConcurrentThrottleAndRecovery(t *testing.T) { - f := newProviderObservationFixture(t) - successes := []sessions.Turn{} - failures := []sessions.Turn{} - for range 8 { - successes = append(successes, f.terminal(t, sessions.TurnCompleted, "", "")) - } - for i := range 8 { - code := []string{"authentication_error", "rate_limit_exceeded"}[i%2] - failures = append(failures, f.terminal(t, sessions.TurnFailed, "engine_failed", code)) - } - concurrent := func(turns []sessions.Turn, want int64) { - t.Helper() - var wg sync.WaitGroup - counts := make(chan int64, len(turns)) - errs := make(chan error, len(turns)) - for _, turn := range turns { - wg.Add(1) - go func() { - defer wg.Done() - n, err := f.s.ObserveDeploymentModelProvider(t.Context(), f.tenant, f.session.ID, turn.ID) - counts <- n - errs <- err - }() - } - wg.Wait() - close(counts) - close(errs) - var n int64 - for v := range counts { - n += v - } - for err := range errs { - if err != nil { - t.Fatal(err) - } - } - if n != want { - t.Fatalf("concurrent writes=%d want=%d", n, want) - } - } - concurrent(successes, 1) - concurrent(failures, 1) - concurrent(successes, 1) - concurrent(successes, 0) - concurrent(failures, 0) -} - -// Only the DB clock sample is controlled; execute the actual generated query, -// retaining PostgreSQL locking, predicates, constraints and write count. -type observationClockDB struct { - *pgxpool.Pool - at time.Time - query string -} - -func (db *observationClockDB) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) { - db.query = sql - sql = strings.ReplaceAll(sql, "clock_timestamp()", "'"+db.at.Format(time.RFC3339Nano)+"'::timestamptz") - return db.Pool.Exec(ctx, sql, args...) -} -func TestDeploymentObservationClockBoundariesRollbackAndPlan(t *testing.T) { - f := newProviderObservationFixture(t) - success := f.terminal(t, sessions.TurnCompleted, "", "") - failure := f.terminal(t, sessions.TurnFailed, "engine_failed", "authentication_error") - base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) - db := &observationClockDB{Pool: f.pool} - q := sqlc.New(db) - observe := func(turn sessions.Turn, offset time.Duration, want int64) { - t.Helper() - db.at = base.Add(offset) - lookup, _ := turnLookup(f.tenant, f.session.ID, turn.ID) - n, err := q.ObserveDeploymentModelProvider(t.Context(), sqlc.ObserveDeploymentModelProviderParams{TenantID: lookup.TenantID, SessionID: lookup.SessionID, TurnID: lookup.ID}) - if err != nil || n != want { - t.Fatalf("at %s got %d want %d: %v", offset, n, want, err) - } - } - observe(success, 0, 1) - observe(failure, time.Second, 1) - observe(success, 2*time.Second, 1) - observe(failure, 30*time.Second, 0) - observe(failure, 31*time.Second, 1) - observe(success, 31*time.Second, 1) - observe(success, 31*time.Second, 0) - // A new accepted error can recover once even if the clock moves backwards. - observe(failure, 61*time.Second, 1) - observe(success, -time.Second, 1) - observe(success, -time.Second, 0) - observe(success, 29*time.Second, 1) - observe(success, 59*time.Second-time.Microsecond, 0) - observe(success, 59*time.Second, 1) - var actual time.Time - if err := f.pool.QueryRow(t.Context(), "SELECT last_used_at FROM deployment_model_providers").Scan(&actual); err != nil || !actual.Equal(base.Add(59*time.Second)) { - t.Fatal("timestamp was clamped", err) - } - lookup, _ := turnLookup(f.tenant, f.session.ID, success.ID) - // EXPLAIN uses the normal production planner and does not execute the update. - // Tenant/Session and root Turn lookups must constrain the default lookup. - tx, err := f.pool.Begin(t.Context()) - if err != nil { - t.Fatal(err) - } - defer tx.Rollback(context.Background()) - rows, err := tx.Query(t.Context(), "EXPLAIN "+db.query, lookup.TenantID, lookup.SessionID, lookup.ID) - if err != nil { - t.Fatal(err) - } - var plan strings.Builder - for rows.Next() { - var line string - _ = rows.Scan(&line) - plan.WriteString(line) - } - rows.Close() - if rows.Err() != nil || (!strings.Contains(plan.String(), "deployment_model_providers_pkey") || (!strings.Contains(plan.String(), "turns_pkey") && !strings.Contains(plan.String(), "turns_session_id_id_key"))) { - t.Fatal("bounded lookup indexes absent", rows.Err(), plan.String()) - } -} -func TestDeploymentObservationReplacementLockRecheckAndTimeout(t *testing.T) { - for _, remove := range []bool{false, true} { - t.Run(fmt.Sprint("delete=", remove), func(t *testing.T) { - f := newProviderObservationFixture(t) - turn := f.terminal(t, sessions.TurnCompleted, "", "") - tx, err := f.pool.Begin(t.Context()) - if err != nil { - t.Fatal(err) - } - defer tx.Rollback(context.Background()) - if _, err = tx.Exec(t.Context(), "SELECT harness FROM deployment_model_providers FOR UPDATE"); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithTimeout(t.Context(), 80*time.Millisecond) - defer cancel() - if n, err := f.s.ObserveDeploymentModelProvider(ctx, f.tenant, f.session.ID, turn.ID); err == nil || n != 0 { - t.Fatal("locked observation did not time out") - } - done := make(chan int64, 1) - errs := make(chan error, 1) - go func() { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - n, err := f.s.ObserveDeploymentModelProvider(ctx, f.tenant, f.session.ID, turn.ID) - done <- n - errs <- err - }() - // Verify it actually reached a PostgreSQL lock wait, rather than racing on - // goroutine scheduling, before replacing/deleting the selected revision. - deadline := time.Now().Add(time.Second) - waiting := false - for time.Now().Before(deadline) { - if err = f.pool.QueryRow(t.Context(), "SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock' AND query LIKE '%ObserveDeploymentModelProvider%')").Scan(&waiting); err != nil { - t.Fatal(err) - } - if waiting { - break - } - time.Sleep(time.Millisecond) - } - if !waiting { - t.Fatal("observation never waited on row lock") - } - if remove { - _, err = tx.Exec(t.Context(), "DELETE FROM deployment_model_providers") - } else { - _, err = tx.Exec(t.Context(), "UPDATE deployment_model_providers SET revision=$1", uuid.New()) - } - if err != nil { - t.Fatal(err) - } - if err = tx.Commit(t.Context()); err != nil { - t.Fatal(err) - } - if n := <-done; n != 0 { - t.Fatal("stale revision updated replacement", n) - } - if err = <-errs; err != nil { - t.Fatal(err) - } - }) - } -} -func TestDeploymentObservationMigrationRoundTrip(t *testing.T) { - f := newProviderObservationFixture(t) - old := f.revision(t) - var secret []byte - if err := f.pool.QueryRow(t.Context(), "SELECT encrypted_config FROM deployment_model_providers").Scan(&secret); err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile("../../migrations/000084_deployment_provider_observations.sql") - if err != nil { - t.Fatal(err) - } - parts := strings.Split(string(raw), "-- +goose Down") - if _, err = f.pool.Exec(t.Context(), parts[1]); err != nil { - t.Fatal(err) - } - if _, err = f.pool.Exec(t.Context(), parts[0]); err != nil { - t.Fatal(err) - } - var revision uuid.UUID - var frozen pgtype.UUID - var preserved []byte - if err = f.pool.QueryRow(t.Context(), "SELECT revision,encrypted_config FROM deployment_model_providers").Scan(&revision, &preserved); err != nil { - t.Fatal(err) - } - if err = f.pool.QueryRow(t.Context(), "SELECT deployment_provider_revision FROM session_execution_configuration WHERE session_id=$1", f.session.ID).Scan(&frozen); err != nil { - t.Fatal(err) - } - if revision == old || frozen.Valid || string(secret) != string(preserved) { - t.Fatal("migration recreated historical identity or changed ciphertext") - } - frozenProvider, err := f.s.SessionModelExecution(t.Context(), f.tenant, f.session.ID) - if err != nil || frozenProvider == nil || *frozenProvider != *f.input.ModelProvider { - t.Fatal("migration/replacement changed Session bundle", err) - } - f.observe(t, f.terminal(t, sessions.TurnCompleted, "", ""), 0) -} diff --git a/services/core/internal/store/dispatch_test.go b/services/core/internal/store/dispatch_test.go index 50cc73f7..ca0dc023 100644 --- a/services/core/internal/store/dispatch_test.go +++ b/services/core/internal/store/dispatch_test.go @@ -14,6 +14,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtime" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimegateway" @@ -112,7 +114,7 @@ func newDispatchHarnessForSession(t *testing.T, configuration []byte, local bool } time.Sleep(10 * time.Millisecond) } - h.d = &execution.Dispatcher{Store: s, Registry: h.registry} + h.d = &execution.Dispatcher{Store: s, Registry: h.registry, Observer: modelconfigurationpg.New(pgunit.NewPool(db.pool))} return h } diff --git a/services/core/internal/store/fixture_db_test.go b/services/core/internal/store/fixture_db_test.go index 25636872..81ad3077 100644 --- a/services/core/internal/store/fixture_db_test.go +++ b/services/core/internal/store/fixture_db_test.go @@ -8,6 +8,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -40,7 +41,8 @@ func newManagedTestStoreDB(t *testing.T) (*store.Store, fixtureDB) { // startWorker starts the execution Worker as cmd/server does: it acquires the // execution lease on db and hands it, with the execution writer built on it, to // the Worker, which closes it when Run exits. The Worker opens MCP bearer tokens -// through the vaults service on db. +// through the vaults service on db and records model configuration observations +// through the model configuration adapter on db. func startWorker(t testing.TB, ctx context.Context, db fixtureDB, dispatcher *execution.Dispatcher) *execution.Worker { t.Helper() worker, err := startWorkerErr(ctx, db, dispatcher) @@ -62,6 +64,7 @@ func startWorkerErr(ctx context.Context, db fixtureDB, dispatcher *execution.Dis } owned := *dispatcher owned.Credentials = credentials + owned.Observer = modelconfigurationpg.New(pgunit.NewPool(db.pool)) return execution.StartWorker(ctx, &owned, execution.Owner{Lease: lease, Store: store.NewExecution(dispatcher.Store, lease)}) } diff --git a/services/core/internal/store/function_images_native_test.go b/services/core/internal/store/function_images_native_test.go index 3fb73082..8551062d 100644 --- a/services/core/internal/store/function_images_native_test.go +++ b/services/core/internal/store/function_images_native_test.go @@ -47,7 +47,7 @@ func TestNativeFunctionImagePublicExecution(t *testing.T) { {OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}, {OrganizationID: "test", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "other", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, }) - handler, err := publicHandler(t, h.s, h.db, auth, kind, workerExecution(worker), withPolicy(h.d.Policy), nativeDeploymentDefaults(h.s, model, provider)) + handler, err := publicHandler(t, h.s, h.db, auth, kind, workerExecution(worker), withPolicy(h.d.Policy), nativeDeploymentDefaults(model, provider)) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/function_public_native_test.go b/services/core/internal/store/function_public_native_test.go index 238cfaa9..369a1431 100644 --- a/services/core/internal/store/function_public_native_test.go +++ b/services/core/internal/store/function_public_native_test.go @@ -75,7 +75,7 @@ func nativePublicFunctionServer(t *testing.T, h *dispatchHarness, ctx context.Co }) token := uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}}) - handler, err := publicHandler(t, h.s, h.db, auth, "codex", workerExecution(worker), nativeDeploymentDefaults(h.s, "gpt-5.5", provider)) + handler, err := publicHandler(t, h.s, h.db, auth, "codex", workerExecution(worker), nativeDeploymentDefaults("gpt-5.5", provider)) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/initial_files_http_test.go b/services/core/internal/store/initial_files_http_test.go index 88c98569..97cd3ce4 100644 --- a/services/core/internal/store/initial_files_http_test.go +++ b/services/core/internal/store/initial_files_http_test.go @@ -24,7 +24,7 @@ func TestInitialFilesHTTPInlineLimitsAndRetry(t *testing.T) { tenant, token := uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: tenant}}) // Exercise HTTP parsing and durable storage without starting a Runtime. - handler, err := publicHandler(t, s, db, auth, "codex", storeExecution(t, s), managedSandboxes(t, s), fixtureDeploymentProvider(s)) + handler, err := publicHandler(t, s, db, auth, "codex", storeExecution(t, s), managedSandboxes(t, s), fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/mcode_public_native_test.go b/services/core/internal/store/mcode_public_native_test.go index f71ac1de..036eab07 100644 --- a/services/core/internal/store/mcode_public_native_test.go +++ b/services/core/internal/store/mcode_public_native_test.go @@ -47,7 +47,7 @@ func TestNativeMCodePublicExecution(t *testing.T) { {OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}, {OrganizationID: "test", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "other", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, }) - handler, err := publicHandler(t, h.s, h.db, auth, "mcode", workerExecution(worker), acceptUnavailable(t), nativeDeploymentDefaults(h.s, model, provider)) + handler, err := publicHandler(t, h.s, h.db, auth, "mcode", workerExecution(worker), acceptUnavailable(t), nativeDeploymentDefaults(model, provider)) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/message_images_native_test.go b/services/core/internal/store/message_images_native_test.go index 29002b5e..bbe9f1aa 100644 --- a/services/core/internal/store/message_images_native_test.go +++ b/services/core/internal/store/message_images_native_test.go @@ -48,7 +48,7 @@ func TestNativeMessageImagePublicExecution(t *testing.T) { {OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}, {OrganizationID: "test", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "other", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, }) - handler, err := publicHandler(t, h.s, h.db, auth, kind, workerExecution(worker), withPolicy(h.d.Policy), nativeDeploymentDefaults(h.s, model, provider)) + handler, err := publicHandler(t, h.s, h.db, auth, kind, workerExecution(worker), withPolicy(h.d.Policy), nativeDeploymentDefaults(model, provider)) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/model_protocol_native_test.go b/services/core/internal/store/model_protocol_native_test.go index 1fec8ba4..b1a9c2c8 100644 --- a/services/core/internal/store/model_protocol_native_test.go +++ b/services/core/internal/store/model_protocol_native_test.go @@ -12,8 +12,8 @@ import ( "time" v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" "github.com/google/uuid" ) @@ -78,8 +78,8 @@ func TestNativeModelProtocolPublicExecution(t *testing.T) { token := uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}}) providerRevision := uuid.New() - handler, err := publicHandler(t, h.s, h.db, auth, options.Engine, workerExecution(worker), withPolicy(h.d.Policy), modelProviderDefaults(h.s, func(context.Context, string) (*store.DeploymentModelProviderSnapshot, error) { - return &store.DeploymentModelProviderSnapshot{Model: options.Model, HarnessConfig: options.HarnessConfig, Provider: &options.Provider, Revision: providerRevision}, nil + handler, err := publicHandler(t, h.s, h.db, auth, options.Engine, workerExecution(worker), withPolicy(h.d.Policy), modelProviderDefaults(func(context.Context, string) (*modelconfiguration.Snapshot, error) { + return &modelconfiguration.Snapshot{Model: options.Model, HarnessConfig: options.HarnessConfig, Provider: &options.Provider, Revision: providerRevision}, nil })) if err != nil { t.Fatal("cannot create public API handler") diff --git a/services/core/internal/store/model_provider_fixture_test.go b/services/core/internal/store/model_provider_fixture_test.go index 448e4b65..9866aebd 100644 --- a/services/core/internal/store/model_provider_fixture_test.go +++ b/services/core/internal/store/model_provider_fixture_test.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" ) @@ -15,9 +16,9 @@ import ( // key (store.NewModelTestStore). // fixtureDeploymentProvider configures a deployment default for every harness. -func fixtureDeploymentProvider(s *store.Store) func(*api.Dependencies) { - return modelProviderDefaults(s, func(_ context.Context, harness string) (*store.DeploymentModelProviderSnapshot, error) { - return &store.DeploymentModelProviderSnapshot{Model: "fixture", Provider: store.FixtureModelProvider(harness), Revision: uuid.New()}, nil +func fixtureDeploymentProvider() func(*api.Dependencies) { + return modelProviderDefaults(func(_ context.Context, harness string) (*modelconfiguration.Snapshot, error) { + return &modelconfiguration.Snapshot{Model: "fixture", Provider: store.FixtureModelProvider(harness), Revision: uuid.New()}, nil }) } diff --git a/services/core/internal/store/native_daemon_test.go b/services/core/internal/store/native_daemon_test.go index cf4b8cc0..4aab20f2 100644 --- a/services/core/internal/store/native_daemon_test.go +++ b/services/core/internal/store/native_daemon_test.go @@ -24,7 +24,7 @@ import ( v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/api" - "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/google/uuid" ) @@ -150,10 +150,10 @@ func nativeModelProvider(model *httptest.Server) *v1.ModelProviderInput { // nativeDeploymentDefaults makes provider the deployment default model provider, // which public environment:none Sessions freeze at creation. -func nativeDeploymentDefaults(s *store.Store, model string, provider *v1.ModelProviderInput) func(*api.Dependencies) { +func nativeDeploymentDefaults(model string, provider *v1.ModelProviderInput) func(*api.Dependencies) { revision := uuid.New() - return modelProviderDefaults(s, func(context.Context, string) (*store.DeploymentModelProviderSnapshot, error) { - return &store.DeploymentModelProviderSnapshot{Model: model, Provider: provider, Revision: revision}, nil + return modelProviderDefaults(func(context.Context, string) (*modelconfiguration.Snapshot, error) { + return &modelconfiguration.Snapshot{Model: model, Provider: provider, Revision: revision}, nil }) } diff --git a/services/core/internal/store/native_public_execution_test.go b/services/core/internal/store/native_public_execution_test.go index 2eed0cad..d22a8da9 100644 --- a/services/core/internal/store/native_public_execution_test.go +++ b/services/core/internal/store/native_public_execution_test.go @@ -36,7 +36,7 @@ func verifyNativePublicExecution(t *testing.T, h *dispatchHarness, parent contex }() token, foreign := uuid.NewString(), uuid.NewString() auth := newTestAuthenticator(t, []testAPIKey{{OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}, {OrganizationID: "test-org", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "test-runner", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}}) - handler, err := publicHandler(t, h.s, h.db, auth, "codex", workerExecution(worker), nativeDeploymentDefaults(h.s, "gpt-5.5", provider)) + handler, err := publicHandler(t, h.s, h.db, auth, "codex", workerExecution(worker), nativeDeploymentDefaults("gpt-5.5", provider)) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/public_handler_fixture_test.go b/services/core/internal/store/public_handler_fixture_test.go index e56bd3e9..d24d7153 100644 --- a/services/core/internal/store/public_handler_fixture_test.go +++ b/services/core/internal/store/public_handler_fixture_test.go @@ -13,9 +13,11 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmenttemplates" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/execution" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/files" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/agentpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/auditpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/filepg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/templatepg" "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/runtimedevice" @@ -54,9 +56,15 @@ func publicHandler(t testing.TB, s *store.Store, db fixtureDB, keys fixtureKeyRe if err != nil { return nil, err } + modelConfigurationStore := modelconfigurationpg.New(pgunit.NewPool(db.pool)) + modelConfigurationService, err := modelconfiguration.NewService(modelConfigurationStore, db.cipher) + if err != nil { + return nil, err + } deps := api.Dependencies{ Engine: engine, CoreKeys: admin, InstallationBindings: s, - Projects: fixtureProjects{Store: s, keys: keys}, ModelProviders: s, Skills: s, + Projects: fixtureProjects{Store: s, keys: keys}, Skills: s, + ModelProviders: modelConfigurationService, ModelProvidersReader: modelConfigurationStore, Vaults: vaultService, VaultsReader: vaultStore, Files: fileService, FilesReader: fileStore, EnvironmentTemplates: environmentTemplates, EnvironmentTemplatesReader: templates, @@ -155,17 +163,19 @@ func managedSandboxes(t testing.TB, s *store.Store) func(*api.Dependencies) { } // modelProviderDefaults resolves deployment model provider defaults with -// resolve instead of the Store's deployment configuration. -func modelProviderDefaults(s *store.Store, resolve func(context.Context, string) (*store.DeploymentModelProviderSnapshot, error)) func(*api.Dependencies) { - return func(d *api.Dependencies) { d.ModelProviders = resolvedModelProviders{Store: s, resolve: resolve} } +// resolve instead of the stored deployment configuration. +func modelProviderDefaults(resolve func(context.Context, string) (*modelconfiguration.Snapshot, error)) func(*api.Dependencies) { + return func(d *api.Dependencies) { + d.ModelProviders = resolvedModelProviders{ModelProviders: d.ModelProviders, resolve: resolve} + } } type resolvedModelProviders struct { - *store.Store - resolve func(context.Context, string) (*store.DeploymentModelProviderSnapshot, error) + api.ModelProviders + resolve func(context.Context, string) (*modelconfiguration.Snapshot, error) } -func (p resolvedModelProviders) DeploymentModelProvider(ctx context.Context, harness string) (*store.DeploymentModelProviderSnapshot, error) { +func (p resolvedModelProviders) Resolve(ctx context.Context, harness string) (*modelconfiguration.Snapshot, error) { return p.resolve(ctx, harness) } diff --git a/services/core/internal/store/session_creation_identity_test.go b/services/core/internal/store/session_creation_identity_test.go index 2e445846..cab1fcc5 100644 --- a/services/core/internal/store/session_creation_identity_test.go +++ b/services/core/internal/store/session_creation_identity_test.go @@ -1,6 +1,7 @@ package store import ( + "bytes" "context" "encoding/json" "errors" @@ -9,6 +10,13 @@ import ( "sync" "testing" + v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/adminaudit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/credentialcrypto" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/modelconfiguration" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/modelconfigurationpg" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/persistence/postgres/pgunit" + "github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sessions" "github.com/google/uuid" ) @@ -107,3 +115,147 @@ func TestSessionCreationIdentityDoesNotInventHistoricalIntent(t *testing.T) { t.Fatal("historical retry rules changed", err) } } + +// A provider key enters the retry hashes only through a fingerprint keyed by the +// credential key: the same request under two credential keys hashes differently, +// and an intent whose provider cannot be read is rejected, not hashed raw. +func TestProviderKeyEntersRetryHashesOnlyAsKeyedFingerprint(t *testing.T) { + _, pool := testStore(t) + provider := &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://example.com/v1", APIKey: "hash-key-canary"} + intent, err := json.Marshal(map[string]any{"agent": map[string]string{"model": "m"}, "environment": map[string]string{"type": "openai_hosted"}, "x_agents_core": map[string]any{"model_provider": provider}}) + if err != nil { + t.Fatal(err) + } + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: "same-request", ModelProvider: provider, ModelProviderSource: v1.ModelProviderSourceSession, + Configuration: []byte(`{"agent":{"model":"m"},"environment":{"type":"openai_hosted"}}`), CreationRequest: intent} + var hashes [2][2]string + for index, seed := range []byte{71, 72} { + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{seed}, 32)) + if err != nil { + t.Fatal(err) + } + session, err := NewWithCredentialCipher(pool, cipher).CreateSession(t.Context(), uuid.NewString(), input) + if err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(t.Context(), "SELECT request_hash, creation_request_hash FROM sessions WHERE id=$1", session.ID).Scan(&hashes[index][0], &hashes[index][1]); err != nil { + t.Fatal(err) + } + } + if hashes[0][0] == hashes[1][0] || hashes[0][1] == hashes[1][1] { + t.Fatal("retry hashes do not depend on the credential key", hashes) + } + cipher, _ := credentialcrypto.New(bytes.Repeat([]byte{71}, 32)) + unreadable := input + unreadable.IdempotencyKey, unreadable.CreationRequest = "unreadable", json.RawMessage(`{"x_agents_core":{"model_provider":"hash-key-canary"}}`) + if _, err := NewWithCredentialCipher(pool, cipher).CreateSession(t.Context(), uuid.NewString(), unreadable); !errors.Is(err, ErrInvalidInput) { + t.Fatal("unreadable provider intent was hashed", err) + } +} + +// A Session keeps the deployment default and revision it resolved. Replacing, +// removing or recreating the default never changes a created Session, a retry +// that resolved a newer default replays the original, and a Session that froze +// an older revision never updates the current default's observations. +func TestSessionCreationKeepsItsResolvedDeploymentRevision(t *testing.T) { + s, pool := newManagedTestStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{73}, 32)) + if err != nil { + t.Fatal(err) + } + defaults := modelconfigurationpg.New(pgunit.NewPool(pool)) + service, err := modelconfiguration.NewService(defaults, cipher) + if err != nil { + t.Fatal(err) + } + admin := adminaudit.WithSource(t.Context(), adminaudit.Source{CredentialID: "fixture-admin", RequestID: uuid.NewString(), TraceID: uuid.NewString()}) + replace := func(provider v1.ModelProviderInput) { + t.Helper() + if _, err := service.Replace(admin, modelconfiguration.Replacement{Harness: "codex", Configuration: v1.ModelConfigurationInput{ModelProvider: provider, Model: "fixture"}}); err != nil { + t.Fatal(err) + } + } + resolve := func() *modelconfiguration.Snapshot { + t.Helper() + snapshot, err := service.Resolve(t.Context(), "codex") + if err != nil || snapshot == nil { + t.Fatal("deployment default did not resolve", err) + } + return snapshot + } + revision := func(session string) uuid.UUID { + t.Helper() + var value uuid.UUID + if err := pool.QueryRow(t.Context(), "SELECT deployment_provider_revision FROM session_execution_configuration WHERE session_id=$1", session).Scan(&value); err != nil { + t.Fatal(err) + } + return value + } + tenant := uuid.NewString() + observe := func(session string, status, coreCode, nativeCode string) { + t.Helper() + receipt := submitMessage(t, s, tenant, session, uuid.NewString()) + transition(t, s, tenant, session, receipt.TurnID, sessions.TurnQueued, sessions.TurnInProgress) + outcome, _ := json.Marshal(map[string]string{"error_code": coreCode, "engine_error_code": nativeCode}) + turn, err := s.CompleteExecution(t.Context(), tenant, session, receipt.TurnID, status, outcome, "", receipt.Sequence) + if err != nil { + t.Fatal(err) + } + if n, err := defaults.ObserveDeploymentModelProvider(t.Context(), modelconfiguration.Observation{TenantID: tenant, SessionID: session, TurnID: turn.ID}); err != nil || n != 0 { + t.Fatalf("observation writes=%d want=0 err=%v", n, err) + } + } + replace(*FixtureModelProvider("codex")) + before := resolve() + input := executionProjectionInput("deployment") + input.Configuration = json.RawMessage(`{"agent":{"model":"frozen-model"},"environment":{"type":"none"}}`) + input.ModelProvider, input.ModelProviderSource, input.DeploymentProviderRevision = before.Provider, "deployment", before.Revision + input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: "deployment"} + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + original := revision(session.ID) + // Resolution and insertion have independent boundaries: retain the tuple while + // a replacement lands, then create with that exact earlier tuple. + replacement := *before.Provider + replacement.APIKey = "different-fixture-key" + replace(replacement) + if _, err = pool.Exec(t.Context(), "UPDATE deployment_model_providers SET updated_at='2000-01-01'"); err != nil { + t.Fatal(err) + } + staleInput := input + staleInput.IdempotencyKey = uuid.NewString() + stale, err := s.CreateSession(t.Context(), tenant, staleInput) + if err != nil { + t.Fatal(err) + } + if revision(stale.ID) != original { + t.Fatal("tuple revision changed during insertion") + } + frozen, err := s.SessionModelExecution(t.Context(), tenant, stale.ID) + if err != nil || frozen == nil || *frozen != *before.Provider { + t.Fatal("frozen tuple bundle changed", err) + } + observe(stale.ID, sessions.TurnFailed, "engine_failed", "authentication_error") + current := resolve() + retry := input + retry.ModelProvider = current.Provider + retry.DeploymentProviderRevision = current.Revision + replay, err := s.CreateSession(t.Context(), tenant, retry) + if err != nil || replay.ID != session.ID || revision(session.ID) != original { + t.Fatal("retry replaced frozen metadata", err) + } + if err = service.Delete(admin, "codex"); err != nil { + t.Fatal(err) + } + replace(*before.Provider) + if recreated := resolve(); recreated.Revision == original || recreated.Revision == current.Revision { + t.Fatal("revision reused") + } + frozenProvider, err := s.SessionModelExecution(t.Context(), tenant, session.ID) + if err != nil || frozenProvider == nil || *frozenProvider != *input.ModelProvider { + t.Fatal("replacement changed the Session bundle", err) + } + observe(session.ID, sessions.TurnCompleted, "", "") +} diff --git a/services/core/internal/store/structured_output_native_test.go b/services/core/internal/store/structured_output_native_test.go index 9d2149b9..c23804de 100644 --- a/services/core/internal/store/structured_output_native_test.go +++ b/services/core/internal/store/structured_output_native_test.go @@ -44,7 +44,7 @@ func TestNativeStructuredOutputPublicExecution(t *testing.T) { {OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}, {OrganizationID: "test", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "other", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, }) - handler, err := publicHandler(t, h.s, h.db, auth, "claude_sdk", workerExecution(worker), nativeDeploymentDefaults(h.s, model, provider)) + handler, err := publicHandler(t, h.s, h.db, auth, "claude_sdk", workerExecution(worker), nativeDeploymentDefaults(model, provider)) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/template_composition_public_test.go b/services/core/internal/store/template_composition_public_test.go index 4aee834b..c809364c 100644 --- a/services/core/internal/store/template_composition_public_test.go +++ b/services/core/internal/store/template_composition_public_test.go @@ -40,7 +40,7 @@ func TestTemplateCompositionOfficialClientPostgres(t *testing.T) { serve := func(current *store.Store) *httptest.Server { t.Helper() // Hosted admission and freezing use the real Store; no Runtime or model runs. - h, err := publicHandler(t, current, db, auth, "codex", storeExecution(t, current), managedSandboxes(t, current), fixtureDeploymentProvider(current)) + h, err := publicHandler(t, current, db, auth, "codex", storeExecution(t, current), managedSandboxes(t, current), fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/template_null_selection_public_test.go b/services/core/internal/store/template_null_selection_public_test.go index 7156f74b..e8f29207 100644 --- a/services/core/internal/store/template_null_selection_public_test.go +++ b/services/core/internal/store/template_null_selection_public_test.go @@ -41,7 +41,7 @@ func TestTemplateNullSelectionOfficialClientPostgres(t *testing.T) { }) serve := func(current *store.Store) *httptest.Server { t.Helper() - h, err := publicHandler(t, current, db, auth, "codex", storeExecution(t, current), managedSandboxes(t, current), fixtureDeploymentProvider(current)) + h, err := publicHandler(t, current, db, auth, "codex", storeExecution(t, current), managedSandboxes(t, current), fixtureDeploymentProvider()) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/tool_policy_native_test.go b/services/core/internal/store/tool_policy_native_test.go index 35e02579..aea5f019 100644 --- a/services/core/internal/store/tool_policy_native_test.go +++ b/services/core/internal/store/tool_policy_native_test.go @@ -50,7 +50,7 @@ func TestNativeToolPolicyPublicExecution(t *testing.T) { {OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}, {OrganizationID: "test", ProjectID: foreignTenant, SubjectKind: "service_account", SubjectID: "other", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: foreignTenant}, }) - handler, err := publicHandler(t, h.s, h.db, auth, kind, workerExecution(worker), withPolicy(h.d.Policy), nativeDeploymentDefaults(h.s, model, provider)) + handler, err := publicHandler(t, h.s, h.db, auth, kind, workerExecution(worker), withPolicy(h.d.Policy), nativeDeploymentDefaults(model, provider)) if err != nil { t.Fatal(err) } diff --git a/services/core/internal/store/tool_search_native_test.go b/services/core/internal/store/tool_search_native_test.go index c36fe169..9dc35365 100644 --- a/services/core/internal/store/tool_search_native_test.go +++ b/services/core/internal/store/tool_search_native_test.go @@ -44,7 +44,7 @@ func TestNativeToolSearchPublicExecution(t *testing.T) { {OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: runtimedevice.HashCredential(token), TenantID: h.tenant}, {OrganizationID: "test", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "other", TokenSHA256: runtimedevice.HashCredential(foreign), TenantID: uuid.NewString()}, }) - handler, err := publicHandler(t, h.s, h.db, auth, "claude_sdk", workerExecution(worker), nativeDeploymentDefaults(h.s, model, provider)) + handler, err := publicHandler(t, h.s, h.db, auth, "claude_sdk", workerExecution(worker), nativeDeploymentDefaults(model, provider)) if err != nil { t.Fatal(err) }