mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 04:20:16 +00:00
* refactor(codex-pool): remove redundant primary_first strategy * test(tools): update tool schema fixtures to pointer form * feat(create_image): route Codex pools through chain with member failover Codex pool chain entries now iterate pool members per the pool's own strategy (round_robin or priority_order) and fail over internally before the outer chain advances to the next entry. - ChatGPTOAuthRouter.GenerateImage implements NativeImageProvider: iterates orderedProviders, tries each member, advances round-robin state only on success, aggregates errors on pool exhaustion. - media_provider_chain.wrapPoolProvider wraps a *CodexProvider in a router when RoutingDefaults has extras or a non-primary-first strategy. Solo Codex (no extras) stays unwrapped. - Router exposes ProviderType() so chain telemetry records "chatgpt_oauth". Closes #1008 * feat(ui/builtin-tools): pool badge on create_image chain entries Chain entry card shows a read-only "Pool · <strategy>" badge when the entry's provider is a Codex pool base. Strategy label translates in en/vi/zh. No new toggle or selector — pool config lives on the provider itself. #1008 * refactor(providers): move Codex test helpers to providertest subpackage Addresses code-review High finding: NewTestCodexProviderFast and its staticTestTokenSource were exported from a non-_test.go file, compiling into the production binary. Relocating to internal/providers/providertest/ keeps the helper importable from other packages' tests without leaking test-only symbols into production. Also tightens wrapPoolProvider — pools with zero extra members no longer get wrapped in a router (KISS: nothing to rotate between). - Added CodexProvider.WithRetryConfig as a legitimate fluent option (the test helper now uses the public API). - Dropped the internal-package test helper file. #1008 * docs(pr-1006): add pool badge UI evidence Force-UI style capture of the read-only "Pool · Round-robin" badge on the create_image chain entry card when the selected provider carries settings.codex_pool with extras. Captured against staging gateway. * refactor(ui/builtin-tools): unify pool UX with Create Agent pattern The create_image chain Provider dropdown was listing pool members alongside pool owners, letting users accidentally bypass pool semantics by picking a member directly. Matches the pattern already used by Create Agent: hide pool members, show an inline "Pool" chip on owners. - Filter pool members from the chain Provider dropdown via getChatGPTOAuthPoolOwnership.ownerByMember. - Inline "Pool" chip on owner options, reusing the existing providers:list.poolBadge i18n key. - Drop the separate card-level "Pool · <strategy>" badge — the dropdown chip alone conveys the information without duplication. - Remove the now-unused isPoolProvider / poolStrategyOf helpers and the builtin.mediaChain.poolBadge* i18n keys we briefly introduced. #1008 * docs(pr-1006): add pool-filtered dropdown screenshot * fix(ui/builtin-tools): migrate stale pool-member chain entries on load Red-team blocker: a chain saved before the pool-aware UI landed may reference a pool member by name (e.g. openai-codex-2). After the dropdown started hiding members, such an entry produced: - empty Select trigger (no SelectItem matches the stored value) - conflicting Row 1 label still showing the member name - silent runtime misroute (bare solo call, no pool wrap) parseInitialEntries now consults getChatGPTOAuthPoolOwnership and rewrites any chain entry whose provider is a pool member to the owner's name + id. The next save persists the migrated value. Safe no-op for non-pool entries and for entries already pointing at an owner. Also memoize enabledProviders in the parent form so the card's useMemo boundaries actually hold (minor perf ding flagged by same review). * docs(pr-1006): refresh HTML evidence to match filter-based UX * fix(ui/agent-codex-pool): traffic policy reflects effective strategy under inherit On the agent's OpenAI Account Pool page, when Agent routing mode is Use Provider Defaults, the Traffic Policy buttons painted the draft's placeholder strategy ("priority_order") as selected — contradicting the top-of-page chip which already correctly shows the provider's effective strategy. The removal of primary_first in this PR unmasked the latent bug: the placeholder used to be a deprecated value that didn't match any live button, so nothing appeared selected. Derive selectedStrategy from defaultRouting when mode === "inherit": the button highlight now mirrors what actually runs. Buttons remain disabled in inherit mode (unchanged), but the displayed selection no longer misleads the user. * docs(pr-1006): add screenshot of inherit-mode Traffic Policy fix * fix(permissions): remove duplicate MethodSessionsCompact entry The writeExact slice listed MethodSessionsCompact twice. slices.Contains still returned correct results so runtime behavior is unchanged, but the duplicate entry was dead code. Spotted during review of PR #1006. --------- Co-authored-by: viettranx <edu@200lab.io>
322 lines
11 KiB
Go
322 lines
11 KiB
Go
package tools
|
|
|
|
// Integration tests for pool-failover-before-chain-fallthrough semantics in create_image.
|
|
// These tests exercise the full stack: ExecuteWithChain + wrapPoolProvider +
|
|
// CreateImageTool.callProvider, wired through a real ChatGPTOAuthRouter backed
|
|
// by mock HTTP servers. They prove the contract from issue #1008 is correct:
|
|
// pool member failover happens INSIDE the router before the outer chain advances.
|
|
//
|
|
// Not duplicated here (already covered at unit level):
|
|
// - internal/providers/chatgpt_oauth_router_image_test.go — router failover semantics
|
|
// - internal/tools/media_provider_chain_pool_test.go — wrapPoolProvider decisions
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers/providertest"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// poolImageSSE returns a minimal SSE body that parseNativeImageSSE accepts.
|
|
func poolImageSSE(b64data string) string {
|
|
return `data: {"type":"response.output_item.done","item":{"type":"image_generation_call","result":"` +
|
|
b64data + `","output_format":"png"}}` + "\n\ndata: [DONE]\n"
|
|
}
|
|
|
|
// poolSSEServer starts a test server that returns a successful image SSE on each request.
|
|
func poolSSEServer(t *testing.T, hits *atomic.Int32) *httptest.Server {
|
|
t.Helper()
|
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
hits.Add(1)
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte(poolImageSSE("aW1hZ2VkYXRh"))) // "imagedata" base64
|
|
}))
|
|
t.Cleanup(s.Close)
|
|
return s
|
|
}
|
|
|
|
// pool429Server starts a test server that always returns HTTP 429 (retryable).
|
|
func pool429Server(t *testing.T, hits *atomic.Int32) *httptest.Server {
|
|
t.Helper()
|
|
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
hits.Add(1)
|
|
http.Error(w, "rate limited", http.StatusTooManyRequests)
|
|
}))
|
|
t.Cleanup(s.Close)
|
|
return s
|
|
}
|
|
|
|
// buildPoolChainRegistry creates a registry and registers each CodexProvider under
|
|
// both the master tenant (so ExecuteWithChain's Get resolves it) and the given
|
|
// tenantID (so ChatGPTOAuthRouter's GetForTenant resolves pool members).
|
|
func buildPoolChainRegistry(tenantID uuid.UUID, members ...*providers.CodexProvider) *providers.Registry {
|
|
reg := providers.NewRegistry(nil)
|
|
for _, p := range members {
|
|
reg.Register(p) // master tenant — found by ExecuteWithChain
|
|
reg.RegisterForTenant(tenantID, p) // tenant scope — found by router's GetForTenant
|
|
}
|
|
return reg
|
|
}
|
|
|
|
// poolBaseChainEntry returns a chain entry pointing at baseProvider.
|
|
// Prompt injected via Params so callProvider can build NativeImageRequest.
|
|
func poolBaseChainEntry(baseProvider string) MediaProviderEntry {
|
|
return MediaProviderEntry{
|
|
Provider: baseProvider,
|
|
Model: "gpt-image-2",
|
|
Enabled: true,
|
|
Timeout: 10,
|
|
MaxRetries: 1,
|
|
Params: map[string]any{
|
|
"prompt": "integration test image",
|
|
"aspect_ratio": "1:1",
|
|
},
|
|
}
|
|
}
|
|
|
|
// fakeFallbackEntry returns a chain entry for a nativeImageProvider-backed fake
|
|
// (already defined in create_image_native_path_test.go in this package).
|
|
// Using a native fake avoids credential requirements for the fallback slot.
|
|
func fakeFallbackEntry(name string) MediaProviderEntry {
|
|
return MediaProviderEntry{
|
|
Provider: name,
|
|
Model: "fake-model",
|
|
Enabled: true,
|
|
Timeout: 10,
|
|
MaxRetries: 1,
|
|
Params: map[string]any{
|
|
"prompt": "integration test image",
|
|
"aspect_ratio": "1:1",
|
|
},
|
|
}
|
|
}
|
|
|
|
// --- Scenario 1 ---
|
|
// Chain: [Pool(A retryable, B success), Fallback]
|
|
// Expected: result from B; Fallback NOT called.
|
|
// Proves issue #1008 fix: pool failover is internal to the router, never leaks to outer chain.
|
|
func TestCreateImagePoolChain_PoolMemberFailover_FallbackNotCalled(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
|
|
var hitsA, hitsB atomic.Int32
|
|
serverA := pool429Server(t, &hitsA)
|
|
serverB := poolSSEServer(t, &hitsB)
|
|
|
|
baseA := providertest.NewCodexProviderFast("pool-a", serverA.URL)
|
|
memberB := providertest.NewCodexProviderFast("pool-b", serverB.URL)
|
|
baseA.WithRoutingDefaults("round_robin", []string{"pool-b"})
|
|
|
|
reg := buildPoolChainRegistry(tenantID, baseA, memberB)
|
|
|
|
// Fallback fake — should NOT be called.
|
|
fallback := &nativeImageProvider{
|
|
name: "fallback-fake",
|
|
model: "fake-model",
|
|
returnData: []byte("fallback-bytes"),
|
|
}
|
|
reg.Register(fallback)
|
|
|
|
ctx := store.WithTenantID(t.Context(), tenantID)
|
|
ctx = WithToolWorkspace(ctx, t.TempDir())
|
|
|
|
chain := []MediaProviderEntry{
|
|
poolBaseChainEntry("pool-a"),
|
|
fakeFallbackEntry("fallback-fake"),
|
|
}
|
|
|
|
tool := NewCreateImageTool(reg)
|
|
result, err := ExecuteWithChain(ctx, chain, reg, tool.callProvider)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteWithChain failed: %v", err)
|
|
}
|
|
if len(result.Data) == 0 {
|
|
t.Error("result.Data empty — expected image bytes from pool member B")
|
|
}
|
|
if hitsA.Load() == 0 {
|
|
t.Error("pool member A was never hit (expected 429)")
|
|
}
|
|
if hitsB.Load() == 0 {
|
|
t.Error("pool member B was never hit (expected success)")
|
|
}
|
|
// Core assertion from issue #1008: fallback must NOT have been called.
|
|
if fallback.calledWith != nil {
|
|
t.Errorf("fallback provider was called — pool failover to B should prevent chain fallthrough (issue #1008 regression)")
|
|
}
|
|
}
|
|
|
|
// --- Scenario 2 ---
|
|
// Chain: [Pool(A fail, B fail), Fallback success]
|
|
// Expected: result from Fallback.
|
|
func TestCreateImagePoolChain_PoolExhausted_FallsThroughToFallback(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
|
|
var hitsA, hitsB atomic.Int32
|
|
serverA := pool429Server(t, &hitsA)
|
|
serverB := pool429Server(t, &hitsB)
|
|
|
|
baseA := providertest.NewCodexProviderFast("pool-a2", serverA.URL)
|
|
memberB := providertest.NewCodexProviderFast("pool-b2", serverB.URL)
|
|
baseA.WithRoutingDefaults("round_robin", []string{"pool-b2"})
|
|
|
|
reg := buildPoolChainRegistry(tenantID, baseA, memberB)
|
|
|
|
fallback := &nativeImageProvider{
|
|
name: "fallback-fake2",
|
|
model: "fake-model",
|
|
returnData: []byte("fallback-image-bytes"),
|
|
}
|
|
reg.Register(fallback)
|
|
|
|
ctx := store.WithTenantID(t.Context(), tenantID)
|
|
ctx = WithToolWorkspace(ctx, t.TempDir())
|
|
|
|
chain := []MediaProviderEntry{
|
|
poolBaseChainEntry("pool-a2"),
|
|
fakeFallbackEntry("fallback-fake2"),
|
|
}
|
|
|
|
tool := NewCreateImageTool(reg)
|
|
result, err := ExecuteWithChain(ctx, chain, reg, tool.callProvider)
|
|
if err != nil {
|
|
t.Fatalf("ExecuteWithChain failed: %v — expected fallback to succeed", err)
|
|
}
|
|
if len(result.Data) == 0 {
|
|
t.Error("result.Data empty — expected bytes from fallback")
|
|
}
|
|
if hitsA.Load() == 0 {
|
|
t.Error("pool member A was not attempted")
|
|
}
|
|
if hitsB.Load() == 0 {
|
|
t.Error("pool member B was not attempted")
|
|
}
|
|
if fallback.calledWith == nil {
|
|
t.Error("fallback was not called — expected chain fallthrough after pool exhausted")
|
|
}
|
|
}
|
|
|
|
// --- Scenario 3 ---
|
|
// Chain: [Pool(A,B) exhausted, Fallback also fails]
|
|
// Expected: error surfaces; no panic.
|
|
func TestCreateImagePoolChain_AllFail_ErrorSurfaces(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
|
|
var hitsA, hitsB atomic.Int32
|
|
serverA := pool429Server(t, &hitsA)
|
|
serverB := pool429Server(t, &hitsB)
|
|
|
|
baseA := providertest.NewCodexProviderFast("pool-a3", serverA.URL)
|
|
memberB := providertest.NewCodexProviderFast("pool-b3", serverB.URL)
|
|
baseA.WithRoutingDefaults("round_robin", []string{"pool-b3"})
|
|
|
|
reg := buildPoolChainRegistry(tenantID, baseA, memberB)
|
|
|
|
// Fallback fake that returns an error.
|
|
fallbackErr := &nativeImageProvider{
|
|
name: "fallback-fake3",
|
|
model: "fake-model",
|
|
returnError: errPoolTestFailure,
|
|
}
|
|
reg.Register(fallbackErr)
|
|
|
|
ctx := store.WithTenantID(t.Context(), tenantID)
|
|
ctx = WithToolWorkspace(ctx, t.TempDir())
|
|
|
|
chain := []MediaProviderEntry{
|
|
poolBaseChainEntry("pool-a3"),
|
|
fakeFallbackEntry("fallback-fake3"),
|
|
}
|
|
|
|
tool := NewCreateImageTool(reg)
|
|
_, err := ExecuteWithChain(ctx, chain, reg, tool.callProvider)
|
|
if err == nil {
|
|
t.Fatal("expected error when all providers fail, got nil")
|
|
}
|
|
}
|
|
|
|
// errPoolTestFailure is a sentinel error used for fallback failure simulation.
|
|
var errPoolTestFailure = &poolChainTestError{msg: "pool integration test: simulated failure"}
|
|
|
|
// poolChainTestError is a simple non-retryable error for testing.
|
|
type poolChainTestError struct{ msg string }
|
|
|
|
func (e *poolChainTestError) Error() string { return e.msg }
|
|
|
|
// --- Scenario 4 ---
|
|
// Chain: [Pool(A)] — single-member pool, no routing defaults.
|
|
// wrapPoolProvider must NOT wrap; callProvider routes directly to A via native path.
|
|
func TestCreateImagePoolChain_SingleMemberPool_NoWrapOverhead(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
|
|
var hitsA atomic.Int32
|
|
serverA := poolSSEServer(t, &hitsA)
|
|
|
|
// Solo provider — no WithRoutingDefaults → wrapPoolProvider returns it unchanged.
|
|
soloA := providertest.NewCodexProviderFast("solo-a", serverA.URL)
|
|
|
|
reg := buildPoolChainRegistry(tenantID, soloA)
|
|
|
|
ctx := store.WithTenantID(t.Context(), tenantID)
|
|
ctx = WithToolWorkspace(ctx, t.TempDir())
|
|
|
|
chain := []MediaProviderEntry{poolBaseChainEntry("solo-a")}
|
|
|
|
tool := NewCreateImageTool(reg)
|
|
result, err := ExecuteWithChain(ctx, chain, reg, tool.callProvider)
|
|
if err != nil {
|
|
t.Fatalf("single-member pool failed: %v", err)
|
|
}
|
|
if len(result.Data) == 0 {
|
|
t.Error("result.Data empty")
|
|
}
|
|
if hitsA.Load() == 0 {
|
|
t.Error("solo member A was not called")
|
|
}
|
|
}
|
|
|
|
// --- Scenario 5 ---
|
|
// Chain: [Pool(A,B) round_robin] — 2 calls must hit different members.
|
|
// Verifies RR counter advances once per GenerateImage call (not per member tried).
|
|
func TestCreateImagePoolChain_RoundRobin_RotatesAcrossTwoCalls(t *testing.T) {
|
|
tenantID := uuid.New()
|
|
|
|
var hitsA, hitsB atomic.Int32
|
|
serverA := poolSSEServer(t, &hitsA)
|
|
serverB := poolSSEServer(t, &hitsB)
|
|
|
|
baseA := providertest.NewCodexProviderFast("rr-a", serverA.URL)
|
|
memberB := providertest.NewCodexProviderFast("rr-b", serverB.URL)
|
|
baseA.WithRoutingDefaults("round_robin", []string{"rr-b"})
|
|
|
|
reg := buildPoolChainRegistry(tenantID, baseA, memberB)
|
|
|
|
ctx := store.WithTenantID(t.Context(), tenantID)
|
|
ctx = WithToolWorkspace(ctx, t.TempDir())
|
|
|
|
chain := []MediaProviderEntry{poolBaseChainEntry("rr-a")}
|
|
tool := NewCreateImageTool(reg)
|
|
|
|
for i := range 2 {
|
|
result, err := ExecuteWithChain(ctx, chain, reg, tool.callProvider)
|
|
if err != nil {
|
|
t.Fatalf("call %d: ExecuteWithChain failed: %v", i+1, err)
|
|
}
|
|
if len(result.Data) == 0 {
|
|
t.Errorf("call %d: result.Data empty", i+1)
|
|
}
|
|
}
|
|
|
|
// With round_robin and 2 members, 2 successful calls must each hit a different member.
|
|
if hitsA.Load() != 1 {
|
|
t.Errorf("hitsA = %d, want 1 (round-robin should spread 2 calls across 2 members)", hitsA.Load())
|
|
}
|
|
if hitsB.Load() != 1 {
|
|
t.Errorf("hitsB = %d, want 1 (round-robin should spread 2 calls across 2 members)", hitsB.Load())
|
|
}
|
|
}
|