diff --git a/.github/workflows/agent-network-e2e.yml b/.github/workflows/agent-network-e2e.yml index d78e3bbd3..bf4868871 100644 --- a/.github/workflows/agent-network-e2e.yml +++ b/.github/workflows/agent-network-e2e.yml @@ -51,6 +51,9 @@ jobs: # token (and URL, for gateways) is unset, so partial coverage is fine. OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }} ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }} + # Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire + # shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api. + KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }} VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }} VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }} OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }} diff --git a/e2e/agentnetwork/chat_test.go b/e2e/agentnetwork/chat_test.go index 17ed40d5f..b3da29691 100644 --- a/e2e/agentnetwork/chat_test.go +++ b/e2e/agentnetwork/chat_test.go @@ -39,6 +39,21 @@ func availableProviders() []providerCase { if k := os.Getenv("ANTHROPIC_TOKEN"); k != "" { ps = append(ps, providerCase{name: "anthropic", catalogID: "anthropic_api", upstream: "https://api.anthropic.com", apiKey: k, model: "claude-haiku-4-5", kind: harness.WireMessages}) } + if k := os.Getenv("KIMI_TOKEN"); k != "" { + // Kimi (Moonshot AI) serves two body shapes from the same key: OpenAI + // Chat Completions on the bare host (/v1/...) and the Anthropic + // Messages API under the /anthropic path prefix (the endpoint + // Moonshot's Claude Code guide uses). The platform serves this + // account exactly ONE model — kimi-k3 (kimi-k2-thinking and even + // kimi-latest return resource_not_found_error on both surfaces) — so + // two concurrent provider records would claim the same model and + // route ambiguously. Run the Anthropic shape, the flagship Claude + // Code path; the OpenAI wire shape is covered live by the other + // chat-shaped matrix providers, and Kimi-over-chat passed with + // kimi-k3 before the single-model constraint surfaced (run #73 on + // the kimi feature branch). + ps = append(ps, providerCase{name: "kimi", catalogID: "kimi_api", upstream: "https://api.moonshot.ai/anthropic", apiKey: k, model: "kimi-k3", kind: harness.WireMessages}) + } if k, u := os.Getenv("VERCEL_TOKEN"), os.Getenv("VERCEL_URL"); k != "" && u != "" { ps = append(ps, providerCase{name: "vercel", catalogID: "vercel_ai_gateway", upstream: u, apiKey: k, model: "openai/gpt-4o-mini", kind: harness.WireChat}) } @@ -84,25 +99,46 @@ func availableProviders() []providerCase { } } - // Bedrock: path-routed, bearer auth. Model is a cross-region inference - // profile id (distinct string from the first-party Anthropic case). + // Bedrock: path-routed, bearer auth. Model is the FULL cross-region + // inference-profile id exactly as AWS issues it — region-family prefix + // plus the date/version suffix. A bare or wrong-region id makes Bedrock + // reject the request with "The provided model identifier is invalid" + // before any inference runs. The proxy normalizes this id to the catalog + // key (anthropic.claude-haiku-4-5) for routing/pricing/allowlists. + // Defaults pair eu-central-1 with the eu.* profile; AWS_REGION overrides + // the region and the prefix follows its family. if k := os.Getenv("AWS_BEARER_TOKEN_BEDROCK"); k != "" { region := os.Getenv("AWS_REGION") if region == "" { - region = "us-east-1" + region = "eu-central-1" } // A valid Bedrock inference-profile id (region prefix + date + version), - // overridable per account. `global.` profiles can be invoked from any - // region; set AWS_BEDROCK_MODEL to match the enabled profile for the token. + // overridable per account via AWS_BEDROCK_MODEL (e.g. a `global.` + // profile, invokable from any region). The default derives the + // region-family prefix from the configured region. model := os.Getenv("AWS_BEDROCK_MODEL") if model == "" { - model = "global.anthropic.claude-haiku-4-5-20251001-v1:0" + model = bedrockProfilePrefix(region) + ".anthropic.claude-haiku-4-5-20251001-v1:0" } ps = append(ps, providerCase{name: "bedrock", catalogID: "bedrock_api", upstream: "https://bedrock-runtime." + region + ".amazonaws.com", apiKey: k, model: model, kind: harness.WireBedrock}) } return ps } +// bedrockProfilePrefix maps an AWS region to its cross-region inference +// profile prefix: us-east-1 -> us, eu-central-1 -> eu, ap-southeast-1 -> apac, +// us-gov-west-1 -> us-gov. +func bedrockProfilePrefix(region string) string { + switch { + case strings.HasPrefix(region, "us-gov-"): + return "us-gov" + case strings.HasPrefix(region, "ap-"): + return "apac" + default: + return strings.SplitN(region, "-", 2)[0] + } +} + // providerRequest builds a create request for a matrix provider: enabled, with // a uniquely-priced model for body-routed providers and none for the // path-routed Vertex (whose model lives in the request path). diff --git a/e2e/agentnetwork/guardrail_test.go b/e2e/agentnetwork/guardrail_test.go index d4fe98dda..c8976a732 100644 --- a/e2e/agentnetwork/guardrail_test.go +++ b/e2e/agentnetwork/guardrail_test.go @@ -17,8 +17,10 @@ import ( ) // bedrockRegionPrefixes and bedrockVersionSuffix mirror the proxy's Bedrock -// model normalization (region/inference-profile prefix + version suffix) so the -// provider is registered under the same catalog key the router matches against. +// model normalization (llm.NormalizeBedrockModel, not importable from here — +// proxy/internal): region/inference-profile prefix + version suffix are +// stripped so the provider is registered under the same catalog key the +// router and guardrail allowlist compare against. var ( bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."} bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`) @@ -52,7 +54,9 @@ func catalogModel(pc providerCase) string { func disallowedModel(pc providerCase) string { switch pc.kind { case harness.WireBedrock: - return "us.anthropic.claude-opus-4-8" + // Same profile prefix as the allowed model so only the model name + // differs; the guardrail must deny it before it reaches AWS. + return strings.SplitN(pc.model, ".", 2)[0] + ".anthropic.claude-opus-4-8" case harness.WireVertex: return "claude-opus-4-8@20250101" default: diff --git a/e2e/harness/client.go b/e2e/harness/client.go index 19210349f..e31173374 100644 --- a/e2e/harness/client.go +++ b/e2e/harness/client.go @@ -56,6 +56,11 @@ func StartClient(ctx context.Context, c *Combined, setupKey string) (*Client, er // Match the proxy: the combined relay is WebSocket-only, so the // client must use WS transport to keep a stable relay link to it. "NB_RELAY_TRANSPORT": "ws", + // Lazy connections defer the peer dial until traffic flows, which + // leaves `netbird status` at "0/N Connected" and starves + // WaitProxyPeer. Force eager connections so the proxy peer shows + // Connected as soon as the tunnel is up. + "NB_LAZY_CONN": "false", }, HostConfigModifier: func(hc *container.HostConfig) { hc.CapAdd = append(hc.CapAdd, "NET_ADMIN", "SYS_ADMIN", "SYS_RESOURCE") diff --git a/e2e/harness/proxy.go b/e2e/harness/proxy.go index 8db2c140f..8d5b7e4f2 100644 --- a/e2e/harness/proxy.go +++ b/e2e/harness/proxy.go @@ -77,6 +77,10 @@ func StartProxy(ctx context.Context, c *Combined, proxyToken string) (*Proxy, er // Management is plain HTTP in-cluster, so allow the proxy token to // ride a non-TLS gRPC connection. "NB_PROXY_ALLOW_INSECURE": "true", + // Keep the embedded overlay peer eager (see the client env note): + // lazy connections leave peers unconnected until traffic, which + // starves the harness's WaitProxyPeer. + "NB_LAZY_CONN": "false", // The combined server multiplexes the relay over WebSocket on :8080 // (no QUIC listener). The proxy's embedded relay client defaults to // QUIC, which fails here and flaps the relay link, churning the diff --git a/management/internals/modules/agentnetwork/catalog/catalog.go b/management/internals/modules/agentnetwork/catalog/catalog.go index f82cffae6..f82e94bf3 100644 --- a/management/internals/modules/agentnetwork/catalog/catalog.go +++ b/management/internals/modules/agentnetwork/catalog/catalog.go @@ -420,6 +420,47 @@ var providers = []Provider{ {ID: "mistral-embed", Label: "Mistral Embed", InputPer1k: 0.0001, OutputPer1k: 0, ContextWindow: 8192}, }, }, + { + ID: "kimi_api", + Kind: KindProvider, + Name: "Kimi (Moonshot AI) API", + Description: "Kimi K3 / K2 models via the Moonshot AI platform", + DefaultHost: "api.moonshot.ai", + AuthHeaderName: "Authorization", + AuthHeaderTemplate: "Bearer ${API_KEY}", + DefaultContentType: "application/json", + BrandColor: "#1A1A2E", + // ParserID empty on purpose: Moonshot serves two body shapes on + // the same host and key, and the proxy's URL sniffer dispatches + // both (same pattern as Bifrost). /v1/chat/completions matches + // OpenAIParser; the Anthropic-compatible endpoint the official + // Claude Code guide uses (/anthropic/v1/messages) contains + // "/v1/messages" and matches AnthropicParser. Pinning "openai" + // here would misparse the Claude Code path — the primary way + // teams consume Kimi for coding today. Both endpoints accept the + // same Moonshot key via Authorization: Bearer (Claude Code's + // ANTHROPIC_AUTH_TOKEN rides that header too). + // + // api.moonshot.ai is the international platform; mainland-China + // accounts live on api.moonshot.cn with separate billing — + // operators there override the host on the provider record. The + // kimi.com subscription coding endpoint (api.kimi.com/coding, + // model id "k3") is account-bound seat licensing rather than a + // meterable platform key, so it's deliberately not the default. + ParserID: "", + // Pricing per Moonshot's platform rates at K3 launch (July 2026): + // $3/$15 per MTok with $0.30 cached input, flat across the 1M-token + // window. kimi-k3 is the ONLY model the platform serves newer + // accounts — K2-era ids (kimi-k2-thinking) and even the kimi-latest + // alias return resource_not_found_error, verified live 2026-07-21 — + // so it's the only catalog entry. Grandfathered accounts with K2 + // access can still type those ids on the provider's model rows. + // The consumer app's "K3 Swarm Max" mode is not an API SKU, so it + // doesn't appear here. + Models: []Model{ + {ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000}, + }, + }, { ID: "litellm_proxy", Kind: KindGateway, diff --git a/proxy/internal/llm/pricing/defaults_pricing.yaml b/proxy/internal/llm/pricing/defaults_pricing.yaml index cd5c64fbf..3fba8fe3f 100644 --- a/proxy/internal/llm/pricing/defaults_pricing.yaml +++ b/proxy/internal/llm/pricing/defaults_pricing.yaml @@ -161,6 +161,16 @@ openai: input_per_1k: 0.0001 output_per_1k: 0 + # Kimi / Moonshot AI (kimi_api) — OpenAI-compatible /v1 endpoint. Moonshot + # reports cache hits OpenAI-style when present; cached input is 10% of + # input ($0.30 vs $3.00 per MTok). kimi-k3 is the only model the platform + # serves newer accounts (K2-era ids and kimi-latest 404), matching the + # management catalog. + kimi-k3: + input_per_1k: 0.003 + output_per_1k: 0.015 + cached_input_per_1k: 0.0003 + anthropic: # Claude 4.x family — cache reads ≈10% of input, cache writes ≈125% of input. # Pricing source: Anthropic's current published rates per million tokens, @@ -206,6 +216,20 @@ anthropic: cache_read_per_1k: 0.0001 cache_creation_per_1k: 0.00125 + # Kimi / Moonshot AI (kimi_api) via the Anthropic-compatible endpoint + # (/anthropic/v1/messages — the official Claude Code setup). Same rates + # as the OpenAI-shape entry above. "kimi-k3[1m]" is the model id some + # Claude Code guides set for the 1M-context alias; priced identically so + # cost metering doesn't silently skip those requests. + kimi-k3: + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + "kimi-k3[1m]": + input_per_1k: 0.003 + output_per_1k: 0.015 + cache_read_per_1k: 0.0003 + bedrock: # AWS Bedrock model ids, normalised by the request parser (cross-region # inference-profile prefix + version/throughput suffix stripped), e.g.