refactor(acp): remove hardcoded endpoints and migrate to path-based routing
This commit is contained in:
parent
55bfda225e
commit
939a1b6c87
@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
This document records the post-deployment validation of the bridge public
|
This document records the post-deployment validation of the bridge public
|
||||||
origin at `xworkmate-bridge.svc.plus` and the independent upstream ACP ingress
|
origin at `xworkmate-bridge.svc.plus` and the independent upstream ACP ingress
|
||||||
at `acp-server.svc.plus`.
|
at `xworkmate-bridge.svc.plus/acp-server`.
|
||||||
|
|
||||||
For APP integration, the canonical public contract remains the bridge origin
|
For APP integration, the canonical public contract remains the bridge origin
|
||||||
and the `.../acp/rpc` path on that origin. The direct `acp-server.svc.plus`
|
and the `.../acp/rpc` path on that origin. The direct `xworkmate-bridge.svc.plus/acp-server`
|
||||||
URLs in this document are upstream validation targets, not the preferred APP
|
URLs in this document are upstream validation targets, not the preferred APP
|
||||||
entry points.
|
entry points.
|
||||||
|
|
||||||
@ -29,9 +29,9 @@ Recommended APP-facing endpoint:
|
|||||||
|
|
||||||
Verified public HTTP JSON-RPC endpoints:
|
Verified public HTTP JSON-RPC endpoints:
|
||||||
|
|
||||||
- Codex: `https://acp-server.svc.plus/codex/acp/rpc`
|
- Codex: `https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc`
|
||||||
- OpenCode: `https://acp-server.svc.plus/opencode/acp/rpc`
|
- OpenCode: `https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc`
|
||||||
- Gemini: `https://acp-server.svc.plus/gemini/acp/rpc`
|
- Gemini: `https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc`
|
||||||
|
|
||||||
The `.../acp` path remains reserved for WebSocket ACP.
|
The `.../acp` path remains reserved for WebSocket ACP.
|
||||||
|
|
||||||
|
|||||||
@ -55,9 +55,9 @@ flowchart TD
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph UPSTREAM["Independent upstream services"]
|
subgraph UPSTREAM["Independent upstream services"]
|
||||||
C1["https://acp-server.svc.plus/codex/acp/rpc"]
|
C1["https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc"]
|
||||||
C2["https://acp-server.svc.plus/opencode/acp/rpc"]
|
C2["https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc"]
|
||||||
C3["https://acp-server.svc.plus/gemini/acp/rpc"]
|
C3["https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc"]
|
||||||
C4["wss://openclaw.svc.plus"]
|
C4["wss://openclaw.svc.plus"]
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -107,9 +107,9 @@ flowchart LR
|
|||||||
end
|
end
|
||||||
|
|
||||||
subgraph L3["上游视角"]
|
subgraph L3["上游视角"]
|
||||||
U1["https://acp-server.svc.plus/codex/acp/rpc"]
|
U1["https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc"]
|
||||||
U2["https://acp-server.svc.plus/opencode/acp/rpc"]
|
U2["https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc"]
|
||||||
U3["https://acp-server.svc.plus/gemini/acp/rpc"]
|
U3["https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc"]
|
||||||
U4["wss://openclaw.svc.plus<br/>reported as openclaw.svc.plus:443"]
|
U4["wss://openclaw.svc.plus<br/>reported as openclaw.svc.plus:443"]
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -163,7 +163,7 @@ Important distinction:
|
|||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
- app traffic reaches upstream ACP and gateway services only through the bridge
|
- app traffic reaches upstream ACP and gateway services only through the bridge
|
||||||
- app does not call `acp-server.svc.plus/*` or `openclaw.svc.plus` directly
|
- app does not call `xworkmate-bridge.svc.plus/acp-server/*` or `openclaw.svc.plus` directly
|
||||||
- upstream auth stays bridge-internal:
|
- upstream auth stays bridge-internal:
|
||||||
- `Authorization: Bearer $INTERNAL_SERVICE_TOKEN`
|
- `Authorization: Bearer $INTERNAL_SERVICE_TOKEN`
|
||||||
- `acp.capabilities` is the provider / capability discovery source
|
- `acp.capabilities` is the provider / capability discovery source
|
||||||
|
|||||||
@ -127,7 +127,7 @@ Use these terms consistently:
|
|||||||
|
|
||||||
- `canonical app-facing path`: `/acp/rpc` and `/acp`
|
- `canonical app-facing path`: `/acp/rpc` and `/acp`
|
||||||
- `gateway runtime method family`: `xworkmate.gateway.*`
|
- `gateway runtime method family`: `xworkmate.gateway.*`
|
||||||
- `independent upstream service`: `acp-server.svc.plus/*`, `wss://openclaw.svc.plus`
|
- `independent upstream service`: `xworkmate-bridge.svc.plus/acp-server/*`, `wss://openclaw.svc.plus`
|
||||||
- `bridge-owned routing`: provider / gateway selection performed inside bridge
|
- `bridge-owned routing`: provider / gateway selection performed inside bridge
|
||||||
- `routing metadata`: execution target and resolved provider/gateway identifiers returned to the app
|
- `routing metadata`: execution target and resolved provider/gateway identifiers returned to the app
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,49 @@
|
|||||||
package acp
|
package acp
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
func TestResolveSingleAgentForwardEndpoint(t *testing.T) {
|
func TestResolveSingleAgentForwardEndpointFromExampleConfig(t *testing.T) {
|
||||||
|
// Set the config path to example/config.yaml relative to this test file
|
||||||
|
os.Setenv("BRIDGE_CONFIG_PATH", "../../example/config.yaml")
|
||||||
|
defer os.Unsetenv("BRIDGE_CONFIG_PATH")
|
||||||
|
|
||||||
|
catalog, order := newProductionProviderCatalog()
|
||||||
|
if len(order) == 0 {
|
||||||
|
t.Fatal("Expected non-empty provider order from example/config.yaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedEndpoints := map[string]string{
|
||||||
|
"codex": "https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc",
|
||||||
|
"opencode": "https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc",
|
||||||
|
"gemini": "https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range order {
|
||||||
|
id := id
|
||||||
|
t.Run(id, func(t *testing.T) {
|
||||||
|
provider, ok := catalog[id]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("Provider %s missing from catalog", id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !provider.Enabled {
|
||||||
|
t.Errorf("Provider %s should be enabled in example config", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := expectedEndpoints[id]
|
||||||
|
got := resolveSingleAgentForwardEndpoint(provider)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("resolveSingleAgentForwardEndpoint(%s) = %q, want %q (from example config)", id, got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSingleAgentForwardEndpointManual(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
@ -13,26 +54,10 @@ func TestResolveSingleAgentForwardEndpoint(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "preserves upstream endpoint",
|
name: "preserves upstream endpoint",
|
||||||
provider: syncedProvider{
|
provider: syncedProvider{
|
||||||
ProviderID: "opencode",
|
ProviderID: "custom",
|
||||||
Endpoint: "https://acp-server.svc.plus/opencode/acp/rpc",
|
Endpoint: "https://upstream-provider.example.com/acp/rpc",
|
||||||
},
|
},
|
||||||
want: "https://acp-server.svc.plus/opencode/acp/rpc",
|
want: "https://upstream-provider.example.com/acp/rpc",
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "does not rewrite bridge endpoint placeholder for codex",
|
|
||||||
provider: syncedProvider{
|
|
||||||
ProviderID: "codex",
|
|
||||||
Endpoint: "https://xworkmate-bridge.svc.plus",
|
|
||||||
},
|
|
||||||
want: "https://xworkmate-bridge.svc.plus",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "does not rewrite bridge endpoint placeholder for gemini",
|
|
||||||
provider: syncedProvider{
|
|
||||||
ProviderID: "gemini",
|
|
||||||
Endpoint: "https://xworkmate-bridge.svc.plus",
|
|
||||||
},
|
|
||||||
want: "https://xworkmate-bridge.svc.plus",
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,14 +8,6 @@ import (
|
|||||||
"xworkmate-bridge/internal/shared"
|
"xworkmate-bridge/internal/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 默认生产端点
|
|
||||||
const (
|
|
||||||
productionGatewayEndpointURL = "https://xworkmate-bridge.svc.plus/gateway/openclaw/"
|
|
||||||
productionCodexEndpointURL = "https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc"
|
|
||||||
productionOpenCodeEndpointURL = "https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc"
|
|
||||||
productionGeminiEndpointURL = "https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc"
|
|
||||||
)
|
|
||||||
|
|
||||||
type syncedProvider struct {
|
type syncedProvider struct {
|
||||||
ProviderID string
|
ProviderID string
|
||||||
Label string
|
Label string
|
||||||
@ -46,17 +38,15 @@ func loadBridgeConfig() *BridgeConfig {
|
|||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveURL(yamlVal, envKey, defaultVal string) string {
|
func resolveURL(yamlVal, envKey string) string {
|
||||||
val := strings.TrimSpace(yamlVal)
|
val := strings.TrimSpace(yamlVal)
|
||||||
if val != "" {
|
if val != "" {
|
||||||
return val
|
return val
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(shared.EnvOrDefault(envKey, defaultVal))
|
return strings.TrimSpace(shared.EnvOrDefault(envKey, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
func bridgeUpstreamAuthorizationHeader() string {
|
func bridgeUpstreamAuthorizationHeader() string {
|
||||||
// Original logic used firstNonEmptyString and normalizeAuthorizationHeader
|
|
||||||
// but let's keep it simple and match expected "Bearer token" if it exists.
|
|
||||||
token := strings.TrimSpace(shared.EnvOrDefault("BRIDGE_AUTH_TOKEN", ""))
|
token := strings.TrimSpace(shared.EnvOrDefault("BRIDGE_AUTH_TOKEN", ""))
|
||||||
if token == "" {
|
if token == "" {
|
||||||
token = strings.TrimSpace(shared.EnvOrDefault("INTERNAL_SERVICE_TOKEN", ""))
|
token = strings.TrimSpace(shared.EnvOrDefault("INTERNAL_SERVICE_TOKEN", ""))
|
||||||
@ -71,30 +61,32 @@ func newProductionProviderCatalog() (map[string]syncedProvider, []string) {
|
|||||||
config := loadBridgeConfig()
|
config := loadBridgeConfig()
|
||||||
authorizationHeader := bridgeUpstreamAuthorizationHeader()
|
authorizationHeader := bridgeUpstreamAuthorizationHeader()
|
||||||
|
|
||||||
catalog := map[string]syncedProvider{
|
providers := []struct {
|
||||||
"codex": {
|
id string
|
||||||
ProviderID: "codex",
|
label string
|
||||||
Label: "Codex",
|
yaml string
|
||||||
Endpoint: resolveURL(config.Upstream.CodexURL, "OPENCLAW_CODEX_URL", productionCodexEndpointURL),
|
envKey string
|
||||||
AuthorizationHeader: authorizationHeader,
|
}{
|
||||||
Enabled: true,
|
{"codex", "Codex", config.Upstream.CodexURL, "OPENCLAW_CODEX_URL"},
|
||||||
},
|
{"opencode", "OpenCode", config.Upstream.OpenCodeURL, "OPENCLAW_OPENCODE_URL"},
|
||||||
"opencode": {
|
{"gemini", "Gemini", config.Upstream.GeminiURL, "OPENCLAW_GEMINI_URL"},
|
||||||
ProviderID: "opencode",
|
|
||||||
Label: "OpenCode",
|
|
||||||
Endpoint: resolveURL(config.Upstream.OpenCodeURL, "OPENCLAW_OPENCODE_URL", productionOpenCodeEndpointURL),
|
|
||||||
AuthorizationHeader: authorizationHeader,
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
"gemini": {
|
|
||||||
ProviderID: "gemini",
|
|
||||||
Label: "Gemini",
|
|
||||||
Endpoint: resolveURL(config.Upstream.GeminiURL, "OPENCLAW_GEMINI_URL", productionGeminiEndpointURL),
|
|
||||||
AuthorizationHeader: authorizationHeader,
|
|
||||||
Enabled: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
order := []string{"codex", "opencode", "gemini"}
|
|
||||||
|
catalog := make(map[string]syncedProvider)
|
||||||
|
var order []string
|
||||||
|
|
||||||
|
for _, p := range providers {
|
||||||
|
endpoint := resolveURL(p.yaml, p.envKey)
|
||||||
|
catalog[p.id] = syncedProvider{
|
||||||
|
ProviderID: p.id,
|
||||||
|
Label: p.label,
|
||||||
|
Endpoint: endpoint,
|
||||||
|
AuthorizationHeader: authorizationHeader,
|
||||||
|
Enabled: endpoint != "",
|
||||||
|
}
|
||||||
|
order = append(order, p.id)
|
||||||
|
}
|
||||||
|
|
||||||
return catalog, order
|
return catalog, order
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,7 +100,7 @@ func (s *Server) syncedProviderByID(providerID string) (syncedProvider, bool) {
|
|||||||
func (s *Server) availableProviderCatalog() []Provider {
|
func (s *Server) availableProviderCatalog() []Provider {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
var catalog []Provider
|
var catalog []Provider
|
||||||
for _, id := range s.providerOrder {
|
for _, id := range s.providerOrder {
|
||||||
if p, ok := s.providerCatalog[id]; ok && p.Enabled {
|
if p, ok := s.providerCatalog[id]; ok && p.Enabled {
|
||||||
@ -125,7 +117,7 @@ func (s *Server) availableProviderCatalog() []Provider {
|
|||||||
func (s *Server) availableProviders() []string {
|
func (s *Server) availableProviders() []string {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
var providers []string
|
var providers []string
|
||||||
for _, id := range s.providerOrder {
|
for _, id := range s.providerOrder {
|
||||||
if p, ok := s.providerCatalog[id]; ok && p.Enabled {
|
if p, ok := s.providerCatalog[id]; ok && p.Enabled {
|
||||||
|
|||||||
@ -109,7 +109,7 @@ case "${scenario}" in
|
|||||||
https://xworkmate-bridge.svc.plus/)
|
https://xworkmate-bridge.svc.plus/)
|
||||||
printf 'xworkmate-bridge is running\n'
|
printf 'xworkmate-bridge is running\n'
|
||||||
;;
|
;;
|
||||||
https://acp-server.svc.plus/*/acp/rpc)
|
https://xworkmate-bridge.svc.plus/acp-server/*/acp/rpc)
|
||||||
printf '{"jsonrpc":"2.0","result":{"providers":["ok"]}}\n'
|
printf '{"jsonrpc":"2.0","result":{"providers":["ok"]}}\n'
|
||||||
;;
|
;;
|
||||||
https://xworkmate-bridge.svc.plus/acp/rpc)
|
https://xworkmate-bridge.svc.plus/acp/rpc)
|
||||||
@ -135,7 +135,7 @@ case "${scenario}" in
|
|||||||
https://xworkmate-bridge.svc.plus/)
|
https://xworkmate-bridge.svc.plus/)
|
||||||
printf 'xworkmate-bridge is running\n'
|
printf 'xworkmate-bridge is running\n'
|
||||||
;;
|
;;
|
||||||
https://acp-server.svc.plus/*/acp/rpc)
|
https://xworkmate-bridge.svc.plus/acp-server/*/acp/rpc)
|
||||||
printf '{"jsonrpc":"2.0","result":{"providers":["ok"]}}\n'
|
printf '{"jsonrpc":"2.0","result":{"providers":["ok"]}}\n'
|
||||||
;;
|
;;
|
||||||
https://xworkmate-bridge.svc.plus/acp/rpc)
|
https://xworkmate-bridge.svc.plus/acp/rpc)
|
||||||
@ -191,9 +191,9 @@ run_validate_capture() {
|
|||||||
FAKE_CURL_STATE_DIR="${RUN_STATE_DIR}" \
|
FAKE_CURL_STATE_DIR="${RUN_STATE_DIR}" \
|
||||||
BRIDGE_SERVER_URL="https://xworkmate-bridge.svc.plus" \
|
BRIDGE_SERVER_URL="https://xworkmate-bridge.svc.plus" \
|
||||||
OPENCLAW_URL="wss://openclaw.svc.plus" \
|
OPENCLAW_URL="wss://openclaw.svc.plus" \
|
||||||
CODEX_RPC_URL="https://acp-server.svc.plus/codex/acp/rpc" \
|
CODEX_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/codex/acp/rpc" \
|
||||||
OPENCODE_RPC_URL="https://acp-server.svc.plus/opencode/acp/rpc" \
|
OPENCODE_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/opencode/acp/rpc" \
|
||||||
GEMINI_RPC_URL="https://acp-server.svc.plus/gemini/acp/rpc" \
|
GEMINI_RPC_URL="https://xworkmate-bridge.svc.plus/acp-server/gemini/acp/rpc" \
|
||||||
INTERNAL_SERVICE_TOKEN="test-token" \
|
INTERNAL_SERVICE_TOKEN="test-token" \
|
||||||
bash "${SCRIPT_PATH}" "${IMAGE_REF}" 2>&1
|
bash "${SCRIPT_PATH}" "${IMAGE_REF}" 2>&1
|
||||||
)"
|
)"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user