feat(subscription): 接入 xAI OAuth 订阅渠道

This commit is contained in:
tbphp committed 2026-08-18 21:22:13 +08:00
1 parent 020d9e9f02
commit d1fd07ce3e
55 files changed
+4382 -72

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ HOST=127.0.0.1
BIND_ADDRESS=127.0.0.1
# Subscription browser OAuth uses provider-fixed loopback callbacks:
# Codex localhost:1455, Claude localhost:54545, and Antigravity localhost:51121.
# Grok uses device OAuth and does not require a callback port.
OAUTH_CALLBACK_BIND_ADDRESS=127.0.0.1
PORT=3001
GRACEFUL_SHUTDOWN_TIMEOUT=10
+2 -2
View File
@@ -45,14 +45,14 @@ For the maintained 1.4.x release documentation, visit the [official documentatio
- **Four client protocols:** OpenAI Completions, OpenAI Responses, Anthropic Messages, and Gemini requests keep their public endpoints. Each channel declares its supported native and converted operations; unsupported feature combinations fail before dispatch.
- **Channel and traffic management:** Users select a searchable code-defined channel, then enter models and encrypted credentials. GPT-Load owns AccessKey filtering, cross-Group credential scheduling, retry decisions, health state, cooldown, blacklist, and automatic weights.
- **Provider execution:** The official Bifrost Core Go SDK maintains provider-specific authentication, request/response conversion, streaming, normalized usage, and provider errors. GPT-Load pins one selected credential per logical attempt and disables Bifrost's configured retry and fallback policy.
- **Codex and Claude subscriptions:** OpenAI Groups can use API keys or Codex browser OAuth/CPA JSON accounts. The separate Claude channel accepts Claude browser OAuth/CPA JSON accounts, while the existing Anthropic channel continues to use API keys. Both subscription channels share GPT-Load's scheduling, retry, health, weight, and affinity behavior, and their credentials stay encrypted.
- **Subscription channels:** Codex, Claude, Antigravity, and Grok are independent subscription-backed channels alongside the existing API-key channels. Codex, Claude, and Antigravity use browser OAuth/CPA JSON; Grok uses xAI device OAuth/CPA JSON. All reuse GPT-Load's encrypted credential lifecycle, scheduling, retry, health, weight, and affinity behavior.
- **Control and observability:** runtime settings, route inspection, health views, RequestLog, and a Chinese, English, and Japanese admin UI.
- **Usage and estimated cost:** usage extraction for the four protocols where the endpoint returns generation usage, 24-hour/30-day reports, per-request quality states, exact four-slot model prices synchronized from Models.dev where available, and user-managed prices.
The M3 control-plane UI and M4 usage/pricing scope are present in the local candidate, but their formal exit and public release are unfinished. Prices and costs are best-effort **estimates** derived from upstream usage and the active pricing rules. They are not a billing ledger, invoice, or provider bill, and historical requests are not repriced.
> [!WARNING]
> Claude subscription OAuth and execution rely on a pinned, compatibility-sensitive CPA contract rather than the Anthropic API-key contract. Upstream changes may require a reviewed CPA bridge update; validate the channel with an authorized account before production use.
> Subscription OAuth execution relies on pinned, compatibility-sensitive CPA contracts rather than the corresponding API-key contracts. Upstream changes may require a reviewed CPA bridge update; validate each enabled channel with an authorized account before production use.
## 2.0.0 support boundaries
+2 -2
View File
@@ -45,14 +45,14 @@ GPT-Load 是一个用 Go 构建的自托管多渠道 AI 网关。它通过单个
- **四种客户端协议**:继续提供 OpenAI Completions、OpenAI Responses、Anthropic Messages 与 Gemini 入口。每个渠道明确声明可原生执行或转换的 operation;不支持的能力组合会在发送前拒绝。
- **渠道与流量管理**:用户只需搜索并选择代码内置渠道,再填写模型和加密凭据。GPT-Load 继续负责 AccessKey 筛选、跨 Group 的凭据调度、重试决策、健康状态、cooldown、blacklist 与自动权重。
- **Provider 执行**:官方 Bifrost Core Go SDK 负责不同 Provider 的认证、请求/响应转换、流式、usage 归一化和错误解析。每个逻辑 attempt 由 GPT-Load 固定一个凭据,并关闭 Bifrost 可配置的重试和 fallback。
- **Codex 与 Claude 订阅**:OpenAI Group 可选择 API Key,或通过浏览器 OAuth/CPA JSON 接入 Codex 订阅账号;独立的 Claude 渠道通过浏览器 OAuth/CPA JSON 接入 Claude 订阅账号,原 Anthropic 渠道继续使用 API Key。两类订阅渠道统一复用 GPT-Load 的调度、重试、健康、权重和亲和机制,凭据始终加密。
- **订阅类型渠道**:Codex、Claude、Antigravity 与 Grok 都是独立于现有 API Key 渠道的订阅渠道。Codex、Claude、Antigravity 使用浏览器 OAuth/CPA JSON,Grok 使用 xAI Device OAuth/CPA JSON;统一复用 GPT-Load 的加密凭据生命周期、调度、重试、健康、权重和亲和机制。
- **控制与可观测性**:运行设置、路由检查、健康视图、RequestLog,以及中文、英文、日文管理 UI。
- **用量与估算成本**:对四种协议中会返回生成 usage 的接口进行采集,提供 24 小时/30 天汇总、明细质量状态、可用时从 Models.dev 同步的精确四槽模型价格,以及用户管理的价格。
M3 控制面 UI 与 M4 用量/定价范围已经进入本地候选,但正式出口与公开发布尚未完成。价格和成本是基于上游返回 usage 与当前价格规则的 best-effort **估算**,不是 billing ledger、发票或供应商账单,也不会对历史请求重新计价。
> [!WARNING]
> Claude 订阅 OAuth 与执行依赖固定版本 CPA 提供的兼容性协议,不属于 Anthropic API Key 契约。上游变更可能需要审查并更新 CPA bridge;生产使用前应通过已授权账号完成真实合同验证。
> 订阅 OAuth 执行依赖固定版本 CPA 提供的兼容性协议,不属于同品牌 API Key 契约。上游变更可能需要审查并更新 CPA bridge;生产使用前应通过已授权账号完成各渠道真实合同验证。
## 2.0.0 支持边界
+2 -2
View File
@@ -45,14 +45,14 @@ GPT-Loadは、Goで構築されたセルフホスト型のマルチチャネルA
- **4つのクライアントプロトコル**:OpenAI Completions、OpenAI Responses、Anthropic Messages、Geminiの公開エンドポイントを維持します。各チャネルはネイティブまたは変換可能なoperationを明示し、未対応の機能組み合わせは送信前に拒否します。
- **チャネルとトラフィックの管理**:ユーザーは検索可能なコード定義チャネルを選び、モデルと暗号化認証情報を入力します。GPT-LoadがAccessKeyフィルター、Groupをまたぐ認証情報スケジューリング、リトライ判断、ヘルス、cooldown、blacklist、自動重み付けを所有します。
- **Provider実行**:公式Bifrost Core Go SDKがProvider固有の認証、request/response変換、streaming、usage正規化、エラー解析を担当します。GPT-Loadは論理attemptごとに1つの認証情報を固定し、Bifrostの設定可能なretryとfallbackを無効にします。
- **CodexとClaudeのサブスクリプション**:OpenAI GroupではAPI Key、またはブラウザーOAuth/CPA JSONによるCodexアカウントを選択できます。独立したClaudeチャネルはブラウザーOAuth/CPA JSONでClaudeアカウントを受け付け、既存のAnthropicチャネルは引き続きAPI Keyを使用します。両サブスクリプションチャネルはGPT-Loadのスケジューリング、retry、health、weight、affinityを共有し、認証情報は暗号化されます。
- **サブスクリプションチャネル**:Codex、Claude、Antigravity、Grokは、既存のAPI Keyチャネルとは独立したサブスクリプションチャネルです。Codex、Claude、AntigravityはブラウザーOAuth/CPA JSON、GrokはxAI Device OAuth/CPA JSONを使用し、暗号化された認証情報ライフサイクル、スケジューリング、retry、health、weight、affinityを共通利用します。
- **制御と可観測性**:ランタイム設定、ルート検査、ヘルス表示、RequestLog、中国語・英語・日本語の管理UI。
- **使用量と推定コスト**:4プロトコルのうち生成usageを返すエンドポイントからusageを取得し、24時間/30日レポート、リクエスト単位の品質状態、利用可能な場合にModels.devから同期する完全一致の4価格スロット、ユーザー管理価格を提供します。
M3のコントロールプレーンUIとM4のusage/pricing範囲はローカル候補に含まれていますが、正式なリリース判断・承認と公開は未完了です。価格とコストは、上流から返されたusageと現在の価格ルールに基づくbest-effortの**推定値**です。billing ledger、請求書、プロバイダー請求ではなく、過去のリクエストを再計算することもありません。
> [!WARNING]
> ClaudeサブスクリプションのOAuthと実行は、Anthropic API Key契約ではなく、固定バージョンCPAの互換性に敏感な契約に依存します。上流変更時はCPA bridgeのレビューと更新が必要になる可能性があるため、本番利用前に許可されたアカウントで実契約を検証してください。
> サブスクリプションOAuthの実行は、同ブランドのAPI Key契約ではなく、固定バージョンCPAの互換性に敏感な契約に依存します。上流変更時はCPA bridgeのレビューと更新が必要になる可能性があるため、本番利用前に許可されたアカウントで各チャネルの実契約を検証してください。
## 2.0.0のサポート境界
+1 -1
View File
@@ -26,7 +26,7 @@ The complete Apache License 2.0 text is distributed in
- License: MIT License
GPT-Load uses a pinned, execution-only embedded adapter around CLIProxyAPI's
Codex, Claude, and Antigravity OAuth and HTTP executor code. GPT-Load retains ownership of
Codex, Claude, Antigravity, and xAI OAuth and HTTP executor code. GPT-Load retains ownership of
credential storage, account selection, retry, health, affinity, logging, and
usage policy; the embedded adapter does not use CLIProxyAPI's manager, pool,
file store, WebSocket executor, fallback, or automatic retry.
+1
View File
@@ -13,6 +13,7 @@ func builtInModules() []spec.Module {
modules.Codex(),
modules.Claude(),
modules.Antigravity(),
modules.Grok(),
modules.Anthropic(),
modules.Gemini(),
modules.AzureOpenAI(),
+3
View File
@@ -25,6 +25,7 @@ const (
Codex = spec.Codex
Claude = spec.Claude
Antigravity = spec.Antigravity
Grok = spec.Grok
Anthropic = spec.Anthropic
Gemini = spec.Gemini
AzureOpenAI = spec.AzureOpenAI
@@ -74,6 +75,7 @@ type AuthorizationMethod = spec.AuthorizationMethod
const (
AuthorizationBrowserOAuth = spec.AuthorizationBrowserOAuth
AuthorizationDeviceOAuth = spec.AuthorizationDeviceOAuth
AuthorizationOAuthFile = spec.AuthorizationOAuthFile
)
@@ -185,6 +187,7 @@ const (
ProviderCodex = spec.ProviderCodex
ProviderClaude = spec.ProviderClaude
ProviderAntigravity = spec.ProviderAntigravity
ProviderGrok = spec.ProviderGrok
ProviderAnthropic = spec.ProviderAnthropic
ProviderGemini = spec.ProviderGemini
ProviderOpenAICompatible = spec.ProviderOpenAICompatible
+14
View File
@@ -50,6 +50,20 @@ func TestCompilerRejectsUnknownAuthorizationMethod(t *testing.T) {
}
}
func TestCompilerAcceptsDeviceOAuthAuthorizationMethod(t *testing.T) {
t.Parallel()
codex := findModule(t, builtInModules(), Codex)
codex.Definition.Connection.AuthorizationMethods = []spec.AuthorizationMethod{
"device_oauth",
spec.AuthorizationOAuthFile,
}
if _, err := compileBuiltInModules([]spec.Module{codex}); err != nil {
t.Fatalf("compileBuiltInModules() rejected device OAuth: %v", err)
}
}
func TestCompilerRejectsQuotaPriorityWithoutObservation(t *testing.T) {
t.Parallel()
@@ -16,6 +16,7 @@ func TestFinalRegistryContainsOnlyApprovedChannels(t *testing.T) {
Codex,
Claude,
Antigravity,
Grok,
Anthropic,
Gemini,
AzureOpenAI,
+67
View File
@@ -0,0 +1,67 @@
package channel
import (
"reflect"
"testing"
"gpt-load/internal/channel/modules"
"gpt-load/internal/channel/spec"
"gpt-load/internal/execution"
"gpt-load/internal/protocol"
)
func TestGrokModuleDeclaresCompleteSubscriptionContract(t *testing.T) {
t.Parallel()
definitions, err := compileBuiltInModules([]spec.Module{modules.Grok()})
if err != nil {
t.Fatal(err)
}
registry, err := newRegistry(definitions)
if err != nil {
t.Fatal(err)
}
descriptor, ok := registry.Get(Grok)
if !ok {
t.Fatal("Grok descriptor is missing")
}
if descriptor.Connection.Type != string(spec.ConnectionSubscription) ||
!reflect.DeepEqual(descriptor.Connection.AuthorizationMethods, []AuthorizationMethod{
AuthorizationDeviceOAuth,
AuthorizationOAuthFile,
}) {
t.Fatalf("Grok connection = %#v", descriptor.Connection)
}
target, err := registry.Resolve(Grok, nil)
if err != nil {
t.Fatal(err)
}
if target.ProviderKind != ProviderGrok || target.CatalogProviderID != "" {
t.Fatalf("Grok target = %#v", target)
}
bindings, ok := registry.CapabilityBindings(Grok)
if !ok || bindings.SubscriptionDriver != modules.GrokSubscriptionDriver ||
bindings.ModelDiscovery != modules.GrokModelDiscovery || bindings.QuotaObservation != "" {
t.Fatalf("Grok capabilities = %#v, %t", bindings, ok)
}
for _, test := range []struct {
clientProtocol protocol.Protocol
operation execution.Operation
mode RouteMode
}{
{protocol.OpenAIResponses, execution.OperationResponsesCreate, RouteNative},
{protocol.OpenAIResponses, execution.OperationResponsesInputTokens, RouteNative},
{protocol.OpenAICompletions, execution.OperationChatCompletion, RouteConverted},
{protocol.Anthropic, execution.OperationChatCompletion, RouteConverted},
{protocol.Anthropic, execution.OperationCountTokens, RouteConverted},
{protocol.Gemini, execution.OperationChatCompletion, RouteConverted},
{protocol.Gemini, execution.OperationCountTokens, RouteConverted},
} {
if mode, exists := target.Mode(test.clientProtocol, test.operation); !exists || mode != test.mode {
t.Fatalf("Grok %q/%q mode = %q, %t; want %q", test.clientProtocol, test.operation, mode, exists, test.mode)
}
}
if _, exists := target.Mode(protocol.OpenAIResponses, execution.OperationResponsesCompact); exists {
t.Fatal("Grok unexpectedly declares Responses Compact")
}
}
+52
View File
@@ -0,0 +1,52 @@
package modules
import (
"gpt-load/internal/channel/spec"
"gpt-load/internal/execution"
"gpt-load/internal/protocol"
)
const (
GrokSubscriptionDriver spec.SubscriptionDriverID = "grok"
GrokModelDiscovery spec.UtilityID = "grok_models"
)
// Grok declares the subscription-backed xAI OAuth channel.
func Grok() spec.Module {
return spec.Module{Definition: spec.Definition{
ID: spec.Grok,
Name: "Grok",
Mark: "GR",
Icon: "xai",
SearchTerms: []string{"subscription", "oauth", "xai", "grok"},
Description: "xAI Grok subscription",
Connection: spec.Connection{
Type: spec.ConnectionSubscription,
CredentialInput: "authorization",
AuthorizationMethods: []spec.AuthorizationMethod{
spec.AuthorizationDeviceOAuth,
spec.AuthorizationOAuthFile,
},
},
Params: []spec.Field{},
Credentials: []spec.Field{},
Provider: spec.ProviderBinding{
ProviderKind: spec.ProviderGrok,
EndpointPolicy: spec.EndpointNone,
},
Routes: []spec.Route{
spec.NewRoute(protocol.OpenAIResponses, execution.OperationResponsesCreate, execution.RouteNative),
spec.NewRoute(protocol.OpenAIResponses, execution.OperationResponsesInputTokens, execution.RouteNative),
spec.NewRoute(protocol.OpenAICompletions, execution.OperationChatCompletion, execution.RouteConverted),
spec.NewRoute(protocol.Anthropic, execution.OperationChatCompletion, execution.RouteConverted),
spec.NewRoute(protocol.Anthropic, execution.OperationCountTokens, execution.RouteConverted),
spec.NewRoute(protocol.Gemini, execution.OperationChatCompletion, execution.RouteConverted),
spec.NewRoute(protocol.Gemini, execution.OperationCountTokens, execution.RouteConverted),
},
Capabilities: spec.CapabilityBindings{
SubscriptionDriver: GrokSubscriptionDriver,
ModelDiscovery: GrokModelDiscovery,
},
Scheduling: spec.SchedulingPolicy{QuotaPriority: false},
}}
}
+13 -7
View File
@@ -18,6 +18,7 @@ func TestRegistryHasStableBuiltInOrderAndSearch(t *testing.T) {
Codex,
Claude,
Antigravity,
Grok,
Anthropic,
Gemini,
ID("azure_openai"),
@@ -40,8 +41,8 @@ func TestRegistryHasStableBuiltInOrderAndSearch(t *testing.T) {
if got := descriptorIDs(registry.Search("GeMiNi")); !reflect.DeepEqual(got, []ID{Gemini}) {
t.Fatalf("Search(gemini) IDs = %v", got)
}
if got := descriptorIDs(registry.Search("subscription")); !reflect.DeepEqual(got, []ID{Codex, Claude, Antigravity}) {
t.Fatalf("Search(subscription) IDs = %v, want [codex claude antigravity]", got)
if got := descriptorIDs(registry.Search("subscription")); !reflect.DeepEqual(got, []ID{Codex, Claude, Antigravity, Grok}) {
t.Fatalf("Search(subscription) IDs = %v, want [codex claude antigravity grok]", got)
}
if got := descriptorIDs(registry.Search("compatible")); !reflect.DeepEqual(got, []ID{OpenAICompatible}) {
t.Fatalf("Search(compatible) IDs = %v", got)
@@ -65,7 +66,7 @@ func TestRegistryHasStableBuiltInOrderAndSearch(t *testing.T) {
first := registry.List()
first[0].ClientProtocols[0] = protocol.Protocol("mutated")
first[0].ParamFields = append(first[0].ParamFields, FieldDescriptor{Key: "mutated"})
vertexIndex := 8
vertexIndex := 9
if first[vertexIndex].ID != GoogleVertex || first[vertexIndex].ParamFields[0].DefaultValue == nil {
t.Fatalf("unexpected Vertex descriptor = %#v", first[vertexIndex])
}
@@ -250,12 +251,14 @@ func TestRegistryPublicDescriptorUsesSingularConnectionAndExplicitRoutes(t *test
}
}
func TestOnlyCodexSupportsSubscription(t *testing.T) {
func TestSubscriptionChannelsRemainSeparateFromAPIKeyChannels(t *testing.T) {
t.Parallel()
registry := NewRegistry()
if !registry.SupportsConnectionType(Codex, "subscription") {
t.Fatal("Codex subscription is not supported")
for _, id := range []ID{Codex, Claude, Antigravity, Grok} {
if !registry.SupportsConnectionType(id, "subscription") {
t.Fatalf("%s subscription is not supported", id)
}
}
if registry.SupportsConnectionType(OpenAI, "subscription") ||
registry.SupportsConnectionType(Anthropic, "subscription") ||
@@ -273,7 +276,7 @@ func TestOnlyCodexSupportsSubscription(t *testing.T) {
func TestRegistryReturnsExactCatalogProviderMappingWithoutResolvingParams(t *testing.T) {
registry := NewRegistry()
for id, want := range map[ID]string{
OpenAI: "openai", Codex: "", Claude: "", Anthropic: "anthropic", Gemini: "google",
OpenAI: "openai", Codex: "", Claude: "", Antigravity: "", Grok: "", Anthropic: "anthropic", Gemini: "google",
ID("azure_openai"): "azure", ID("aws_bedrock"): "amazon-bedrock", ID("google_vertex"): "google-vertex",
OpenAICompatible: "",
} {
@@ -295,6 +298,9 @@ func TestRegistryReturnsProviderKindWithoutExposingItInDescriptor(t *testing.T)
if got, ok := registry.ProviderKind(Codex); !ok || string(got) != "codex" {
t.Fatalf("ProviderKind(codex) = %q, %t, want codex, true", got, ok)
}
if got, ok := registry.ProviderKind(Grok); !ok || got != ProviderGrok {
t.Fatalf("ProviderKind(grok) = %q, %t", got, ok)
}
if got, ok := registry.ProviderKind(ID("missing")); ok || got != "" {
t.Fatalf("ProviderKind(missing) = %q, %t", got, ok)
}
+1 -1
View File
@@ -41,7 +41,7 @@ func TestBuiltInRouteGolden(t *testing.T) {
}
digest := fmt.Sprintf("%x", sha256.Sum256([]byte(strings.Join(got, "\n"))))
const wantDigest = "fb123f1bc2a0afdb64a29e525eab265933a4c7ab6ba8057d73303293569887f2"
const wantDigest = "4fb788d9792b871c7e2ea00d073756fb3464841370b85dcbfbbb9a904496dea8"
if digest != wantDigest {
t.Fatalf("built-in routes changed: digest = %s, want %s\n%s", digest, wantDigest, strings.Join(got, "\n"))
}
+5 -1
View File
@@ -17,6 +17,7 @@ const (
Codex ID = "codex"
Claude ID = "claude"
Antigravity ID = "antigravity"
Grok ID = "grok"
Anthropic ID = "anthropic"
Gemini ID = "gemini"
AzureOpenAI ID = "azure_openai"
@@ -42,6 +43,7 @@ const (
ProviderCodex ProviderKind = "codex"
ProviderClaude ProviderKind = "claude"
ProviderAntigravity ProviderKind = "antigravity"
ProviderGrok ProviderKind = "grok"
ProviderAnthropic ProviderKind = "anthropic"
ProviderGemini ProviderKind = "gemini"
ProviderOpenAICompatible ProviderKind = "openai_compatible"
@@ -90,6 +92,7 @@ func (kind ProviderKind) Valid() bool {
ProviderCodex,
ProviderClaude,
ProviderAntigravity,
ProviderGrok,
ProviderAnthropic,
ProviderGemini,
ProviderOpenAICompatible,
@@ -143,13 +146,14 @@ type AuthorizationMethod string
const (
AuthorizationBrowserOAuth AuthorizationMethod = "browser_oauth"
AuthorizationDeviceOAuth AuthorizationMethod = "device_oauth"
AuthorizationOAuthFile AuthorizationMethod = "oauth_file"
)
// Valid reports whether the authorization method is part of the public channel contract.
func (method AuthorizationMethod) Valid() bool {
switch method {
case AuthorizationBrowserOAuth, AuthorizationOAuthFile:
case AuthorizationBrowserOAuth, AuthorizationDeviceOAuth, AuthorizationOAuthFile:
return true
default:
return false
+1
View File
@@ -228,6 +228,7 @@ func newProviderAdapterRegistry(
{ProviderKind: channel.ProviderCodex, Adapter: cpa},
{ProviderKind: channel.ProviderClaude, Adapter: cpa},
{ProviderKind: channel.ProviderAntigravity, Adapter: cpa},
{ProviderKind: channel.ProviderGrok, Adapter: cpa},
}
return provideradapter.NewRegistry(channels, bindings)
}
@@ -14,6 +14,7 @@ import (
"gpt-load/internal/storage/models"
"gpt-load/internal/subscription/providers/antigravity"
"gpt-load/internal/subscription/providers/codex"
"gpt-load/internal/subscription/providers/grok"
subscriptionruntime "gpt-load/internal/subscription/runtime"
)
@@ -165,6 +166,68 @@ func TestAntigravityDiscoveryUsesOnlySubscriptionModelsAndReferencePrices(t *tes
}
}
func TestGrokDiscoveryUsesOnlySubscriptionModelsAndReferencePrices(t *testing.T) {
fixture := newServiceFixture(t)
fixture.catalogRuntime.Publish(&catalog.Snapshot{Providers: map[string]catalog.Provider{
"xai": {
ID: "xai", Name: "xAI", Models: map[string]catalog.Model{
"grok-4.3": {
ID: "grok-4.3", Name: "xAI catalog name",
Cost: &catalog.ModelCost{Prices: pricing.Prices{Input: priceTestValue(1)}},
},
"xai-catalog-only": {
ID: "xai-catalog-only", Name: "xAI catalog only",
Cost: &catalog.ModelCost{Prices: pricing.Prices{Input: priceTestValue(2)}},
},
},
},
}})
canonical, err := grok.MarshalCredential(grok.Credential{
Type: grok.Provider, AccessToken: "access", RefreshToken: "refresh", AccountID: "xai-account",
Email: "grok@example.com", Expire: "2030-01-01T00:00:00Z", TokenEndpoint: "https://auth.x.ai/oauth/token",
})
if err != nil {
t.Fatal(err)
}
driver, ok := fixture.service.subscriptions.Driver(channel.Grok)
if !ok {
t.Fatal("Grok driver is unavailable")
}
credential, err := driver.Parse(canonical)
if err != nil {
t.Fatal(err)
}
stage, err := fixture.service.persistReadyCredentialStage(t.Context(), channel.Grok, "oauth_file", credential)
if err != nil {
t.Fatal(err)
}
fixture.service.discoverSubscriptionModels = func(
_ context.Context,
channelID channel.ID,
_ subscriptionruntime.Credential,
) ([]string, error) {
if channelID != channel.Grok {
t.Fatalf("channel = %q, want Grok", channelID)
}
return []string{"grok-4.3"}, nil
}
got, err := fixture.service.DiscoverModels(t.Context(), ModelDiscoveryRequest{
ChannelID: channel.Grok, StagedCredentialID: stage.StageID, ConnectionType: "subscription",
})
if err != nil {
t.Fatal(err)
}
xai := "xAI"
want := []ModelCandidate{{
ID: "grok-4.3", Name: "grok-4.3", Sources: []string{"live"},
PricingStatus: PricingStatusConfigured, PricingSource: &xai,
}}
if !reflect.DeepEqual(got.Models, want) {
t.Fatalf("Grok candidates = %#v, want %#v", got.Models, want)
}
}
func TestDraftDiscoveryMergesLiveAndLocalCatalogByExactIDWithoutURLInference(t *testing.T) {
fixture := newServiceFixture(t)
fixture.catalogRuntime.Publish(&catalog.Snapshot{Providers: map[string]catalog.Provider{
@@ -13,6 +13,7 @@ import (
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
@@ -77,6 +78,60 @@ func TestCredentialStageRoutesRequireAuthAndNeverReturnSecrets(t *testing.T) {
}
}
func TestDeviceAuthorizationRouteReturnsSafeChallengeAndPollsByPOST(t *testing.T) {
initControlI18n(t)
fixture := newServiceFixture(t)
now := fixture.service.now().UTC()
fixture.service.now = func() time.Time { return now }
fixture.service.beginDeviceAuthorization = func(context.Context, channel.ID) (subscriptionruntime.DeviceAuthorization, error) {
return subscriptionruntime.DeviceAuthorization{
VerificationURL: "https://auth.x.ai/device", UserCode: "ABCD-EFGH",
DriverState: []byte(`{"device_code":"device-secret"}`), ExpiresAt: now.Add(20 * time.Minute),
PollInterval: time.Second,
}, nil
}
fixture.service.pollDeviceAuthorization = func(context.Context, channel.ID, []byte) (subscriptionruntime.DeviceAuthorizationPoll, error) {
return subscriptionruntime.DeviceAuthorizationPoll{
Status: subscriptionruntime.DeviceAuthorizationPending,
DriverState: []byte(`{"device_code":"next-secret"}`), PollInterval: 5 * time.Second,
}, nil
}
listenerCalled := false
fixture.service.oauthCallback.listen = func(string, string) (net.Listener, error) {
listenerCalled = true
return nil, fmt.Errorf("device OAuth must not start a listener")
}
engine := gin.New()
NewServer(&config.Config{AuthKey: "test-auth-key"}, fixture.service).RegisterRoutes(engine)
request := httptest.NewRequest(http.MethodPost, "/api/credential-stages/authorizations", strings.NewReader(`{"channel_id":"grok"}`))
request.Header.Set("Authorization", "Bearer test-auth-key")
request.Header.Set("Content-Type", "application/json")
response := httptest.NewRecorder()
engine.ServeHTTP(response, request)
if response.Code != http.StatusOK || listenerCalled || strings.Contains(response.Body.String(), "device-secret") {
t.Fatalf("device begin response = %d %s, listener=%t", response.Code, response.Body, listenerCalled)
}
var envelope struct {
Data CredentialStageResult `json:"data"`
}
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if envelope.Data.AuthorizationMethod != string(channel.AuthorizationDeviceOAuth) || envelope.Data.UserCode != "ABCD-EFGH" {
t.Fatalf("device challenge = %#v", envelope.Data)
}
now = now.Add(time.Second)
poll := httptest.NewRequest(http.MethodPost, "/api/credential-stages/"+envelope.Data.StageID+"/device-poll", nil)
poll.Header.Set("Authorization", "Bearer test-auth-key")
pollResponse := httptest.NewRecorder()
engine.ServeHTTP(pollResponse, poll)
if pollResponse.Code != http.StatusOK || strings.Contains(pollResponse.Body.String(), "next-secret") {
t.Fatalf("device poll response = %d %s", pollResponse.Code, pollResponse.Body)
}
}
func TestBeginCredentialAuthorizationAllowsManualCallbackWhenListenerIsUnavailable(t *testing.T) {
initControlI18n(t)
fixture := newServiceFixture(t)
+336 -19
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"net/http"
"net/url"
"sort"
"strings"
"time"
@@ -23,8 +24,11 @@ import (
const (
credentialStageReadyTTL = 30 * time.Minute
credentialStageAuthTTL = 5 * time.Minute
credentialStageDeviceMaxTTL = 30 * time.Minute
credentialStageTombstoneTTL = 24 * time.Hour
maxOAuthFileBytes = 64 * 1024
maxDeviceAuthorizationURL = 4096
maxDeviceAuthorizationCode = 128
stagedSubscriptionSchemaV2 = 2
)
@@ -63,13 +67,27 @@ type CredentialStageAccount struct {
}
type CredentialStageResult struct {
StageID string `json:"stage_id"`
Status string `json:"status"`
AuthorizationURL string `json:"authorization_url,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty"`
Account CredentialStageAccount `json:"account"`
ExpiresAtMS int64 `json:"expires_at_ms"`
ErrorCode string `json:"error_code,omitempty"`
StageID string `json:"stage_id"`
Status string `json:"status"`
AuthorizationMethod string `json:"authorization_method,omitempty"`
AuthorizationURL string `json:"authorization_url,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty"`
UserCode string `json:"user_code,omitempty"`
NextPollAtMS int64 `json:"next_poll_at_ms,omitempty"`
Account CredentialStageAccount `json:"account"`
ExpiresAtMS int64 `json:"expires_at_ms"`
ErrorCode string `json:"error_code,omitempty"`
}
type credentialStageAuthorizationSummary struct {
AuthorizationURL string `json:"authorization_url"`
UserCode string `json:"user_code"`
NextPollAtMS int64 `json:"next_poll_at_ms"`
PollIntervalMS int64 `json:"poll_interval_ms"`
}
type credentialStageSafeSummary struct {
Authorization *credentialStageAuthorizationSummary `json:"authorization,omitempty"`
}
type stagedSubscriptionPayload struct {
@@ -349,13 +367,25 @@ func (s *Service) BeginCredentialAuthorization(
if s == nil || s.db == nil || s.encryption == nil {
return CredentialStageResult{}, app_errors.ErrValidation
}
if s.channelRegistry == nil ||
!s.channelRegistry.SupportsAuthorizationMethod(channelID, channel.AuthorizationBrowserOAuth) {
if s.channelRegistry == nil {
return CredentialStageResult{}, app_errors.ErrValidation
}
if _, err := s.subscriptionDriver(channelID); err != nil {
return CredentialStageResult{}, err
}
if s.channelRegistry.SupportsAuthorizationMethod(channelID, channel.AuthorizationBrowserOAuth) {
return s.beginBrowserCredentialAuthorization(ctx, channelID)
}
if s.channelRegistry.SupportsAuthorizationMethod(channelID, channel.AuthorizationDeviceOAuth) {
return s.beginDeviceCredentialAuthorization(ctx, channelID)
}
return CredentialStageResult{}, app_errors.ErrValidation
}
func (s *Service) beginBrowserCredentialAuthorization(
ctx context.Context,
channelID channel.ID,
) (CredentialStageResult, error) {
if s.beginSubscriptionAuthorization == nil {
return CredentialStageResult{}, app_errors.ErrAuthorizationUnavailable
}
@@ -390,12 +420,98 @@ func (s *Service) BeginCredentialAuthorization(
return CredentialStageResult{}, app_errors.ParseDBError(err)
}
return CredentialStageResult{
StageID: row.ID, Status: string(row.Status), AuthorizationURL: authorization.URL,
RedirectURI: authorization.RedirectURI,
Account: CredentialStageAccount{}, ExpiresAtMS: row.ExpiresAtMS,
StageID: row.ID, Status: string(row.Status), AuthorizationMethod: row.AuthorizationMethod,
AuthorizationURL: authorization.URL,
RedirectURI: authorization.RedirectURI,
Account: CredentialStageAccount{}, ExpiresAtMS: row.ExpiresAtMS,
}, nil
}
func (s *Service) beginDeviceCredentialAuthorization(
ctx context.Context,
channelID channel.ID,
) (CredentialStageResult, error) {
if s.beginDeviceAuthorization == nil {
return CredentialStageResult{}, app_errors.ErrAuthorizationUnavailable
}
beginContext, cancelBegin := context.WithTimeout(ctx, defaultSubscriptionControlTimeout)
defer cancelBegin()
authorization, err := s.beginDeviceAuthorization(beginContext, channelID)
if err != nil {
return CredentialStageResult{}, app_errors.ErrAuthorizationUnavailable
}
now := s.now().UTC()
verificationURL := strings.TrimSpace(authorization.VerificationURL)
userCode := strings.TrimSpace(authorization.UserCode)
parsedURL, parseErr := url.Parse(verificationURL)
pollInterval, intervalOK := normalizeDevicePollInterval(authorization.PollInterval)
if parseErr != nil || !parsedURL.IsAbs() || parsedURL.Opaque != "" || parsedURL.Host == "" ||
!strings.EqualFold(parsedURL.Scheme, "https") || parsedURL.User != nil || parsedURL.Fragment != "" ||
len(verificationURL) > maxDeviceAuthorizationURL || !validDeviceAuthorizationCode(userCode) ||
len(authorization.DriverState) == 0 || len(authorization.DriverState) > maxOAuthFileBytes ||
!intervalOK || !authorization.ExpiresAt.After(now) ||
authorization.ExpiresAt.After(now.Add(credentialStageDeviceMaxTTL)) {
return CredentialStageResult{}, app_errors.ErrAuthorizationUnavailable
}
nextPollAtMS := now.Add(pollInterval).UnixMilli()
summaryJSON, err := json.Marshal(credentialStageSafeSummary{Authorization: &credentialStageAuthorizationSummary{
AuthorizationURL: verificationURL,
UserCode: userCode,
NextPollAtMS: nextPollAtMS,
PollIntervalMS: pollInterval.Milliseconds(),
}})
if err != nil {
return CredentialStageResult{}, app_errors.ErrInternalServer
}
payload, err := json.Marshal(stagedSubscriptionPayload{DriverState: authorization.DriverState})
if err != nil {
return CredentialStageResult{}, app_errors.ErrInternalServer
}
ciphertext, err := s.encryption.Encrypt(string(payload))
clear(payload)
if err != nil {
return CredentialStageResult{}, app_errors.ErrInternalServer
}
stageID, err := newOperationID(s.random)
if err != nil {
return CredentialStageResult{}, app_errors.ErrInternalServer
}
row := models.CredentialStage{
ID: stageID, ChannelID: string(channelID), ConnectionType: models.ConnectionTypeSubscription,
AuthorizationMethod: string(channel.AuthorizationDeviceOAuth), Status: models.CredentialStagePendingAuthorization,
EncryptedPayload: ciphertext, PayloadSchemaVersion: stagedSubscriptionSchemaV2,
SafeSummaryJSON: models.JSON(summaryJSON), ExpiresAtMS: authorization.ExpiresAt.UTC().UnixMilli(),
CreatedAtMS: now.UnixMilli(), UpdatedAtMS: now.UnixMilli(),
}
if err := s.db.WithContext(ctx).Create(&row).Error; err != nil {
return CredentialStageResult{}, app_errors.ParseDBError(err)
}
return CredentialStageResult{
StageID: row.ID, Status: string(row.Status), AuthorizationMethod: row.AuthorizationMethod,
AuthorizationURL: verificationURL, UserCode: userCode, NextPollAtMS: nextPollAtMS,
Account: CredentialStageAccount{}, ExpiresAtMS: row.ExpiresAtMS,
}, nil
}
func normalizeDevicePollInterval(value time.Duration) (time.Duration, bool) {
if value < time.Second || value > time.Minute {
return 0, false
}
return value, true
}
func validDeviceAuthorizationCode(value string) bool {
if value == "" || len(value) > maxDeviceAuthorizationCode {
return false
}
for index := 0; index < len(value); index++ {
if value[index] < 0x20 || value[index] > 0x7e {
return false
}
}
return true
}
// CompleteCredentialAuthorization atomically claims one pending state before
// issuing the one-shot OAuth code exchange.
func (s *Service) CompleteCredentialAuthorization(
@@ -655,6 +771,189 @@ func (s *Service) CompleteCredentialAuthorizationCallback(
)
}
// PollCredentialDeviceAuthorization performs at most one provider token poll.
// Calling it before the advertised interval returns the current Stage without
// dispatching upstream.
func (s *Service) PollCredentialDeviceAuthorization(
ctx context.Context,
stageID string,
) (CredentialStageResult, error) {
if s == nil || s.db == nil || s.encryption == nil || s.pollDeviceAuthorization == nil ||
strings.TrimSpace(stageID) == "" {
return CredentialStageResult{}, app_errors.ErrAuthorizationStateInvalid
}
row, err := s.loadCredentialStage(ctx, stageID)
if err != nil {
return CredentialStageResult{}, err
}
if row.AuthorizationMethod != string(channel.AuthorizationDeviceOAuth) {
return CredentialStageResult{}, app_errors.ErrAuthorizationStateInvalid
}
if row.Status == models.CredentialStageExchanging {
return s.GetCredentialStage(ctx, stageID)
}
if row.Status != models.CredentialStagePendingAuthorization {
return CredentialStageResult{}, app_errors.ErrAuthorizationStateInvalid
}
now := s.now().UTC()
if now.UnixMilli() >= row.ExpiresAtMS {
if err := s.expireCredentialStage(ctx, &row); err != nil {
return CredentialStageResult{}, app_errors.ParseDBError(err)
}
return s.GetCredentialStage(ctx, stageID)
}
_, authorization := decodeCredentialStageSafeSummary(row.SafeSummaryJSON)
if authorization == nil {
return CredentialStageResult{}, app_errors.ErrAuthorizationStateInvalid
}
if now.UnixMilli() < authorization.NextPollAtMS {
return s.GetCredentialStage(ctx, stageID)
}
claim := s.db.WithContext(ctx).Model(&models.CredentialStage{}).
Where(
"id = ? AND authorization_method = ? AND status = ? AND expires_at_ms > ?",
row.ID, string(channel.AuthorizationDeviceOAuth), models.CredentialStagePendingAuthorization, now.UnixMilli(),
).
Updates(map[string]any{
"status": models.CredentialStageExchanging,
"expires_at_ms": now.Add(defaultSubscriptionControlTimeout).UnixMilli(),
"updated_at_ms": now.UnixMilli(),
})
if claim.Error != nil {
return CredentialStageResult{}, app_errors.ParseDBError(claim.Error)
}
if claim.RowsAffected != 1 {
return s.GetCredentialStage(ctx, stageID)
}
plaintext, err := s.encryption.Decrypt(row.EncryptedPayload)
if err != nil {
_ = s.markCredentialStageOutcomeUnknown(ctx, row.ID)
return CredentialStageResult{}, app_errors.ErrAuthorizationExchangeFailed
}
payload, err := decodeStagedAuthorizationPayload(row.PayloadSchemaVersion, []byte(plaintext))
if err != nil || len(payload.DriverState) == 0 {
_ = s.markCredentialStageOutcomeUnknown(ctx, row.ID)
return CredentialStageResult{}, app_errors.ErrAuthorizationExchangeFailed
}
pollContext, cancelPoll := context.WithTimeout(context.WithoutCancel(ctx), defaultSubscriptionControlTimeout)
defer cancelPoll()
poll, err := s.pollDeviceAuthorization(pollContext, channel.ID(row.ChannelID), payload.DriverState)
if err != nil {
if markErr := s.markCredentialStageOutcomeUnknown(ctx, row.ID); markErr != nil {
return CredentialStageResult{}, app_errors.ErrInternalServer
}
return CredentialStageResult{}, app_errors.ErrAuthorizationExchangeFailed
}
switch poll.Status {
case subscriptionruntime.DeviceAuthorizationPending:
return s.finishPendingDeviceAuthorization(ctx, row, payload.DriverState, authorization, poll)
case subscriptionruntime.DeviceAuthorizationAuthorized:
finalizeContext, cancelFinalize := credentialStageFinalizeContext(ctx)
defer cancelFinalize()
return s.finishCredentialStageExchange(finalizeContext, row, poll.Credential)
case subscriptionruntime.DeviceAuthorizationDenied:
if err := s.finishDeviceAuthorizationFailure(ctx, row.ID, models.CredentialStageFailed, "authorization_denied"); err != nil {
return CredentialStageResult{}, err
}
return s.GetCredentialStage(ctx, row.ID)
case subscriptionruntime.DeviceAuthorizationExpired:
if err := s.finishDeviceAuthorizationFailure(ctx, row.ID, models.CredentialStageExpired, "authorization_expired"); err != nil {
return CredentialStageResult{}, err
}
return s.GetCredentialStage(ctx, row.ID)
default:
if err := s.markCredentialStageOutcomeUnknown(ctx, row.ID); err != nil {
return CredentialStageResult{}, app_errors.ErrInternalServer
}
return CredentialStageResult{}, app_errors.ErrAuthorizationExchangeFailed
}
}
func (s *Service) finishPendingDeviceAuthorization(
ctx context.Context,
row models.CredentialStage,
previousDriverState []byte,
authorization *credentialStageAuthorizationSummary,
poll subscriptionruntime.DeviceAuthorizationPoll,
) (CredentialStageResult, error) {
driverState := poll.DriverState
if len(driverState) == 0 {
driverState = previousDriverState
}
if len(driverState) == 0 || len(driverState) > maxOAuthFileBytes {
_ = s.markCredentialStageOutcomeUnknown(ctx, row.ID)
return CredentialStageResult{}, app_errors.ErrAuthorizationExchangeFailed
}
pollInterval := poll.PollInterval
if pollInterval == 0 {
pollInterval = time.Duration(authorization.PollIntervalMS) * time.Millisecond
}
var intervalOK bool
pollInterval, intervalOK = normalizeDevicePollInterval(pollInterval)
if !intervalOK {
_ = s.markCredentialStageOutcomeUnknown(ctx, row.ID)
return CredentialStageResult{}, app_errors.ErrAuthorizationExchangeFailed
}
payload, err := json.Marshal(stagedSubscriptionPayload{DriverState: driverState})
if err != nil {
_ = s.markCredentialStageOutcomeUnknown(ctx, row.ID)
return CredentialStageResult{}, app_errors.ErrInternalServer
}
ciphertext, err := s.encryption.Encrypt(string(payload))
clear(payload)
if err != nil {
_ = s.markCredentialStageOutcomeUnknown(ctx, row.ID)
return CredentialStageResult{}, app_errors.ErrInternalServer
}
now := s.now().UTC()
authorization.NextPollAtMS = now.Add(pollInterval).UnixMilli()
authorization.PollIntervalMS = pollInterval.Milliseconds()
summaryJSON, err := json.Marshal(credentialStageSafeSummary{Authorization: authorization})
if err != nil {
_ = s.markCredentialStageOutcomeUnknown(ctx, row.ID)
return CredentialStageResult{}, app_errors.ErrInternalServer
}
finalizeContext, cancelFinalize := credentialStageFinalizeContext(ctx)
defer cancelFinalize()
result := s.db.WithContext(finalizeContext).Model(&models.CredentialStage{}).
Where("id = ? AND status = ?", row.ID, models.CredentialStageExchanging).
Updates(map[string]any{
"status": models.CredentialStagePendingAuthorization, "encrypted_payload": ciphertext,
"safe_summary_json": models.JSON(summaryJSON), "expires_at_ms": row.ExpiresAtMS,
"updated_at_ms": now.UnixMilli(),
})
if result.Error != nil {
return CredentialStageResult{}, app_errors.ParseDBError(result.Error)
}
if result.RowsAffected != 1 {
return CredentialStageResult{}, app_errors.ErrCredentialAuthOutcomeUnknown
}
return s.GetCredentialStage(ctx, row.ID)
}
func (s *Service) finishDeviceAuthorizationFailure(
ctx context.Context,
stageID string,
status models.CredentialStageStatus,
errorCode string,
) error {
finalizeContext, cancelFinalize := credentialStageFinalizeContext(ctx)
defer cancelFinalize()
result := s.db.WithContext(finalizeContext).Model(&models.CredentialStage{}).
Where("id = ? AND status = ?", stageID, models.CredentialStageExchanging).
Updates(map[string]any{
"status": status, "encrypted_payload": "", "error_code": errorCode,
"updated_at_ms": s.now().UnixMilli(),
})
if result.Error != nil {
return app_errors.ParseDBError(result.Error)
}
if result.RowsAffected != 1 {
return app_errors.ErrCredentialAuthOutcomeUnknown
}
return nil
}
func (s *Service) GetCredentialStage(ctx context.Context, stageID string) (CredentialStageResult, error) {
row, err := s.loadCredentialStage(ctx, stageID)
if err != nil {
@@ -667,14 +966,17 @@ func (s *Service) GetCredentialStage(ctx context.Context, stageID string) (Crede
return CredentialStageResult{}, app_errors.ParseDBError(err)
}
}
var account CredentialStageAccount
if len(row.SafeSummaryJSON) > 0 {
_ = json.Unmarshal(row.SafeSummaryJSON, &account)
}
account, authorization := decodeCredentialStageSafeSummary(row.SafeSummaryJSON)
result := CredentialStageResult{
StageID: row.ID, Status: string(row.Status), Account: account, ExpiresAtMS: row.ExpiresAtMS,
StageID: row.ID, Status: string(row.Status), AuthorizationMethod: row.AuthorizationMethod,
Account: account, ExpiresAtMS: row.ExpiresAtMS,
ErrorCode: stageResultErrorCode(row.Status, row.ErrorCode),
}
if authorization != nil {
result.AuthorizationURL = authorization.AuthorizationURL
result.UserCode = authorization.UserCode
result.NextPollAtMS = authorization.NextPollAtMS
}
if row.Status == models.CredentialStagePendingAuthorization || row.Status == models.CredentialStageExchanging {
if callback, ok := s.subscriptions.LocalCallback(channel.ID(row.ChannelID)); ok {
result.RedirectURI = callback.RedirectURI
@@ -683,6 +985,19 @@ func (s *Service) GetCredentialStage(ctx context.Context, stageID string) (Crede
return result, nil
}
func decodeCredentialStageSafeSummary(raw []byte) (CredentialStageAccount, *credentialStageAuthorizationSummary) {
if len(raw) == 0 {
return CredentialStageAccount{}, nil
}
var safe credentialStageSafeSummary
if json.Unmarshal(raw, &safe) == nil && safe.Authorization != nil {
return CredentialStageAccount{}, safe.Authorization
}
var account CredentialStageAccount
_ = json.Unmarshal(raw, &account)
return account, nil
}
func (s *Service) CancelCredentialStage(ctx context.Context, stageID string) error {
if strings.TrimSpace(stageID) == "" {
return app_errors.ErrBadRequest
@@ -741,7 +1056,8 @@ func (s *Service) persistReadyCredentialStage(
return CredentialStageResult{}, app_errors.ParseDBError(err)
}
return CredentialStageResult{
StageID: row.ID, Status: string(row.Status), Account: summary, ExpiresAtMS: row.ExpiresAtMS,
StageID: row.ID, Status: string(row.Status), AuthorizationMethod: row.AuthorizationMethod,
Account: summary, ExpiresAtMS: row.ExpiresAtMS,
}, nil
}
@@ -782,7 +1098,8 @@ func (s *Service) finishCredentialStageExchange(
return CredentialStageResult{}, app_errors.ErrAuthorizationStateInvalid
}
return CredentialStageResult{
StageID: row.ID, Status: string(models.CredentialStageReady), Account: summary, ExpiresAtMS: expiresAtMS,
StageID: row.ID, Status: string(models.CredentialStageReady), AuthorizationMethod: row.AuthorizationMethod,
Account: summary, ExpiresAtMS: expiresAtMS,
}, nil
}
+249
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"strings"
"sync/atomic"
"testing"
"time"
@@ -14,6 +15,7 @@ import (
app_errors "gpt-load/internal/platform/errors"
"gpt-load/internal/storage/models"
"gpt-load/internal/subscription/providers/codex"
subscriptionruntime "gpt-load/internal/subscription/runtime"
)
type credentialImportStatusError struct{ status int }
@@ -182,6 +184,253 @@ func TestBeginBrowserAuthorizationCreatesPendingStage(t *testing.T) {
}
}
func TestBeginDeviceAuthorizationCreatesEncryptedPendingStage(t *testing.T) {
fixture := newServiceFixture(t)
now := time.UnixMilli(1_800_000_000_000).UTC()
fixture.service.now = func() time.Time { return now }
fixture.service.beginDeviceAuthorization = func(ctx context.Context, channelID channel.ID) (subscriptionruntime.DeviceAuthorization, error) {
if ctx.Err() != nil || channelID != channel.Grok {
t.Fatalf("begin device input = %v, %q", ctx.Err(), channelID)
}
return subscriptionruntime.DeviceAuthorization{
VerificationURL: "https://auth.x.ai/device", UserCode: "ABCD-EFGH",
DriverState: []byte(`{"device_code":"device-secret"}`),
ExpiresAt: now.Add(20 * time.Minute), PollInterval: 5 * time.Second,
}, nil
}
result, err := fixture.service.BeginCredentialAuthorization(t.Context(), channel.Grok)
if err != nil {
t.Fatalf("BeginCredentialAuthorization() error = %v", err)
}
if result.Status != string(models.CredentialStagePendingAuthorization) ||
result.AuthorizationMethod != string(channel.AuthorizationDeviceOAuth) ||
result.AuthorizationURL != "https://auth.x.ai/device" || result.UserCode != "ABCD-EFGH" ||
result.NextPollAtMS != now.Add(5*time.Second).UnixMilli() ||
result.ExpiresAtMS != now.Add(20*time.Minute).UnixMilli() {
t.Fatalf("device stage result = %#v", result)
}
var row models.CredentialStage
if err := fixture.db.Take(&row, "id = ?", result.StageID).Error; err != nil {
t.Fatal(err)
}
if row.AuthorizationMethod != string(channel.AuthorizationDeviceOAuth) || row.OAuthStateHash != nil ||
row.EncryptedPayload == "" || strings.Contains(row.EncryptedPayload, "device-secret") ||
strings.Contains(string(row.SafeSummaryJSON), "device-secret") {
t.Fatalf("stored device stage = %#v", row)
}
}
func TestBeginDeviceAuthorizationRejectsUnsafeChallenge(t *testing.T) {
now := time.UnixMilli(1_800_000_000_000).UTC()
for _, test := range []struct {
name string
verification string
userCode string
driverState []byte
}{
{name: "control character", verification: "https://auth.x.ai/device", userCode: "ABCD\nEFGH", driverState: []byte(`{"device_code":"secret"}`)},
{name: "oversized URL", verification: "https://auth.x.ai/" + strings.Repeat("a", 4096), userCode: "ABCD-EFGH", driverState: []byte(`{"device_code":"secret"}`)},
{name: "oversized state", verification: "https://auth.x.ai/device", userCode: "ABCD-EFGH", driverState: make([]byte, maxOAuthFileBytes+1)},
} {
t.Run(test.name, func(t *testing.T) {
fixture := newServiceFixture(t)
fixture.service.now = func() time.Time { return now }
fixture.service.beginDeviceAuthorization = func(context.Context, channel.ID) (subscriptionruntime.DeviceAuthorization, error) {
return subscriptionruntime.DeviceAuthorization{
VerificationURL: test.verification,
UserCode: test.userCode,
DriverState: test.driverState,
ExpiresAt: now.Add(20 * time.Minute),
PollInterval: 5 * time.Second,
}, nil
}
if _, err := fixture.service.BeginCredentialAuthorization(t.Context(), channel.Grok); !errors.Is(err, app_errors.ErrAuthorizationUnavailable) {
t.Fatalf("BeginCredentialAuthorization() error = %v", err)
}
})
}
}
func TestPollDeviceAuthorizationHonorsIntervalAndPersistsPendingState(t *testing.T) {
fixture := newServiceFixture(t)
now := time.UnixMilli(1_800_000_000_000).UTC()
fixture.service.now = func() time.Time { return now }
fixture.service.beginDeviceAuthorization = func(context.Context, channel.ID) (subscriptionruntime.DeviceAuthorization, error) {
return subscriptionruntime.DeviceAuthorization{
VerificationURL: "https://auth.x.ai/device", UserCode: "ABCD-EFGH",
DriverState: []byte(`{"device_code":"initial"}`), ExpiresAt: now.Add(20 * time.Minute),
PollInterval: 5 * time.Second,
}, nil
}
started, err := fixture.service.BeginCredentialAuthorization(t.Context(), channel.Grok)
if err != nil {
t.Fatal(err)
}
pollCalls := 0
fixture.service.pollDeviceAuthorization = func(_ context.Context, channelID channel.ID, state []byte) (subscriptionruntime.DeviceAuthorizationPoll, error) {
pollCalls++
var claimed models.CredentialStage
if err := fixture.db.Take(&claimed, "id = ?", started.StageID).Error; err != nil {
t.Fatal(err)
}
if claimed.Status != models.CredentialStageExchanging ||
claimed.ExpiresAtMS != now.Add(defaultSubscriptionControlTimeout).UnixMilli() {
t.Fatalf("claimed device stage = %#v", claimed)
}
if channelID != channel.Grok || !strings.Contains(string(state), "initial") {
t.Fatalf("poll input = %q, %s", channelID, state)
}
return subscriptionruntime.DeviceAuthorizationPoll{
Status: subscriptionruntime.DeviceAuthorizationPending, DriverState: []byte(`{"device_code":"next"}`),
PollInterval: 10 * time.Second,
}, nil
}
if _, err := fixture.service.PollCredentialDeviceAuthorization(t.Context(), started.StageID); err != nil {
t.Fatal(err)
}
if pollCalls != 0 {
t.Fatalf("early poll calls = %d", pollCalls)
}
now = now.Add(5 * time.Second)
pending, err := fixture.service.PollCredentialDeviceAuthorization(t.Context(), started.StageID)
if err != nil {
t.Fatal(err)
}
if pending.Status != string(models.CredentialStagePendingAuthorization) ||
pending.NextPollAtMS != now.Add(10*time.Second).UnixMilli() || pollCalls != 1 {
t.Fatalf("pending poll = %#v, calls = %d", pending, pollCalls)
}
var restored models.CredentialStage
if err := fixture.db.Take(&restored, "id = ?", started.StageID).Error; err != nil {
t.Fatal(err)
}
if restored.ExpiresAtMS != started.ExpiresAtMS {
t.Fatalf("pending device expiry = %d, want %d", restored.ExpiresAtMS, started.ExpiresAtMS)
}
}
func TestPollDeviceAuthorizationConcurrentRequestDoesNotDispatchTwice(t *testing.T) {
fixture := newServiceFixture(t)
now := time.UnixMilli(1_800_000_000_000).UTC()
fixture.service.now = func() time.Time { return now }
fixture.service.beginDeviceAuthorization = func(context.Context, channel.ID) (subscriptionruntime.DeviceAuthorization, error) {
return subscriptionruntime.DeviceAuthorization{
VerificationURL: "https://auth.x.ai/device", UserCode: "ABCD-EFGH",
DriverState: []byte(`{"device_code":"initial"}`), ExpiresAt: now.Add(20 * time.Minute),
PollInterval: time.Second,
}, nil
}
started, err := fixture.service.BeginCredentialAuthorization(t.Context(), channel.Grok)
if err != nil {
t.Fatal(err)
}
now = now.Add(time.Second)
entered := make(chan struct{})
release := make(chan struct{})
var calls atomic.Int32
fixture.service.pollDeviceAuthorization = func(context.Context, channel.ID, []byte) (subscriptionruntime.DeviceAuthorizationPoll, error) {
calls.Add(1)
close(entered)
<-release
return subscriptionruntime.DeviceAuthorizationPoll{
Status: subscriptionruntime.DeviceAuthorizationPending, PollInterval: 5 * time.Second,
}, nil
}
firstDone := make(chan error, 1)
go func() {
_, pollErr := fixture.service.PollCredentialDeviceAuthorization(t.Context(), started.StageID)
firstDone <- pollErr
}()
<-entered
concurrent, err := fixture.service.PollCredentialDeviceAuthorization(t.Context(), started.StageID)
if err != nil || concurrent.Status != string(models.CredentialStageExchanging) || calls.Load() != 1 {
t.Fatalf("concurrent poll = %#v, %v, calls=%d", concurrent, err, calls.Load())
}
close(release)
if err := <-firstDone; err != nil {
t.Fatal(err)
}
}
func TestPollDeviceAuthorizationCompletesReadyStage(t *testing.T) {
fixture := newServiceFixture(t)
now := time.UnixMilli(1_800_000_000_000).UTC()
fixture.service.now = func() time.Time { return now }
fixture.service.beginDeviceAuthorization = func(context.Context, channel.ID) (subscriptionruntime.DeviceAuthorization, error) {
return subscriptionruntime.DeviceAuthorization{
VerificationURL: "https://auth.x.ai/device", UserCode: "ABCD-EFGH",
DriverState: []byte(`{"device_code":"initial"}`), ExpiresAt: now.Add(20 * time.Minute),
PollInterval: time.Second,
}, nil
}
started, err := fixture.service.BeginCredentialAuthorization(t.Context(), channel.Grok)
if err != nil {
t.Fatal(err)
}
fixture.service.pollDeviceAuthorization = func(context.Context, channel.ID, []byte) (subscriptionruntime.DeviceAuthorizationPoll, error) {
credential := subscriptionruntime.NewCredential(
[]byte(`{"type":"grok","access_token":"access","refresh_token":"refresh","account_id":"account"}`),
"account", subscriptionruntime.Account{Email: "owner@example.com"}, now.Add(time.Hour), true,
[]string{"access", "refresh"},
)
return subscriptionruntime.DeviceAuthorizationPoll{Status: subscriptionruntime.DeviceAuthorizationAuthorized, Credential: credential}, nil
}
now = now.Add(time.Second)
ready, err := fixture.service.PollCredentialDeviceAuthorization(t.Context(), started.StageID)
if err != nil {
t.Fatal(err)
}
if ready.Status != string(models.CredentialStageReady) || ready.Account.EmailMask == "" {
t.Fatalf("ready poll = %#v", ready)
}
}
func TestPollDeviceAuthorizationPersistsTerminalStatesAndClearsSecrets(t *testing.T) {
for _, test := range []struct {
name string
poll subscriptionruntime.DeviceAuthorizationStatus
wantState models.CredentialStageStatus
wantCode string
}{
{name: "denied", poll: subscriptionruntime.DeviceAuthorizationDenied, wantState: models.CredentialStageFailed, wantCode: "authorization_denied"},
{name: "expired", poll: subscriptionruntime.DeviceAuthorizationExpired, wantState: models.CredentialStageExpired, wantCode: "authorization_expired"},
} {
t.Run(test.name, func(t *testing.T) {
fixture := newServiceFixture(t)
now := time.UnixMilli(1_800_000_000_000).UTC()
fixture.service.now = func() time.Time { return now }
fixture.service.beginDeviceAuthorization = func(context.Context, channel.ID) (subscriptionruntime.DeviceAuthorization, error) {
return subscriptionruntime.DeviceAuthorization{
VerificationURL: "https://auth.x.ai/device", UserCode: "ABCD-EFGH",
DriverState: []byte(`{"device_code":"secret"}`), ExpiresAt: now.Add(20 * time.Minute),
PollInterval: time.Second,
}, nil
}
started, err := fixture.service.BeginCredentialAuthorization(t.Context(), channel.Grok)
if err != nil {
t.Fatal(err)
}
fixture.service.pollDeviceAuthorization = func(context.Context, channel.ID, []byte) (subscriptionruntime.DeviceAuthorizationPoll, error) {
return subscriptionruntime.DeviceAuthorizationPoll{Status: test.poll}, nil
}
now = now.Add(time.Second)
result, err := fixture.service.PollCredentialDeviceAuthorization(t.Context(), started.StageID)
if err != nil || result.Status != string(test.wantState) {
t.Fatalf("poll result = %#v, %v", result, err)
}
var row models.CredentialStage
if err := fixture.db.Take(&row, "id = ?", started.StageID).Error; err != nil {
t.Fatal(err)
}
if row.Status != test.wantState || row.ErrorCode != test.wantCode || row.EncryptedPayload != "" {
t.Fatalf("terminal device stage = %#v", row)
}
})
}
}
func TestCancelCredentialStageClearsPayload(t *testing.T) {
t.Parallel()
+7
View File
@@ -65,6 +65,13 @@ func (s *Server) HTTPModule() httproute.Module {
s.auditMutation(newMutationDescriptor("credential_stage_oauth_callback", "credential_stage", credentialStageMutationLocator)),
s.handleCredentialAuthorizationCallback,
),
controlRoute(
"control.credential-stages.device-poll",
http.MethodPost,
"/credential-stages/:stage_id/device-poll",
s.auditMutation(newMutationDescriptor("credential_stage_device_poll", "credential_stage", credentialStageMutationLocator)),
s.handlePollCredentialDeviceAuthorization,
),
controlRoute(
"control.credential-stages.cancel",
http.MethodDelete,
+17
View File
@@ -127,6 +127,23 @@ func TestResolveAutomaticPriceForIdentityTreatsAntigravityGooglePriceAsReference
}
}
func TestResolveAutomaticPriceForIdentityTreatsGrokXAIPriceAsReference(t *testing.T) {
xaiCost := &catalog.ModelCost{Prices: pricing.Prices{Input: priceTestValue(1)}}
snapshot := &catalog.Snapshot{Providers: map[string]catalog.Provider{
"xai": {ID: "xai", Models: map[string]catalog.Model{
"grok-4.3": {ID: "grok-4.3", Cost: xaiCost},
}},
}}
match, ok := resolveAutomaticPriceForIdentity(snapshot, pricing.Identity{
ChannelID: string(channel.Grok), ModelID: "grok-4.3",
})
if !ok || match.cost != xaiCost || match.providerID != "xai" ||
match.source != ModelPriceMatchSourceProviderPriorityFallback {
t.Fatalf("Grok price reference = %#v, %t", match, ok)
}
}
func TestCompatibleAutomaticPriceUsesGlobalPriority(t *testing.T) {
openAICost := &catalog.ModelCost{Prices: pricing.Prices{Input: priceTestValue(1)}}
alphaCost := &catalog.ModelCost{Prices: pricing.Prices{Input: priceTestValue(2)}}
+13
View File
@@ -127,6 +127,19 @@ func (s *Server) handleCredentialAuthorizationCallback(c *gin.Context) {
response.SuccessI18n(c, "common.success", result)
}
func (s *Server) handlePollCredentialDeviceAuthorization(c *gin.Context) {
result, err := s.service.PollCredentialDeviceAuthorization(
c.Request.Context(), strings.TrimSpace(c.Param("stage_id")),
)
if err != nil {
writeServiceError(c, "poll_credential_device_authorization", err)
return
}
setMutationResourceLocator(c, "credential-stage:"+result.StageID)
setSecretResponseHeaders(c)
response.SuccessI18n(c, "common.success", result)
}
func (s *Server) handleImportCredentialStage(c *gin.Context) {
reader, err := c.Request.MultipartReader()
if err != nil {
+23
View File
@@ -61,6 +61,8 @@ type Service struct {
operationRandom io.Reader
beginSubscriptionAuthorization func(channel.ID) (subscriptionruntime.Authorization, error)
completeSubscriptionAuthorization func(context.Context, channel.ID, subscriptionruntime.AuthorizationCompletion) (subscriptionruntime.Credential, error)
beginDeviceAuthorization func(context.Context, channel.ID) (subscriptionruntime.DeviceAuthorization, error)
pollDeviceAuthorization func(context.Context, channel.ID, []byte) (subscriptionruntime.DeviceAuthorizationPoll, error)
refreshSubscriptionCredential func(context.Context, channel.ID, subscriptionruntime.Credential) (subscriptionruntime.Credential, error)
prepareSubscriptionCredential func(context.Context, channel.ID, execution.CredentialSnapshot, bool) (subscriptionruntime.Credential, *execution.ErrorEvidence)
discoverSubscriptionModels func(context.Context, channel.ID, subscriptionruntime.Credential) ([]string, error)
@@ -196,6 +198,20 @@ func NewService(
}
return browser.CompleteAuthorization(ctx, completion)
},
beginDeviceAuthorization: func(ctx context.Context, channelID channel.ID) (subscriptionruntime.DeviceAuthorization, error) {
device, ok := subscriptionsDevice(subscriptions, channelID)
if !ok {
return subscriptionruntime.DeviceAuthorization{}, app_errors.ErrAuthorizationUnavailable
}
return device.BeginDeviceAuthorization(ctx)
},
pollDeviceAuthorization: func(ctx context.Context, channelID channel.ID, state []byte) (subscriptionruntime.DeviceAuthorizationPoll, error) {
device, ok := subscriptionsDevice(subscriptions, channelID)
if !ok {
return subscriptionruntime.DeviceAuthorizationPoll{}, app_errors.ErrAuthorizationUnavailable
}
return device.PollDeviceAuthorization(ctx, state)
},
refreshSubscriptionCredential: func(ctx context.Context, channelID channel.ID, credential subscriptionruntime.Credential) (subscriptionruntime.Credential, error) {
driver, ok := subscriptionsDriver(subscriptions, channelID)
if !ok {
@@ -274,6 +290,13 @@ func subscriptionsBrowser(runtime *subscriptionruntime.Runtime, channelID channe
return runtime.BrowserAuthorization(channelID)
}
func subscriptionsDevice(runtime *subscriptionruntime.Runtime, channelID channel.ID) (subscriptionruntime.DeviceAuthorizationDriver, bool) {
if runtime == nil {
return nil, false
}
return runtime.DeviceAuthorization(channelID)
}
type configMutationPublication struct {
ConfigInput state.CompileInput
PriceTable *pricing.Table
+1
View File
@@ -58,6 +58,7 @@ func NewAdapter(credentials *subscription.CredentialManager, channels *channel.R
newCodexProviderBridge(),
newClaudeProviderBridge(),
newAntigravityProviderBridge(),
newGrokProviderBridge(),
),
}
}
+265
View File
@@ -0,0 +1,265 @@
package cpa
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"gpt-load/internal/channel"
"gpt-load/internal/execution"
"gpt-load/internal/protocol"
"gpt-load/internal/subscription/providers/grok"
)
type grokProviderCredential struct{ value grok.Credential }
func (credential grokProviderCredential) redactionValues() []string {
return credential.value.SecretValues()
}
type grokProviderBridge struct{ executor grok.Executor }
func newGrokProviderBridge() *grokProviderBridge {
return &grokProviderBridge{executor: grok.NewExecutor()}
}
func (*grokProviderBridge) ProviderKind() channel.ProviderKind { return channel.ProviderGrok }
func (*grokProviderBridge) UpstreamProtocol() protocol.Protocol { return protocol.OpenAIResponses }
func (*grokProviderBridge) ValidateRouteCapability(route channel.RouteDescriptor) error {
valid := route.ClientProtocol == protocol.OpenAIResponses &&
(route.Operation == execution.OperationResponsesCreate ||
route.Operation == execution.OperationResponsesInputTokens) &&
route.RouteMode == execution.RouteNative
if route.ClientProtocol == protocol.OpenAICompletions {
valid = route.Operation == execution.OperationChatCompletion && route.RouteMode == execution.RouteConverted
}
if route.ClientProtocol == protocol.Anthropic || route.ClientProtocol == protocol.Gemini {
valid = (route.Operation == execution.OperationChatCompletion || route.Operation == execution.OperationCountTokens) &&
route.RouteMode == execution.RouteConverted
}
if !valid {
return fmt.Errorf("route is not implemented by Grok")
}
return nil
}
func (*grokProviderBridge) ParseCredential(raw []byte) (providerCredential, error) {
credential, err := grok.ParseCredentialJSON(raw)
if err != nil {
return nil, err
}
return grokProviderCredential{value: credential}, nil
}
func (bridge *grokProviderBridge) Execute(
ctx context.Context,
credentialID string,
credential providerCredential,
request providerRequest,
) (providerResponse, error) {
value, ok := credential.(grokProviderCredential)
if !ok || bridge == nil || bridge.executor == nil {
return providerResponse{}, errors.New("Grok provider bridge credential mismatch")
}
response, err := bridge.executor.Execute(ctx, credentialID, value.value, grokRequest(request, credentialID))
return providerResponse{
Payload: append([]byte(nil), response.Payload...), Headers: response.Headers.Clone(),
AppliedReasoningEffort: response.AppliedReasoningEffort,
}, err
}
func (bridge *grokProviderBridge) ExecuteStream(
ctx context.Context,
credentialID string,
credential providerCredential,
request providerRequest,
) (*providerStreamResponse, error) {
value, ok := credential.(grokProviderCredential)
if !ok || bridge == nil || bridge.executor == nil {
return nil, errors.New("Grok provider bridge credential mismatch")
}
response, err := bridge.executor.ExecuteStream(ctx, credentialID, value.value, grokRequest(request, credentialID))
if response == nil {
return nil, err
}
if err != nil {
return &providerStreamResponse{
Headers: response.Headers.Clone(), AppliedReasoningEffort: response.AppliedReasoningEffort,
}, err
}
chunks := make(chan providerStreamChunk)
go func() {
defer close(chunks)
for chunk := range response.Chunks {
converted := providerStreamChunk{Payload: append([]byte(nil), chunk.Payload...), Err: chunk.Err}
select {
case chunks <- converted:
case <-ctx.Done():
return
}
}
}()
return &providerStreamResponse{
Headers: response.Headers.Clone(), Chunks: chunks,
AppliedReasoningEffort: response.AppliedReasoningEffort,
}, nil
}
func (bridge *grokProviderBridge) ValidateLocalTokenCount(request providerRequest) error {
if !grokLocalTokenCountModelSupported(request.Model) {
return errors.New("Grok local token count tokenizer is unavailable for model")
}
var root map[string]any
if err := json.Unmarshal(request.Payload, &root); err != nil || root == nil {
return errors.New("Grok local token count request is invalid")
}
switch request.Format {
case "openai-response":
return validateLocalResponsesTokenCount(root)
case "claude":
return validateLocalClaudeTokenCount(root)
case "gemini":
return validateLocalGeminiTokenCount(root)
default:
return errors.New("Grok local token count format is unsupported")
}
}
func grokLocalTokenCountModelSupported(model string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "grok-")
}
func (bridge *grokProviderBridge) CountTokensLocal(
ctx context.Context,
request providerRequest,
) (providerResponse, error) {
if bridge == nil || bridge.executor == nil {
return providerResponse{}, errors.New("Grok provider bridge is unavailable")
}
response, err := bridge.executor.CountTokens(ctx, grok.ExecuteRequest{
AttemptID: request.AttemptID, Model: request.Model,
Payload: append([]byte(nil), request.Payload...), Format: request.Format,
Headers: request.Headers.Clone(), OriginalRequest: append([]byte(nil), request.OriginalRequest...),
})
headers := response.Headers.Clone()
if headers == nil {
headers = make(http.Header)
}
headers.Set(localTokenCountHeader, "local-estimate")
return providerResponse{
Payload: append([]byte(nil), response.Payload...), Headers: headers,
AppliedReasoningEffort: response.AppliedReasoningEffort,
}, err
}
func grokRequest(request providerRequest, credentialID string) grok.ExecuteRequest {
return grok.ExecuteRequest{
AttemptID: request.AttemptID, Model: request.Model,
Payload: append([]byte(nil), request.Payload...), Format: request.Format,
Headers: request.Headers.Clone(), OriginalRequest: append([]byte(nil), request.OriginalRequest...),
ContinuityKey: grokContinuityScope(request.ContinuityKey, credentialID, request.Model, request.AttemptID),
}
}
func grokContinuityScope(base, credentialID, model, attemptID string) string {
base = strings.TrimSpace(base)
credentialID = strings.TrimSpace(credentialID)
model = strings.TrimSpace(model)
attemptID = strings.TrimSpace(attemptID)
if credentialID == "" || model == "" {
return ""
}
if base == "" {
if attemptID == "" {
return ""
}
return strings.Join([]string{"gpt-load-grok-attempt", attemptID, credentialID, model}, "\x00")
}
return strings.Join([]string{"gpt-load-grok", base, credentialID, model}, "\x00")
}
func (*grokProviderBridge) ClassifyError(
ctx context.Context,
err error,
credential providerCredential,
) (int, *execution.ErrorEvidence) {
if err == nil {
return 0, nil
}
status := 0
var statusError interface{ StatusCode() int }
if errors.As(err, &statusError) && statusError != nil {
status = statusError.StatusCode()
}
kind := execution.ErrorKindTransport
if errors.Is(err, context.DeadlineExceeded) || ctx != nil && errors.Is(context.Cause(ctx), context.DeadlineExceeded) {
kind = execution.ErrorKindTimeout
} else if errors.Is(err, context.Canceled) || ctx != nil && errors.Is(context.Cause(ctx), context.Canceled) {
kind = execution.ErrorKindCanceled
} else if status != 0 {
kind = execution.ErrorKindHTTP
} else if _, ok := err.(net.Error); ok {
kind = execution.ErrorKindTransport
}
code := ""
var coded interface{ ErrorCode() string }
if errors.As(err, &coded) && coded != nil {
code = safeScalar(coded.ErrorCode())
}
evidence := &execution.ErrorEvidence{
Kind: kind, StatusCode: status, Code: code,
Summary: grokProviderErrorSummary(status, code),
}
var retry interface{ RetryAfter() *time.Duration }
if errors.As(err, &retry) && retry != nil {
if value := retry.RetryAfter(); value != nil && *value > 0 {
evidence.RetryAfter = *value
}
}
switch {
case status == http.StatusUnauthorized:
evidence.Hint = execution.FailureHintRefreshRequired
evidence.ReplaySafety = execution.ReplaySafetyRejectedBeforeProcessing
case status == http.StatusForbidden:
evidence.Hint = execution.FailureHintCandidateUnavailable
evidence.ReplaySafety = execution.ReplaySafetyRejectedBeforeProcessing
case status == http.StatusBadRequest:
evidence.Hint = execution.FailureHintRequestRejected
case status == http.StatusTooManyRequests:
evidence.Hint = execution.FailureHintRateLimited
case status >= http.StatusInternalServerError:
evidence.Hint = execution.FailureHintHostError
}
if evidence.Summary == "" {
evidence.Summary = safeErrorSummary(err, credential.redactionValues())
}
return status, evidence
}
func grokProviderErrorSummary(status int, code string) string {
switch {
case status == http.StatusUnauthorized:
return "Grok authorization was rejected"
case status == http.StatusForbidden:
return "Grok access was denied"
case status == http.StatusTooManyRequests && strings.Contains(strings.ToLower(code), "free-usage-exhausted"):
return "Grok included free usage is exhausted"
case status == http.StatusTooManyRequests:
return "Grok upstream rate limit was reached"
case status >= http.StatusInternalServerError:
return "Grok upstream service failed"
case status >= http.StatusBadRequest:
return "Grok upstream request was rejected"
default:
return "Grok upstream request failed"
}
}
var _ providerBridge = (*grokProviderBridge)(nil)
var _ providerLocalTokenCounter = (*grokProviderBridge)(nil)
@@ -0,0 +1,105 @@
package cpa
import (
"context"
"net/http"
"strings"
"testing"
"time"
"gpt-load/internal/execution"
)
type grokProviderTestError struct {
status int
code string
retry time.Duration
}
func (err grokProviderTestError) Error() string { return "Grok provider test error" }
func (err grokProviderTestError) StatusCode() int { return err.status }
func (err grokProviderTestError) ErrorCode() string { return err.code }
func (err grokProviderTestError) RetryAfter() *time.Duration {
if err.retry <= 0 {
return nil
}
value := err.retry
return &value
}
func TestGrokProviderClassifiesOAuthAndQuotaFailures(t *testing.T) {
bridge := newGrokProviderBridge()
credential := grokProviderCredential{}
for _, test := range []struct {
name string
err error
hint execution.FailureHint
replay execution.ReplaySafety
}{
{name: "unauthorized", err: grokProviderTestError{status: http.StatusUnauthorized}, hint: execution.FailureHintRefreshRequired, replay: execution.ReplaySafetyRejectedBeforeProcessing},
{name: "forbidden", err: grokProviderTestError{status: http.StatusForbidden}, hint: execution.FailureHintCandidateUnavailable, replay: execution.ReplaySafetyRejectedBeforeProcessing},
{name: "invalid request", err: grokProviderTestError{status: http.StatusBadRequest}, hint: execution.FailureHintRequestRejected},
{name: "free usage", err: grokProviderTestError{status: http.StatusTooManyRequests, code: "subscription:free-usage-exhausted", retry: 24 * time.Hour}, hint: execution.FailureHintRateLimited},
{name: "host", err: grokProviderTestError{status: http.StatusServiceUnavailable}, hint: execution.FailureHintHostError},
} {
t.Run(test.name, func(t *testing.T) {
status, evidence := bridge.ClassifyError(t.Context(), test.err, credential)
if status != test.err.(grokProviderTestError).status || evidence == nil ||
evidence.Hint != test.hint || evidence.ReplaySafety != test.replay {
t.Fatalf("classification = %d/%#v", status, evidence)
}
if test.name == "free usage" && evidence.RetryAfter != 24*time.Hour {
t.Fatalf("RetryAfter = %v", evidence.RetryAfter)
}
})
}
}
func TestGrokLocalTokenCountRejectsStatefulAndMultimodalInputs(t *testing.T) {
bridge := newGrokProviderBridge()
for _, test := range []struct {
name string
payload string
wantErr bool
}{
{name: "text", payload: `{"model":"grok-4.3","input":"hello"}`},
{name: "stateful", payload: `{"model":"grok-4.3","previous_response_id":"resp_1","input":"hello"}`, wantErr: true},
{name: "image", payload: `{"model":"grok-4.3","input":[{"type":"message","role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}]}`, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
err := bridge.ValidateLocalTokenCount(providerRequest{
Model: "grok-4.3", Format: "openai-response", Payload: []byte(test.payload),
})
if (err != nil) != test.wantErr {
t.Fatalf("ValidateLocalTokenCount() error = %v", err)
}
})
}
}
func TestGrokLocalTokenCountDoesNotRequireCredential(t *testing.T) {
bridge := newGrokProviderBridge()
response, err := bridge.CountTokensLocal(context.Background(), providerRequest{
Model: "grok-4.3", Format: "openai-response",
Payload: []byte(`{"model":"grok-4.3","input":"hello"}`),
})
if err != nil {
t.Fatal(err)
}
if len(response.Payload) == 0 || response.Headers.Get(localTokenCountHeader) != "local-estimate" {
t.Fatalf("response = %#v", response)
}
if !strings.Contains(string(response.Payload), `"object":"response.input_tokens"`) ||
!strings.Contains(string(response.Payload), `"input_tokens":`) {
t.Fatalf("Responses token count = %s", response.Payload)
}
}
func TestGrokContinuityScopeIsCredentialAndModelScoped(t *testing.T) {
left := grokContinuityScope("tenant", "credential-1", "grok-4.3", "attempt")
right := grokContinuityScope("tenant", "credential-2", "grok-4.3", "attempt")
otherModel := grokContinuityScope("tenant", "credential-1", "grok-code-fast-1", "attempt")
if left == "" || left == right || left == otherModel {
t.Fatalf("continuity scopes = %q / %q / %q", left, right, otherModel)
}
}
+14
View File
@@ -64,6 +64,20 @@ func TestAdapterDelegatesRouteValidationByProviderKind(t *testing.T) {
}
}
func TestAdapterValidatesAllDeclaredGrokRoutes(t *testing.T) {
registry := channel.NewRegistry()
adapter := NewAdapter(nil, registry)
descriptor, ok := registry.Get(channel.Grok)
if !ok {
t.Fatal("Grok channel is missing")
}
for _, route := range descriptor.Routes {
if err := adapter.ValidateRouteCapability(channel.ProviderGrok, route); err != nil {
t.Fatalf("ValidateRouteCapability(%#v) error = %v", route, err)
}
}
}
func TestRequestScopedFailureSurvivesErrorWrapping(t *testing.T) {
if !requestScopedFailure(errors.New("unrelated: " + requestScopedTestError{}.Error())) {
// A string with the same text is deliberately not sufficient.
@@ -129,5 +129,6 @@ func completeBindings(bifrost execution.Executor, codex execution.Executor) []Bi
{ProviderKind: channel.ProviderCodex, Adapter: codex},
{ProviderKind: channel.ProviderClaude, Adapter: codex},
{ProviderKind: channel.ProviderAntigravity, Adapter: codex},
{ProviderKind: channel.ProviderGrok, Adapter: codex},
}
}
+1
View File
@@ -624,6 +624,7 @@ func TestAutoMigrateCreatesUsageJournalAndMigrationLedger(t *testing.T) {
"0006_upstream_protocol",
"0007_model_price_mode_schedules",
"0008_observation_auth_refresh",
"0009_device_oauth",
}
if !reflect.DeepEqual(migrationIDs, wantMigrationIDs) {
t.Fatalf("schema_migrations IDs = %v, want %v", migrationIDs, wantMigrationIDs)
+6
View File
@@ -82,6 +82,12 @@ var migrations = []migration{
Validate: migrationfiles.Validate0008,
ValidateRecoverable: migrationfiles.ValidateRecoverable0008,
},
{
ID: migrationfiles.ID0009,
Up: migrationfiles.Up0009,
Validate: migrationfiles.Validate0009,
ValidateRecoverable: migrationfiles.ValidateRecoverable0009,
},
}
func applyMigrations(db *gorm.DB) error {
+1
View File
@@ -20,6 +20,7 @@ func TestMigrationRegistryContainsOrderedMigrations(t *testing.T) {
migrationfiles.ID0006,
migrationfiles.ID0007,
migrationfiles.ID0008,
migrationfiles.ID0009,
}
if len(migrations) != len(wantIDs) {
t.Fatalf("migration registry length = %d, want %d", len(migrations), len(wantIDs))
@@ -0,0 +1,190 @@
package migrations
import (
"database/sql"
"fmt"
"strings"
"gorm.io/gorm"
)
const ID0009 = "0009_device_oauth"
const credentialStageAuthorizationConstraint0009 = "chk_credential_stage_authorization_method"
type credentialStageAuthorization0009 struct {
AuthorizationMethod string `gorm:"column:authorization_method;check:chk_credential_stage_authorization_method,authorization_method IN ('browser_oauth','device_oauth','oauth_file')"`
}
func (credentialStageAuthorization0009) TableName() string { return "credential_stages" }
// Up0009 extends the durable authorization method contract for RFC 8628 device
// OAuth while retaining all existing credential stages and indexes.
func Up0009(db *gorm.DB) error {
if err := validateCredentialStagesTable0009(db); err != nil {
return err
}
if definition, err := credentialStageAuthorizationConstraintDefinition0009(db); err == nil &&
strings.Contains(strings.ToLower(definition), "device_oauth") {
return Validate0009(db)
}
if strings.EqualFold(db.Dialector.Name(), "sqlite") {
if err := rebuildCredentialStagesSQLite0009(db); err != nil {
return err
}
return Validate0009(db)
}
model := &credentialStageAuthorization0009{}
if db.Migrator().HasConstraint(model, credentialStageAuthorizationConstraint0009) {
if err := db.Migrator().DropConstraint(model, credentialStageAuthorizationConstraint0009); err != nil {
return fmt.Errorf("drop credential stage authorization constraint: %w", err)
}
}
if err := db.Migrator().CreateConstraint(model, credentialStageAuthorizationConstraint0009); err != nil {
return fmt.Errorf("create credential stage authorization constraint: %w", err)
}
return Validate0009(db)
}
func rebuildCredentialStagesSQLite0009(db *gorm.DB) error {
const temporaryTable = "credential_stages_0009"
if err := db.Exec(`DROP TABLE IF EXISTS credential_stages_0009`).Error; err != nil {
return fmt.Errorf("drop stale SQLite credential stages table: %w", err)
}
if err := db.Exec(`CREATE TABLE credential_stages_0009 (
id varchar(36) NOT NULL,
channel_id varchar(64) NOT NULL,
connection_type varchar(32) NOT NULL,
authorization_method varchar(32) NOT NULL,
status varchar(32) NOT NULL,
encrypted_payload text NOT NULL,
payload_schema_version integer NOT NULL DEFAULT 1,
safe_summary_json json NOT NULL,
identity_fingerprint varchar(128) NOT NULL DEFAULT '',
oauth_state_hash varchar(128),
expires_at_ms integer NOT NULL,
consumed_at_ms integer,
consumed_group_id integer,
error_code varchar(64) NOT NULL DEFAULT '',
created_at_ms integer NOT NULL,
updated_at_ms integer NOT NULL,
PRIMARY KEY (id),
CONSTRAINT chk_credential_stage_updated_at CHECK (updated_at_ms >= 0),
CONSTRAINT chk_credential_stage_payload_schema CHECK (payload_schema_version > 0),
CONSTRAINT chk_credential_stage_consumed_at CHECK (consumed_at_ms IS NULL OR consumed_at_ms >= 0),
CONSTRAINT chk_credential_stage_expires_at CHECK (expires_at_ms >= 0),
CONSTRAINT chk_credential_stage_created_at CHECK (created_at_ms >= 0),
CONSTRAINT chk_credential_stage_connection_type CHECK (connection_type = 'subscription'),
CONSTRAINT chk_credential_stage_authorization_method CHECK (authorization_method IN ('browser_oauth','device_oauth','oauth_file')),
CONSTRAINT chk_credential_stage_status CHECK (status IN ('pending_authorization','exchanging','ready','consumed','failed','cancelled','expired','outcome_unknown'))
)`).Error; err != nil {
return fmt.Errorf("create SQLite credential stages replacement: %w", err)
}
columns := `id, channel_id, connection_type, authorization_method, status,
encrypted_payload, payload_schema_version, safe_summary_json, identity_fingerprint,
oauth_state_hash, expires_at_ms, consumed_at_ms, consumed_group_id, error_code,
created_at_ms, updated_at_ms`
if err := db.Exec(fmt.Sprintf(
"INSERT INTO %s (%s) SELECT %s FROM credential_stages",
temporaryTable, columns, columns,
)).Error; err != nil {
return fmt.Errorf("copy SQLite credential stages: %w", err)
}
if err := db.Exec(`DROP TABLE credential_stages`).Error; err != nil {
return fmt.Errorf("drop old SQLite credential stages: %w", err)
}
if err := db.Exec(`ALTER TABLE credential_stages_0009 RENAME TO credential_stages`).Error; err != nil {
return fmt.Errorf("rename SQLite credential stages replacement: %w", err)
}
for _, statement := range []string{
`CREATE UNIQUE INDEX idx_credential_stages_oauth_state ON credential_stages (oauth_state_hash)`,
`CREATE INDEX idx_credential_stages_status_expires ON credential_stages (status, expires_at_ms, updated_at_ms)`,
} {
if err := db.Exec(statement).Error; err != nil {
return fmt.Errorf("restore SQLite credential stage index: %w", err)
}
}
return nil
}
func ValidateRecoverable0009(db *gorm.DB) error {
if err := validateCredentialStagesTable0009(db); err != nil {
return err
}
if !db.Migrator().HasConstraint(&credentialStageAuthorization0009{}, credentialStageAuthorizationConstraint0009) {
return nil
}
definition, err := credentialStageAuthorizationConstraintDefinition0009(db)
if err != nil {
return err
}
if strings.Contains(strings.ToLower(definition), "device_oauth") {
return nil
}
// The frozen 0002 constraint is a safe, known predecessor which Up0009 can replace.
lower := strings.ToLower(definition)
if strings.Contains(lower, "browser_oauth") && strings.Contains(lower, "oauth_file") {
return nil
}
return fmt.Errorf("credential stage authorization constraint is not recoverable")
}
func Validate0009(db *gorm.DB) error {
if err := validateCredentialStagesTable0009(db); err != nil {
return err
}
if !db.Migrator().HasConstraint(&credentialStageAuthorization0009{}, credentialStageAuthorizationConstraint0009) {
return fmt.Errorf("credential stage authorization constraint is missing")
}
definition, err := credentialStageAuthorizationConstraintDefinition0009(db)
if err != nil {
return err
}
lower := strings.ToLower(definition)
for _, method := range []string{"browser_oauth", "device_oauth", "oauth_file"} {
if !strings.Contains(lower, method) {
return fmt.Errorf("credential stage authorization constraint does not allow %q", method)
}
}
return nil
}
func validateCredentialStagesTable0009(db *gorm.DB) error {
if db == nil || db.Dialector == nil || !db.Migrator().HasTable("credential_stages") {
return fmt.Errorf("credential stages table is missing")
}
return nil
}
func credentialStageAuthorizationConstraintDefinition0009(db *gorm.DB) (string, error) {
var definition sql.NullString
switch strings.ToLower(db.Dialector.Name()) {
case "sqlite":
if err := db.Raw("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?", "credential_stages").Scan(&definition).Error; err != nil {
return "", fmt.Errorf("inspect SQLite credential stage constraint: %w", err)
}
case "mysql":
if err := db.Raw(
"SELECT CHECK_CLAUSE FROM information_schema.CHECK_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = ? LIMIT 1",
credentialStageAuthorizationConstraint0009,
).Scan(&definition).Error; err != nil {
return "", fmt.Errorf("inspect MySQL credential stage constraint: %w", err)
}
case "postgres", "postgresql":
if err := db.Raw(`
SELECT pg_get_constraintdef(c.oid)
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.conname = ? AND t.relname = ? AND n.nspname = current_schema()
LIMIT 1`, credentialStageAuthorizationConstraint0009, "credential_stages").Scan(&definition).Error; err != nil {
return "", fmt.Errorf("inspect PostgreSQL credential stage constraint: %w", err)
}
default:
return "", fmt.Errorf("unsupported database driver %q", db.Dialector.Name())
}
if !definition.Valid || strings.TrimSpace(definition.String) == "" {
return "", fmt.Errorf("credential stage authorization constraint definition is missing")
}
return definition.String, nil
}
@@ -0,0 +1,44 @@
package migrations_test
import (
"testing"
"gorm.io/gorm"
"gpt-load/internal/storage/migrations"
)
func TestDeviceOAuthMigrationPreservesStagesAndExtendsConstraint(t *testing.T) {
db := openInitialTestDatabase(t)
for _, up := range []func(*gorm.DB) error{migrations.Up0001, migrations.Up0002} {
if err := up(db); err != nil {
t.Fatal(err)
}
}
insertCredentialStage0009(t, db, "existing", "browser_oauth")
if err := migrations.Up0009(db); err != nil {
t.Fatal(err)
}
insertCredentialStage0009(t, db, "device", "device_oauth")
if err := db.Exec(`UPDATE credential_stages SET authorization_method = 'unsupported' WHERE id = 'device'`).Error; err == nil {
t.Fatal("unsupported authorization method was accepted")
}
var count int64
if err := db.Table("credential_stages").Where("id IN ?", []string{"existing", "device"}).Count(&count).Error; err != nil || count != 2 {
t.Fatalf("preserved stages = %d, %v", count, err)
}
if err := migrations.Up0009(db); err != nil {
t.Fatalf("idempotent Up0009() error = %v", err)
}
}
func insertCredentialStage0009(t *testing.T, db *gorm.DB, id, method string) {
t.Helper()
if err := db.Exec(`INSERT INTO credential_stages (
id, channel_id, connection_type, authorization_method, status,
encrypted_payload, payload_schema_version, safe_summary_json,
identity_fingerprint, expires_at_ms, error_code, created_at_ms, updated_at_ms
) VALUES (?, 'grok', 'subscription', ?, 'pending_authorization', 'encrypted', 2, '{}', '', 100, '', 1, 1)`, id, method).Error; err != nil {
t.Fatalf("insert stage %q/%q: %v", id, method, err)
}
}
@@ -0,0 +1,159 @@
package grok
import (
"context"
"errors"
"strings"
"time"
"gpt-load/internal/channel/modules"
"gpt-load/internal/channel/spec"
subscriptionruntime "gpt-load/internal/subscription/runtime"
)
type grokDriver struct{}
func newGrokDriver() *grokDriver { return &grokDriver{} }
func Implementations() subscriptionruntime.Implementations {
driver := newGrokDriver()
return subscriptionruntime.Implementations{
Drivers: []subscriptionruntime.Driver{driver},
ModelDiscoveries: []subscriptionruntime.ModelDiscovery{grokModelDiscovery{driver}},
}
}
func (*grokDriver) ID() spec.SubscriptionDriverID { return modules.GrokSubscriptionDriver }
func (*grokDriver) Parse(raw []byte) (subscriptionruntime.Credential, error) {
value, err := ParseCredentialJSON(raw)
if err != nil {
return subscriptionruntime.Credential{}, err
}
canonical, err := MarshalCredential(value)
if err != nil {
return subscriptionruntime.Credential{}, err
}
return grokRuntimeCredential(value, canonical), nil
}
func (*grokDriver) Refresh(ctx context.Context, current subscriptionruntime.Credential) (subscriptionruntime.Credential, error) {
value, err := ParseCredentialJSON(current.Canonical())
if err != nil {
return subscriptionruntime.Credential{}, err
}
refreshed, err := RefreshCredentialOnce(ctx, value)
if err != nil {
return subscriptionruntime.Credential{}, err
}
canonical, err := MarshalCredential(refreshed)
if err != nil {
return subscriptionruntime.Credential{}, err
}
return grokRuntimeCredential(refreshed, canonical), nil
}
func (*grokDriver) ClassifyRefreshFailure(err error) subscriptionruntime.RefreshFailure {
if errors.Is(err, ErrCredentialIdentityChanged) {
return subscriptionruntime.RefreshFailureIdentityChanged
}
var tokenErr *TokenEndpointError
if errors.As(err, &tokenErr) && IsDefinitiveRefreshRejection(tokenErr.Code) {
return subscriptionruntime.RefreshFailureReauthorizationRequired
}
return subscriptionruntime.RefreshFailureOutcomeUnknown
}
func (*grokDriver) BeginDeviceAuthorization(ctx context.Context) (subscriptionruntime.DeviceAuthorization, error) {
value, err := BeginDeviceAuthorization(ctx)
if err != nil {
return subscriptionruntime.DeviceAuthorization{}, err
}
state, err := marshalDeviceState(value.State)
if err != nil {
return subscriptionruntime.DeviceAuthorization{}, err
}
return subscriptionruntime.DeviceAuthorization{
VerificationURL: value.VerificationURL, UserCode: value.UserCode, DriverState: state,
ExpiresAt: value.ExpiresAt, PollInterval: value.PollInterval,
}, nil
}
func (*grokDriver) PollDeviceAuthorization(ctx context.Context, raw []byte) (subscriptionruntime.DeviceAuthorizationPoll, error) {
state, err := unmarshalDeviceState(raw)
if err != nil {
return subscriptionruntime.DeviceAuthorizationPoll{}, err
}
value, err := PollDeviceAuthorizationOnce(ctx, state)
if err != nil {
return subscriptionruntime.DeviceAuthorizationPoll{}, err
}
result := subscriptionruntime.DeviceAuthorizationPoll{PollInterval: value.PollInterval}
switch value.Status {
case DeviceAuthorizationPending:
result.Status = subscriptionruntime.DeviceAuthorizationPending
result.DriverState, err = marshalDeviceState(value.State)
case DeviceAuthorizationAuthorized:
result.Status = subscriptionruntime.DeviceAuthorizationAuthorized
canonical, marshalErr := MarshalCredential(value.Credential)
if marshalErr != nil {
return subscriptionruntime.DeviceAuthorizationPoll{}, marshalErr
}
result.Credential = grokRuntimeCredential(value.Credential, canonical)
case DeviceAuthorizationDenied:
result.Status = subscriptionruntime.DeviceAuthorizationDenied
case DeviceAuthorizationExpired:
result.Status = subscriptionruntime.DeviceAuthorizationExpired
default:
return subscriptionruntime.DeviceAuthorizationPoll{}, errors.New("unknown Grok device authorization status")
}
return result, err
}
func (*grokDriver) ImportCredential(ctx context.Context, raw []byte) (subscriptionruntime.Credential, error) {
value, err := ImportCredential(ctx, raw)
if err != nil {
return subscriptionruntime.Credential{}, err
}
canonical, err := MarshalCredential(value)
if err != nil {
return subscriptionruntime.Credential{}, err
}
return grokRuntimeCredential(value, canonical), nil
}
type grokModelDiscovery struct{ *grokDriver }
func (grokModelDiscovery) ID() spec.UtilityID { return modules.GrokModelDiscovery }
func (*grokDriver) DiscoverModels(ctx context.Context, credential subscriptionruntime.Credential) ([]string, error) {
value, err := ParseCredentialJSON(credential.Canonical())
if err != nil {
return nil, err
}
models, err := ListModels(ctx, value)
if err != nil {
var upstream *UpstreamHTTPError
if errors.As(err, &upstream) {
return nil, &subscriptionruntime.UpstreamHTTPError{StatusCode: upstream.StatusCode}
}
return nil, err
}
return models, nil
}
func grokRuntimeCredential(value Credential, canonical []byte) subscriptionruntime.Credential {
expiresAt, expires := CredentialExpiresAt(value)
account := subscriptionruntime.Account{
Email: strings.TrimSpace(value.Email), ExpiresAt: expiresAt, ExpiresAtKnown: expires,
}
if refreshed, err := time.Parse(time.RFC3339, strings.TrimSpace(value.LastRefresh)); err == nil {
account.LastRefresh, account.LastRefreshKnown = refreshed, true
}
return subscriptionruntime.NewCredential(
canonical, strings.TrimSpace(value.AccountID), account, expiresAt, expires, value.SecretValues(),
)
}
var _ subscriptionruntime.DeviceAuthorizationDriver = (*grokDriver)(nil)
var _ subscriptionruntime.CredentialFileImporter = (*grokDriver)(nil)
@@ -0,0 +1,71 @@
package grok
import (
"errors"
"reflect"
"testing"
"gpt-load/internal/channel/modules"
subscriptionruntime "gpt-load/internal/subscription/runtime"
)
func TestGrokDriverProducesProviderNeutralCredential(t *testing.T) {
driver := newGrokDriver()
credential, err := driver.Parse([]byte(`{
"type":"grok","access_token":"access-secret","refresh_token":"refresh-secret",
"id_token":"id-secret","account_id":"account-1","email":"owner@example.com",
"expired":"2030-01-01T00:00:00Z","token_endpoint":"https://auth.x.ai/oauth/token"
}`))
if err != nil {
t.Fatal(err)
}
if credential.Identity() != "account-1" || credential.Account().Email != "owner@example.com" ||
!credential.Account().ExpiresAtKnown {
t.Fatalf("credential = %#v / %#v", credential.Identity(), credential.Account())
}
if got := credential.SecretValues(); !reflect.DeepEqual(got, []string{
"access-secret", "refresh-secret", "id-secret", "account-1", "owner@example.com",
}) {
t.Fatalf("SecretValues() = %q", got)
}
}
func TestGrokDriverDeclaresDeviceOAuthImporterAndModelDiscovery(t *testing.T) {
driver := newGrokDriver()
if _, ok := any(driver).(subscriptionruntime.DeviceAuthorizationDriver); !ok {
t.Fatal("Grok driver does not implement DeviceAuthorizationDriver")
}
if _, ok := any(driver).(subscriptionruntime.CredentialFileImporter); !ok {
t.Fatal("Grok driver does not implement CredentialFileImporter")
}
implementations := Implementations()
if len(implementations.Drivers) != 1 || implementations.Drivers[0].ID() != modules.GrokSubscriptionDriver ||
len(implementations.ModelDiscoveries) != 1 || implementations.ModelDiscoveries[0].ID() != modules.GrokModelDiscovery ||
len(implementations.QuotaObservations) != 0 || len(implementations.ResetCreditActions) != 0 {
t.Fatalf("implementations = %#v", implementations)
}
}
func TestGrokDriverClassifiesRefreshFailures(t *testing.T) {
driver := newGrokDriver()
if got := driver.ClassifyRefreshFailure(ErrCredentialIdentityChanged); got != subscriptionruntime.RefreshFailureIdentityChanged {
t.Fatalf("identity classification = %v", got)
}
if got := driver.ClassifyRefreshFailure(&TokenEndpointError{StatusCode: 400, Code: "invalid_grant"}); got != subscriptionruntime.RefreshFailureReauthorizationRequired {
t.Fatalf("invalid_grant classification = %v", got)
}
if got := driver.ClassifyRefreshFailure(errors.New("network unavailable")); got != subscriptionruntime.RefreshFailureOutcomeUnknown {
t.Fatalf("network classification = %v", got)
}
}
func TestGrokDeviceStateRejectsUnknownOrIncompleteFields(t *testing.T) {
for _, raw := range [][]byte{
[]byte(`{"device_code":"code","token_endpoint":"https://auth.x.ai/token","userinfo_endpoint":"https://auth.x.ai/userinfo","poll_interval_seconds":5,"proxy":"bad"}`),
[]byte(`{"device_code":"code","poll_interval_seconds":5}`),
} {
if _, err := unmarshalDeviceState(raw); err == nil {
t.Fatalf("unmarshalDeviceState(%s) succeeded", raw)
}
}
}
@@ -0,0 +1,93 @@
package grok
import (
"context"
"net/http"
cpaembedded "github.com/router-for-me/CLIProxyAPI/v7/gptload-embedded/embedded"
)
type ExecuteRequest struct {
AttemptID string
Model string
Payload []byte
Format string
Headers http.Header
OriginalRequest []byte
ContinuityKey string
}
type ExecuteResponse struct {
Payload []byte
Headers http.Header
AppliedReasoningEffort string
}
type ExecuteStreamChunk struct {
Payload []byte
Err error
}
type ExecuteStreamResponse struct {
Headers http.Header
Chunks <-chan ExecuteStreamChunk
AppliedReasoningEffort string
}
type Executor interface {
Execute(context.Context, string, Credential, ExecuteRequest) (ExecuteResponse, error)
CountTokens(context.Context, ExecuteRequest) (ExecuteResponse, error)
ExecuteStream(context.Context, string, Credential, ExecuteRequest) (*ExecuteStreamResponse, error)
}
type executor struct{ bridge cpaembedded.GrokHTTPExecutor }
func NewExecutor() Executor { return &executor{bridge: cpaembedded.NewGrokHTTPExecutor()} }
func (value *executor) Execute(ctx context.Context, credentialID string, credential Credential, request ExecuteRequest) (ExecuteResponse, error) {
response, err := value.bridge.ExecuteCanonical(ctx, credentialID, credentialToBridge(credential), requestToBridge(request))
return responseFromBridge(response), normalizeError(err)
}
func (value *executor) CountTokens(ctx context.Context, request ExecuteRequest) (ExecuteResponse, error) {
response, err := value.bridge.CountTokensCanonical(ctx, requestToBridge(request))
return responseFromBridge(response), normalizeError(err)
}
func (value *executor) ExecuteStream(ctx context.Context, credentialID string, credential Credential, request ExecuteRequest) (*ExecuteStreamResponse, error) {
response, err := value.bridge.ExecuteStreamCanonical(ctx, credentialID, credentialToBridge(credential), requestToBridge(request))
if response == nil {
return nil, normalizeError(err)
}
chunks := make(chan ExecuteStreamChunk)
go func() {
defer close(chunks)
for chunk := range response.Chunks {
converted := ExecuteStreamChunk{Payload: append([]byte(nil), chunk.Payload...), Err: normalizeError(chunk.Err)}
select {
case chunks <- converted:
case <-ctx.Done():
return
}
}
}()
return &ExecuteStreamResponse{
Headers: response.Headers.Clone(), Chunks: chunks,
AppliedReasoningEffort: response.AppliedReasoningEffort,
}, normalizeError(err)
}
func requestToBridge(request ExecuteRequest) cpaembedded.ExecuteRequest {
return cpaembedded.ExecuteRequest{
AttemptID: request.AttemptID, Model: request.Model, Payload: append([]byte(nil), request.Payload...),
Format: request.Format, Headers: request.Headers.Clone(),
OriginalRequest: append([]byte(nil), request.OriginalRequest...), ContinuityKey: request.ContinuityKey,
}
}
func responseFromBridge(response cpaembedded.ExecuteResponse) ExecuteResponse {
return ExecuteResponse{
Payload: append([]byte(nil), response.Payload...), Headers: response.Headers.Clone(),
AppliedReasoningEffort: response.AppliedReasoningEffort,
}
}
@@ -0,0 +1,245 @@
// Package grok owns GPT-Load's Grok subscription contract and isolates the
// embedded CLIProxyAPI xAI bridge from the rest of the application.
package grok
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
cpaembedded "github.com/router-for-me/CLIProxyAPI/v7/gptload-embedded/embedded"
)
const Provider = cpaembedded.ProviderGrok
var ErrCredentialIdentityChanged = errors.New("refreshed grok credential identity changed")
type Credential struct {
Type string `json:"type"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token,omitempty"`
TokenType string `json:"token_type,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
Expire string `json:"expired"`
LastRefresh string `json:"last_refresh,omitempty"`
AccountID string `json:"account_id"`
Email string `json:"email"`
TokenEndpoint string `json:"token_endpoint"`
}
type DeviceAuthorization struct {
VerificationURL string
UserCode string
State DeviceAuthorizationState
ExpiresAt time.Time
PollInterval time.Duration
}
type DeviceAuthorizationState struct {
DeviceCode string `json:"device_code"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"userinfo_endpoint"`
PollIntervalSeconds int `json:"poll_interval_seconds"`
}
type DeviceAuthorizationStatus string
const (
DeviceAuthorizationPending DeviceAuthorizationStatus = "pending"
DeviceAuthorizationAuthorized DeviceAuthorizationStatus = "authorized"
DeviceAuthorizationDenied DeviceAuthorizationStatus = "denied"
DeviceAuthorizationExpired DeviceAuthorizationStatus = "expired"
)
type DeviceAuthorizationPoll struct {
Status DeviceAuthorizationStatus
State DeviceAuthorizationState
Credential Credential
PollInterval time.Duration
}
type TokenEndpointError struct {
StatusCode int
Code string
}
func (err *TokenEndpointError) Error() string {
if err == nil {
return "Grok token endpoint failed"
}
return fmt.Sprintf("Grok token endpoint returned status %d", err.StatusCode)
}
func (err *TokenEndpointError) HTTPStatusCode() int {
if err == nil {
return 0
}
return err.StatusCode
}
type UpstreamHTTPError struct {
Operation string
StatusCode int
}
func (err *UpstreamHTTPError) Error() string {
if err == nil {
return "Grok upstream request failed"
}
return fmt.Sprintf("Grok %s endpoint returned status %d", err.Operation, err.StatusCode)
}
func (err *UpstreamHTTPError) HTTPStatusCode() int {
if err == nil {
return 0
}
return err.StatusCode
}
func ParseCredentialJSON(raw []byte) (Credential, error) {
value, err := cpaembedded.ParseGrokCredentialJSON(raw)
if err != nil {
return Credential{}, normalizeError(err)
}
return credentialFromBridge(value), nil
}
func MarshalCredential(value Credential) ([]byte, error) {
raw, err := cpaembedded.MarshalGrokCredential(credentialToBridge(value))
if err != nil {
return nil, normalizeError(err)
}
return raw, nil
}
func CredentialExpiresAt(value Credential) (time.Time, bool) {
return cpaembedded.GrokCredentialExpiresAt(credentialToBridge(value))
}
func (value Credential) SecretValues() []string { return credentialToBridge(value).SecretValues() }
func BeginDeviceAuthorization(ctx context.Context) (DeviceAuthorization, error) {
value, err := cpaembedded.BeginGrokDeviceAuthorization(ctx, cpaembedded.GrokOptions{})
if err != nil {
return DeviceAuthorization{}, normalizeError(err)
}
return DeviceAuthorization{
VerificationURL: value.VerificationURL, UserCode: value.UserCode,
State: stateFromBridge(value.State), ExpiresAt: value.ExpiresAt, PollInterval: value.PollInterval,
}, nil
}
func PollDeviceAuthorizationOnce(ctx context.Context, state DeviceAuthorizationState) (DeviceAuthorizationPoll, error) {
value, err := cpaembedded.PollGrokDeviceAuthorizationOnce(ctx, stateToBridge(state), cpaembedded.GrokOptions{})
if err != nil {
return DeviceAuthorizationPoll{}, normalizeError(err)
}
return DeviceAuthorizationPoll{
Status: DeviceAuthorizationStatus(value.Status), State: stateFromBridge(value.State),
Credential: credentialFromBridge(value.Credential), PollInterval: value.PollInterval,
}, nil
}
func RefreshCredentialOnce(ctx context.Context, current Credential) (Credential, error) {
value, err := cpaembedded.RefreshGrokCredentialOnce(ctx, credentialToBridge(current), cpaembedded.GrokOptions{})
if err != nil {
return Credential{}, normalizeError(err)
}
return credentialFromBridge(value), nil
}
func ImportCredential(ctx context.Context, raw []byte) (Credential, error) {
value, err := cpaembedded.ImportGrokCredential(ctx, raw, cpaembedded.GrokOptions{})
if err != nil {
return Credential{}, normalizeError(err)
}
return credentialFromBridge(value), nil
}
func ListModels(ctx context.Context, credential Credential) ([]string, error) {
values, err := cpaembedded.DiscoverGrokModels(ctx, credentialToBridge(credential), cpaembedded.GrokOptions{})
if err != nil {
return nil, normalizeError(err)
}
return append([]string(nil), values...), nil
}
func IsDefinitiveRefreshRejection(code string) bool {
switch strings.ToLower(strings.TrimSpace(code)) {
case "invalid_grant", "refresh_token_expired", "refresh_token_revoked", "access_denied":
return true
default:
return false
}
}
func credentialFromBridge(value cpaembedded.GrokCredential) Credential {
return Credential{
Type: value.Type, AccessToken: value.AccessToken, RefreshToken: value.RefreshToken,
IDToken: value.IDToken, TokenType: value.TokenType, ExpiresIn: value.ExpiresIn,
Expire: value.Expire, LastRefresh: value.LastRefresh, AccountID: value.AccountID,
Email: value.Email, TokenEndpoint: value.TokenEndpoint,
}
}
func credentialToBridge(value Credential) cpaembedded.GrokCredential {
return cpaembedded.GrokCredential{
Type: value.Type, AccessToken: value.AccessToken, RefreshToken: value.RefreshToken,
IDToken: value.IDToken, TokenType: value.TokenType, ExpiresIn: value.ExpiresIn,
Expire: value.Expire, LastRefresh: value.LastRefresh, AccountID: value.AccountID,
Email: value.Email, TokenEndpoint: value.TokenEndpoint,
}
}
func stateFromBridge(value cpaembedded.GrokDeviceState) DeviceAuthorizationState {
return DeviceAuthorizationState{
DeviceCode: value.DeviceCode, TokenEndpoint: value.TokenEndpoint,
UserInfoEndpoint: value.UserInfoEndpoint, PollIntervalSeconds: value.PollIntervalSeconds,
}
}
func stateToBridge(value DeviceAuthorizationState) cpaembedded.GrokDeviceState {
return cpaembedded.GrokDeviceState{
DeviceCode: value.DeviceCode, TokenEndpoint: value.TokenEndpoint,
UserInfoEndpoint: value.UserInfoEndpoint, PollIntervalSeconds: value.PollIntervalSeconds,
}
}
func normalizeError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, cpaembedded.ErrGrokCredentialIdentityChanged) {
return ErrCredentialIdentityChanged
}
var tokenErr *cpaembedded.GrokTokenEndpointError
if errors.As(err, &tokenErr) {
return &TokenEndpointError{StatusCode: tokenErr.StatusCode, Code: strings.TrimSpace(tokenErr.Code)}
}
var upstream *cpaembedded.GrokUpstreamHTTPError
if errors.As(err, &upstream) {
return &UpstreamHTTPError{Operation: strings.TrimSpace(upstream.Operation), StatusCode: upstream.StatusCode}
}
return err
}
func marshalDeviceState(value DeviceAuthorizationState) ([]byte, error) { return json.Marshal(value) }
func unmarshalDeviceState(raw []byte) (DeviceAuthorizationState, error) {
var value DeviceAuthorizationState
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return DeviceAuthorizationState{}, err
}
if strings.TrimSpace(value.DeviceCode) == "" || strings.TrimSpace(value.TokenEndpoint) == "" ||
strings.TrimSpace(value.UserInfoEndpoint) == "" || value.PollIntervalSeconds < 1 || value.PollIntervalSeconds > 60 {
return DeviceAuthorizationState{}, errors.New("invalid Grok device authorization state")
}
return value, nil
}
@@ -7,6 +7,7 @@ import (
"gpt-load/internal/subscription/providers/antigravity"
"gpt-load/internal/subscription/providers/claude"
"gpt-load/internal/subscription/providers/codex"
"gpt-load/internal/subscription/providers/grok"
subscriptionruntime "gpt-load/internal/subscription/runtime"
)
@@ -15,5 +16,6 @@ func Implementations() []subscriptionruntime.Implementations {
codex.Implementations(),
claude.Implementations(),
antigravity.Implementations(),
grok.Implementations(),
}
}
@@ -16,7 +16,7 @@ func TestRuntimeCompilesAllSubscriptionProviderCapabilities(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got, want := runtime.ChannelIDs(), []channel.ID{channel.Antigravity, channel.Claude, channel.Codex}; !reflect.DeepEqual(got, want) {
if got, want := runtime.ChannelIDs(), []channel.ID{channel.Antigravity, channel.Claude, channel.Codex, channel.Grok}; !reflect.DeepEqual(got, want) {
t.Fatalf("ChannelIDs() = %v, want %v", got, want)
}
tests := []struct {
@@ -29,6 +29,7 @@ func TestRuntimeCompilesAllSubscriptionProviderCapabilities(t *testing.T) {
{channel.Codex, string(modules.CodexSubscriptionDriver), string(modules.CodexModelDiscovery), string(modules.CodexQuotaObservation), string(modules.CodexResetCreditAction)},
{channel.Claude, string(modules.ClaudeSubscriptionDriver), string(modules.ClaudeModelDiscovery), string(modules.ClaudeQuotaObservation), ""},
{channel.Antigravity, string(modules.AntigravitySubscriptionDriver), string(modules.AntigravityModelDiscovery), string(modules.AntigravityQuotaObservation), ""},
{channel.Grok, string(modules.GrokSubscriptionDriver), string(modules.GrokModelDiscovery), "", ""},
}
for _, test := range tests {
t.Run(string(test.channelID), func(t *testing.T) {
@@ -41,7 +42,7 @@ func TestRuntimeCompilesAllSubscriptionProviderCapabilities(t *testing.T) {
t.Fatalf("discovery = %#v/%t", discovery, ok)
}
observation, ok := runtime.QuotaObservation(test.channelID)
if !ok || string(observation.ID()) != test.observationID {
if test.observationID == "" && ok || test.observationID != "" && (!ok || string(observation.ID()) != test.observationID) {
t.Fatalf("observation = %#v/%t", observation, ok)
}
action, ok := runtime.ResetCreditAction(test.channelID)
@@ -53,6 +54,12 @@ func TestRuntimeCompilesAllSubscriptionProviderCapabilities(t *testing.T) {
if _, ok := runtime.Driver(channel.OpenAI); ok {
t.Fatal("Driver(openai) unexpectedly resolved")
}
if device, ok := runtime.DeviceAuthorization(channel.Grok); !ok || device.ID() != modules.GrokSubscriptionDriver {
t.Fatalf("Grok device authorization = %#v/%t", device, ok)
}
if _, ok := runtime.BrowserAuthorization(channel.Grok); ok {
t.Fatal("Grok unexpectedly resolves browser OAuth")
}
}
type browserlessDriver struct{ subscriptionruntime.Driver }
+74 -16
View File
@@ -119,6 +119,43 @@ type BrowserAuthorizationDriver interface {
LocalCallback() (LocalCallbackSpec, bool)
}
// DeviceAuthorization describes one RFC 8628 device challenge. DriverState
// contains secrets and is persisted only inside the encrypted Stage payload.
type DeviceAuthorization struct {
VerificationURL string
UserCode string
DriverState []byte
ExpiresAt time.Time
PollInterval time.Duration
}
// DeviceAuthorizationStatus is the bounded result of one token-endpoint poll.
type DeviceAuthorizationStatus string
const (
DeviceAuthorizationPending DeviceAuthorizationStatus = "pending"
DeviceAuthorizationAuthorized DeviceAuthorizationStatus = "authorized"
DeviceAuthorizationDenied DeviceAuthorizationStatus = "denied"
DeviceAuthorizationExpired DeviceAuthorizationStatus = "expired"
)
// DeviceAuthorizationPoll contains the result of exactly one token request.
// Pending results may replace DriverState and adjust PollInterval (slow_down).
type DeviceAuthorizationPoll struct {
Status DeviceAuthorizationStatus
Credential Credential
DriverState []byte
PollInterval time.Duration
}
// DeviceAuthorizationDriver is implemented by subscription channels whose
// interactive flow uses a device code rather than an OAuth redirect callback.
type DeviceAuthorizationDriver interface {
Driver
BeginDeviceAuthorization(context.Context) (DeviceAuthorization, error)
PollDeviceAuthorization(context.Context, []byte) (DeviceAuthorizationPoll, error)
}
// ModelDiscovery is a narrow optional subscription capability.
type ModelDiscovery interface {
ID() spec.UtilityID
@@ -174,6 +211,7 @@ func (err *UpstreamHTTPError) Error() string {
type channelRuntime struct {
driver Driver
browser BrowserAuthorizationDriver
device DeviceAuthorizationDriver
callback *LocalCallbackSpec
discovery ModelDiscovery
observation QuotaObservation
@@ -279,27 +317,38 @@ func compileRuntime(
return nil, fmt.Errorf("compile subscription runtime: channel %q references unknown driver %q", descriptor.ID, bindings.SubscriptionDriver)
}
compiled := channelRuntime{driver: driver}
interactiveMethods := 0
for _, method := range descriptor.Connection.AuthorizationMethods {
if method != channel.AuthorizationBrowserOAuth {
continue
}
compiled.browser, ok = driver.(BrowserAuthorizationDriver)
if !ok {
return nil, fmt.Errorf("compile subscription runtime: channel %q declares browser OAuth without driver support", descriptor.ID)
}
callback, local := compiled.browser.LocalCallback()
if local {
callback, err = validateLocalCallbackSpec(callback)
if err != nil {
return nil, fmt.Errorf("compile subscription runtime: channel %q has invalid local callback: %w", descriptor.ID, err)
switch method {
case channel.AuthorizationBrowserOAuth:
interactiveMethods++
compiled.browser, ok = driver.(BrowserAuthorizationDriver)
if !ok {
return nil, fmt.Errorf("compile subscription runtime: channel %q declares browser OAuth without driver support", descriptor.ID)
}
if owner, duplicate := callbackChannels[callback.RedirectURI]; duplicate {
return nil, fmt.Errorf("compile subscription runtime: channels %q and %q share local callback %q", owner, descriptor.ID, callback.RedirectURI)
callback, local := compiled.browser.LocalCallback()
if local {
callback, err = validateLocalCallbackSpec(callback)
if err != nil {
return nil, fmt.Errorf("compile subscription runtime: channel %q has invalid local callback: %w", descriptor.ID, err)
}
if owner, duplicate := callbackChannels[callback.RedirectURI]; duplicate {
return nil, fmt.Errorf("compile subscription runtime: channels %q and %q share local callback %q", owner, descriptor.ID, callback.RedirectURI)
}
callbackChannels[callback.RedirectURI] = descriptor.ID
compiled.callback = &callback
}
case channel.AuthorizationDeviceOAuth:
interactiveMethods++
compiled.device, ok = driver.(DeviceAuthorizationDriver)
if !ok {
return nil, fmt.Errorf("compile subscription runtime: channel %q declares device OAuth without driver support", descriptor.ID)
}
callbackChannels[callback.RedirectURI] = descriptor.ID
compiled.callback = &callback
}
}
if interactiveMethods > 1 {
return nil, fmt.Errorf("compile subscription runtime: channel %q declares multiple interactive authorization methods", descriptor.ID)
}
if bindings.ModelDiscovery != "" {
compiled.discovery, ok = discoveryByID[spec.ExtensionID(bindings.ModelDiscovery)]
if !ok {
@@ -376,6 +425,15 @@ func (runtime *Runtime) BrowserAuthorization(channelID channel.ID) (BrowserAutho
return value.browser, ok && value.browser != nil
}
// DeviceAuthorization resolves the optional device authorization driver.
func (runtime *Runtime) DeviceAuthorization(channelID channel.ID) (DeviceAuthorizationDriver, bool) {
if runtime == nil {
return nil, false
}
value, ok := runtime.byChannel[channelID]
return value.device, ok && value.device != nil
}
// LocalCallback returns the startup-validated fixed callback for a channel.
func (runtime *Runtime) LocalCallback(channelID channel.ID) (LocalCallbackSpec, bool) {
if runtime == nil {
@@ -2,6 +2,7 @@ package subscriptionruntime
import (
"context"
"strings"
"testing"
"gpt-load/internal/channel"
@@ -15,6 +16,64 @@ func TestRuntimeRequiresExplicitImplementations(t *testing.T) {
}
}
type testDiscovery struct{ id spec.UtilityID }
func (value testDiscovery) ID() spec.UtilityID { return value.id }
func (testDiscovery) DiscoverModels(context.Context, Credential) ([]string, error) {
return nil, nil
}
type testObservation struct{ id spec.UtilityID }
func (value testObservation) ID() spec.UtilityID { return value.id }
func (testObservation) Observe(context.Context, Credential) (Observation, error) {
return Observation{}, nil
}
type testResetCredit struct{ id spec.ActionID }
func (value testResetCredit) ID() spec.ActionID { return value.id }
func (testResetCredit) Consume(context.Context, Credential, string) (ResetCreditResult, error) {
return ResetCreditResult{}, nil
}
type testBrowserDriver struct{ duplicateDriver }
func (testBrowserDriver) BeginAuthorization() (Authorization, error) { return Authorization{}, nil }
func (testBrowserDriver) CompleteAuthorization(context.Context, AuthorizationCompletion) (Credential, error) {
return Credential{}, nil
}
func (testBrowserDriver) AuthorizationFailureDefinitive(error) bool { return false }
func (testBrowserDriver) LocalCallback() (LocalCallbackSpec, bool) { return LocalCallbackSpec{}, false }
func completeTestRuntimeImplementations() ([]Driver, []ModelDiscovery, []QuotaObservation, []ResetCreditAction) {
return []Driver{
testBrowserDriver{duplicateDriver{id: modules.CodexSubscriptionDriver}},
testBrowserDriver{duplicateDriver{id: modules.ClaudeSubscriptionDriver}},
testBrowserDriver{duplicateDriver{id: modules.AntigravitySubscriptionDriver}},
duplicateDriver{id: modules.GrokSubscriptionDriver},
}, []ModelDiscovery{
testDiscovery{id: modules.CodexModelDiscovery},
testDiscovery{id: modules.ClaudeModelDiscovery},
testDiscovery{id: modules.AntigravityModelDiscovery},
testDiscovery{id: modules.GrokModelDiscovery},
}, []QuotaObservation{
testObservation{id: modules.CodexQuotaObservation},
testObservation{id: modules.ClaudeQuotaObservation},
testObservation{id: modules.AntigravityQuotaObservation},
}, []ResetCreditAction{
testResetCredit{id: modules.CodexResetCreditAction},
}
}
func TestRuntimeRejectsDeviceOAuthWithoutDriverSupport(t *testing.T) {
drivers, discoveries, observations, actions := completeTestRuntimeImplementations()
_, err := compileRuntime(channel.NewRegistry(), drivers, discoveries, observations, actions)
if err == nil || !strings.Contains(err.Error(), "device OAuth") {
t.Fatalf("compileRuntime() error = %v, want missing device OAuth support", err)
}
}
func TestRuntimeFailsClosedWhenBoundImplementationIsMissing(t *testing.T) {
_, err := compileRuntime(channel.NewRegistry(), nil, nil, nil, nil)
if err == nil {
+18 -2
View File
@@ -1,7 +1,7 @@
# CPA Embedded Bridge
This nested Go module is GPT-Load's deliberately small bridge to
CLIProxyAPI (CPA). It exists because the CPA Codex, Claude, and Antigravity executors and
CLIProxyAPI (CPA). It exists because the CPA Codex, Claude, Antigravity, and xAI executors and
OAuth helpers needed by GPT-Load are implemented in CPA `internal` packages and
cannot be imported from the root `gpt-load` module directly.
@@ -25,6 +25,11 @@ quota policy. This bridge only exposes:
- the execution-only Antigravity HTTP executor, upstream CountTokens request, and supported
protocol translators, with CPA refresh, fallback, cooldown, paid-credit fallback, and global
signature cache disabled.
- xAI OIDC discovery, one-shot device-code begin/poll, strict native/canonical file enrichment,
stable OIDC subject identity, and one-shot context-aware refresh;
- the execution-only Grok HTTP executor, live OAuth model discovery, explicit local CountTokens
estimator, and supported protocol translators, without CPA manager, refresh, retry, fallback,
WebSocket, image, or video execution paths.
It intentionally excludes CPA Manager, selector, pool, file store, server,
watcher, WebSocket/Auto executors, fallback, and internal retry loops.
@@ -43,7 +48,7 @@ resolve CPA itself at the exact version recorded in both `go.mod` files and
CPA upgrades are deliberate compatibility work, not automatic dependency
bumps:
1. Review upstream changes to Codex, Claude, and Antigravity OAuth, token, HTTP
1. Review upstream changes to Codex, Claude, Antigravity, and xAI OAuth, token, HTTP
executor, translation, headers, identity, model discovery, and usage observation code.
2. Update the CPA version in this module and run `go mod tidy` here.
3. Fix only bridge compatibility issues; keep the execution-only boundary and
@@ -97,3 +102,14 @@ CPA_LIVE_ANTIGRAVITY_MODEL=optional-antigravity-model-id \
Browser OAuth, refresh-token rotation, deliberate 401/429 responses, and provider
policy changes remain supervised live gates rather than default test behavior.
The Grok contract requires a prepared canonical xAI OAuth credential. It verifies live OAuth
models, all four unary/streaming protocol routes, and the three explicit local CountTokens
representations. It does not invent an active plan or quota observation because CPA and the
verified xAI execution surface expose no stable proactive quota endpoint.
```bash
CPA_LIVE_GROK_CREDENTIAL_FILE=/absolute/path/to/grok.json \
CPA_LIVE_GROK_MODEL=optional-grok-model-id \
go test -count=1 -run '^TestLiveGrokContract$' ./embedded
```
+280
View File
@@ -0,0 +1,280 @@
package embedded
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
const ProviderGrok = "grok"
var ErrGrokCredentialIdentityChanged = errors.New("refreshed grok credential identity changed")
// GrokCredential is GPT-Load's canonical, execution-only xAI OAuth schema.
type GrokCredential struct {
Type string `json:"type"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token,omitempty"`
TokenType string `json:"token_type,omitempty"`
ExpiresIn int `json:"expires_in,omitempty"`
Expire string `json:"expired"`
LastRefresh string `json:"last_refresh,omitempty"`
AccountID string `json:"account_id"`
Email string `json:"email"`
TokenEndpoint string `json:"token_endpoint"`
}
// GrokOptions provides test-only endpoint/clock seams. Production callers use
// zero values so all endpoints come from xAI's fixed issuer discovery.
type GrokOptions struct {
DiscoveryURL string
UserInfoURL string
ModelsURL string
HTTPClient *http.Client
Now func() time.Time
}
type GrokDeviceState struct {
DeviceCode string `json:"device_code"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"userinfo_endpoint"`
PollIntervalSeconds int `json:"poll_interval_seconds"`
}
type GrokDeviceAuthorization struct {
VerificationURL string
UserCode string
State GrokDeviceState
ExpiresAt time.Time
PollInterval time.Duration
}
type GrokDeviceStatus string
const (
GrokDevicePending GrokDeviceStatus = "pending"
GrokDeviceAuthorized GrokDeviceStatus = "authorized"
GrokDeviceDenied GrokDeviceStatus = "denied"
GrokDeviceExpired GrokDeviceStatus = "expired"
)
type GrokDevicePoll struct {
Status GrokDeviceStatus
State GrokDeviceState
Credential GrokCredential
PollInterval time.Duration
}
type GrokTokenEndpointError struct {
StatusCode int
Code string
}
func (err *GrokTokenEndpointError) Error() string {
if err == nil {
return "Grok token endpoint failed"
}
return fmt.Sprintf("Grok token endpoint returned status %d", err.StatusCode)
}
func (err *GrokTokenEndpointError) HTTPStatusCode() int {
if err == nil {
return 0
}
return err.StatusCode
}
type GrokUpstreamHTTPError struct {
Operation string
StatusCode int
}
func (err *GrokUpstreamHTTPError) Error() string {
if err == nil {
return "Grok upstream request failed"
}
return fmt.Sprintf("Grok %s endpoint returned status %d", err.Operation, err.StatusCode)
}
func (err *GrokUpstreamHTTPError) HTTPStatusCode() int {
if err == nil {
return 0
}
return err.StatusCode
}
func ParseGrokCredentialJSON(raw []byte) (GrokCredential, error) {
if len(raw) == 0 || len(raw) > maxCredentialBytes {
return GrokCredential{}, fmt.Errorf("credential JSON size is invalid")
}
allowed := grokCanonicalFields()
allowCPAAuthFileControlFields(allowed)
if err := validateClaudeCredentialObject(raw, allowed); err != nil {
return GrokCredential{}, err
}
if err := validateCPAAuthFileControlMetadata(raw); err != nil {
return GrokCredential{}, err
}
var credential GrokCredential
if err := json.Unmarshal(raw, &credential); err != nil {
return GrokCredential{}, fmt.Errorf("decode credential: %w", err)
}
normalizeGrokCredential(&credential)
if err := validateGrokCredential(credential); err != nil {
return GrokCredential{}, err
}
return credential, nil
}
func MarshalGrokCredential(credential GrokCredential) ([]byte, error) {
raw, err := json.Marshal(credential)
if err != nil {
return nil, err
}
parsed, err := ParseGrokCredentialJSON(raw)
clear(raw)
if err != nil {
return nil, err
}
return json.Marshal(parsed)
}
func grokCanonicalFields() map[string]struct{} {
return map[string]struct{}{
"type": {}, "access_token": {}, "refresh_token": {}, "id_token": {},
"token_type": {}, "expires_in": {}, "expired": {}, "last_refresh": {},
"account_id": {}, "email": {}, "token_endpoint": {},
}
}
func normalizeGrokCredential(credential *GrokCredential) {
credential.Type = strings.ToLower(strings.TrimSpace(credential.Type))
credential.AccessToken = strings.TrimSpace(credential.AccessToken)
credential.RefreshToken = strings.TrimSpace(credential.RefreshToken)
credential.IDToken = strings.TrimSpace(credential.IDToken)
credential.TokenType = strings.TrimSpace(credential.TokenType)
if credential.TokenType == "" {
credential.TokenType = "Bearer"
}
credential.Expire = strings.TrimSpace(credential.Expire)
credential.LastRefresh = strings.TrimSpace(credential.LastRefresh)
credential.AccountID = strings.TrimSpace(credential.AccountID)
credential.Email = strings.TrimSpace(credential.Email)
credential.TokenEndpoint = strings.TrimSpace(credential.TokenEndpoint)
}
func validateGrokCredential(credential GrokCredential) error {
return validateGrokCredentialWithOptions(credential, GrokOptions{})
}
func validateGrokCredentialWithOptions(credential GrokCredential, options GrokOptions) error {
if credential.Type != ProviderGrok {
return fmt.Errorf("credential type must be grok")
}
for field, value := range map[string]string{
"access_token": credential.AccessToken, "refresh_token": credential.RefreshToken,
"account_id": credential.AccountID, "email": credential.Email,
"expired": credential.Expire, "token_endpoint": credential.TokenEndpoint,
} {
if value == "" {
return fmt.Errorf("credential %s is required", field)
}
}
if credential.ExpiresIn < 0 {
return fmt.Errorf("credential expires_in is invalid")
}
if !strings.EqualFold(credential.TokenType, "Bearer") {
return fmt.Errorf("credential token_type must be Bearer")
}
if err := validateTimestamp("expired", credential.Expire); err != nil {
return err
}
if err := validateTimestamp("last_refresh", credential.LastRefresh); err != nil {
return err
}
for field, value := range map[string]string{
"account_id": credential.AccountID, "email": credential.Email,
"token_type": credential.TokenType, "token_endpoint": credential.TokenEndpoint,
} {
if len(value) > 2048 || strings.ContainsAny(value, "\r\n\x00") {
return fmt.Errorf("credential %s is invalid", field)
}
}
if _, err := validateGrokOAuthEndpoint(
credential.TokenEndpoint,
"token_endpoint",
grokAllowsTestEndpoints(options),
); err != nil {
return err
}
return nil
}
func grokAllowsTestEndpoints(options GrokOptions) bool {
return strings.TrimSpace(options.DiscoveryURL) != "" ||
strings.TrimSpace(options.UserInfoURL) != "" ||
strings.TrimSpace(options.ModelsURL) != "" ||
options.HTTPClient != nil
}
func GrokCredentialExpiresAt(credential GrokCredential) (time.Time, bool) {
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(credential.Expire))
return parsed, err == nil
}
func (credential GrokCredential) SecretValues() []string {
return []string{
credential.AccessToken, credential.RefreshToken, credential.IDToken,
credential.AccountID, credential.Email,
}
}
func BeginGrokDeviceAuthorization(ctx context.Context, options GrokOptions) (GrokDeviceAuthorization, error) {
return beginGrokDeviceAuthorization(ctx, options)
}
func PollGrokDeviceAuthorizationOnce(ctx context.Context, state GrokDeviceState, options GrokOptions) (GrokDevicePoll, error) {
return pollGrokDeviceAuthorizationOnce(ctx, state, options)
}
func ImportGrokCredential(ctx context.Context, raw []byte, options GrokOptions) (GrokCredential, error) {
return importGrokCredential(ctx, raw, options)
}
func RefreshGrokCredentialOnce(ctx context.Context, current GrokCredential, options GrokOptions) (GrokCredential, error) {
return refreshGrokCredentialOnce(ctx, current, options)
}
func grokNow(options GrokOptions) time.Time {
if options.Now != nil {
return options.Now().UTC()
}
return time.Now().UTC()
}
func grokHTTPClient(options GrokOptions) *http.Client {
if options.HTTPClient != nil {
return clientWithoutRedirects(options.HTTPClient)
}
return clientWithoutRedirects(http.DefaultClient)
}
func decodeGrokDeviceState(raw []byte) (GrokDeviceState, error) {
var state GrokDeviceState
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&state); err != nil {
return GrokDeviceState{}, err
}
if strings.TrimSpace(state.DeviceCode) == "" || strings.TrimSpace(state.TokenEndpoint) == "" ||
strings.TrimSpace(state.UserInfoEndpoint) == "" || state.PollIntervalSeconds < 1 || state.PollIntervalSeconds > 60 {
return GrokDeviceState{}, fmt.Errorf("Grok device authorization state is invalid")
}
return state, nil
}
+297
View File
@@ -0,0 +1,297 @@
package embedded
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
internalexecutor "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor"
"github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator"
"github.com/tidwall/sjson"
)
const grokCPAProvider = "xai"
type GrokExecutionError struct {
status int
code string
summary string
retryAfter time.Duration
}
func (err *GrokExecutionError) Error() string {
if err == nil || err.summary == "" {
return "Grok upstream request failed"
}
return err.summary
}
func (err *GrokExecutionError) StatusCode() int {
if err == nil {
return 0
}
return err.status
}
func (err *GrokExecutionError) ErrorCode() string {
if err == nil {
return ""
}
return err.code
}
func (err *GrokExecutionError) RetryAfter() *time.Duration {
if err == nil || err.retryAfter <= 0 {
return nil
}
value := err.retryAfter
return &value
}
type GrokHTTPExecutor interface {
ExecuteCanonical(context.Context, string, GrokCredential, ExecuteRequest) (ExecuteResponse, error)
CountTokensCanonical(context.Context, ExecuteRequest) (ExecuteResponse, error)
ExecuteStreamCanonical(context.Context, string, GrokCredential, ExecuteRequest) (*ExecuteStreamResponse, error)
}
type grokHTTPExecutor struct {
cfg *internalconfig.Config
inner *internalexecutor.XAIExecutor
baseURL string
}
func NewGrokHTTPExecutor() GrokHTTPExecutor {
cfg := &internalconfig.Config{}
return &grokHTTPExecutor{cfg: cfg, inner: internalexecutor.NewXAIExecutor(cfg)}
}
func NewGrokAuth(id string, credential GrokCredential, baseURL string) *cliproxyauth.Auth {
metadata := map[string]any{
"type": ProviderGrok, "access_token": credential.AccessToken,
"auth_kind": "oauth", "using_api": false,
}
attributes := map[string]string{
"auth_kind": "oauth", "using_api": "false",
}
if value := strings.TrimSpace(baseURL); value != "" {
attributes["base_url"] = value
}
return &cliproxyauth.Auth{
ID: strings.TrimSpace(id), Provider: grokCPAProvider, Attributes: attributes, Metadata: metadata,
}
}
func (executor *grokHTTPExecutor) ExecuteCanonical(
ctx context.Context,
credentialID string,
credential GrokCredential,
request ExecuteRequest,
) (ExecuteResponse, error) {
if ctx == nil {
ctx = context.Background()
}
if err := validateGrokCredential(credential); err != nil {
return ExecuteResponse{}, err
}
request = prepareGrokExecutionRequest(request)
format := sdktranslator.FromString(request.Format)
auth := NewGrokAuth(credentialID, credential, executor.baseURL)
observation := newProviderExecutionObservation(request, grokCPAProvider)
response, err := executor.inner.Execute(executor.executionContext(ctx, auth, observation), auth, cliproxyexecutor.Request{
Model: request.Model, Payload: append([]byte(nil), request.Payload...), Format: format,
}, grokExecutorOptions(request, format, false))
if err != nil {
return ExecuteResponse{AppliedReasoningEffort: observation.reasoningEffort()}, normalizeGrokExecutionError(err)
}
return ExecuteResponse{
Payload: append([]byte(nil), response.Payload...), Headers: response.Headers.Clone(),
AppliedReasoningEffort: observation.reasoningEffort(),
}, nil
}
func (executor *grokHTTPExecutor) CountTokensCanonical(
ctx context.Context,
request ExecuteRequest,
) (ExecuteResponse, error) {
if ctx == nil {
ctx = context.Background()
}
request = prepareGrokExecutionRequest(request)
format := sdktranslator.FromString(request.Format)
response, err := executor.inner.CountTokens(ctx, NewGrokAuth("local-token-count", GrokCredential{}, executor.baseURL), cliproxyexecutor.Request{
Model: request.Model, Payload: append([]byte(nil), request.Payload...), Format: format,
}, grokExecutorOptions(request, format, false))
if err != nil {
return ExecuteResponse{}, normalizeGrokExecutionError(err)
}
payload := append([]byte(nil), response.Payload...)
if format == sdktranslator.FormatOpenAIResponse {
payload, err = normalizeCodexResponsesTokenCount(payload)
if err != nil {
return ExecuteResponse{}, err
}
}
return ExecuteResponse{Payload: payload, Headers: response.Headers.Clone()}, nil
}
func (executor *grokHTTPExecutor) ExecuteStreamCanonical(
ctx context.Context,
credentialID string,
credential GrokCredential,
request ExecuteRequest,
) (*ExecuteStreamResponse, error) {
if ctx == nil {
ctx = context.Background()
}
if err := validateGrokCredential(credential); err != nil {
return nil, err
}
request = prepareGrokExecutionRequest(request)
format := sdktranslator.FromString(request.Format)
auth := NewGrokAuth(credentialID, credential, executor.baseURL)
observation := newProviderExecutionObservation(request, grokCPAProvider)
response, err := executor.inner.ExecuteStream(executor.executionContext(ctx, auth, observation), auth, cliproxyexecutor.Request{
Model: request.Model, Payload: append([]byte(nil), request.Payload...), Format: format,
}, grokExecutorOptions(request, format, true))
if err != nil {
return &ExecuteStreamResponse{AppliedReasoningEffort: observation.reasoningEffort()}, normalizeGrokExecutionError(err)
}
chunks := make(chan ExecuteStreamChunk)
go func() {
defer close(chunks)
for chunk := range response.Chunks {
converted := ExecuteStreamChunk{Payload: append([]byte(nil), chunk.Payload...)}
if chunk.Err != nil {
converted.Err = normalizeGrokExecutionError(chunk.Err)
}
select {
case chunks <- converted:
case <-ctx.Done():
return
}
}
}()
return &ExecuteStreamResponse{
Headers: response.Headers.Clone(), Chunks: chunks,
AppliedReasoningEffort: observation.reasoningEffort(),
}, nil
}
func grokExecutorOptions(request ExecuteRequest, format sdktranslator.Format, stream bool) cliproxyexecutor.Options {
options := cliproxyexecutor.Options{
Stream: stream, Headers: request.Headers.Clone(),
OriginalRequest: append([]byte(nil), request.OriginalRequest...),
SourceFormat: format, ResponseFormat: format,
}
if scope := strings.TrimSpace(request.ContinuityKey); scope != "" {
options.Metadata = map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: scope}
}
return options
}
func prepareGrokExecutionRequest(request ExecuteRequest) ExecuteRequest {
if strings.TrimSpace(request.ContinuityKey) == "" && strings.TrimSpace(request.AttemptID) != "" {
request.ContinuityKey = "gpt-load-grok-attempt\x00" + strings.TrimSpace(request.AttemptID)
}
request.Payload = stripGrokCallerContinuity(request.Payload)
request.OriginalRequest = stripGrokCallerContinuity(request.OriginalRequest)
request.Headers = request.Headers.Clone()
for name := range request.Headers {
if strings.EqualFold(name, "Session-Id") || strings.EqualFold(name, "X-Grok-Conv-Id") {
delete(request.Headers, name)
}
}
return request
}
func stripGrokCallerContinuity(raw []byte) []byte {
result := append([]byte(nil), raw...)
for _, path := range []string{
"session_id", "sessionId", "prompt_cache_key", "metadata.session_id", "metadata.sessionId",
} {
updated, err := sjson.DeleteBytes(result, path)
if err == nil {
result = updated
}
}
return result
}
func (executor *grokHTTPExecutor) executionContext(
ctx context.Context,
auth *cliproxyauth.Auth,
observation *executionObservation,
) context.Context {
baseClient := helps.NewProxyAwareHTTPClient(ctx, executor.cfg, auth, 0)
transport := baseClient.Transport
if transport == nil {
transport = http.DefaultTransport
}
return context.WithValue(ctx, "cliproxy.roundtripper", noRedirectRoundTripper{
base: transport, observation: observation,
})
}
func normalizeGrokExecutionError(err error) error {
if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
var statusError interface {
error
StatusCode() int
}
if !errors.As(err, &statusError) || statusError == nil || statusError.StatusCode() == 0 {
return err
}
status := statusError.StatusCode()
retryAfter := time.Duration(0)
var retry interface{ RetryAfter() *time.Duration }
if errors.As(err, &retry) && retry != nil {
if value := retry.RetryAfter(); value != nil && *value > 0 {
retryAfter = *value
}
}
code := boundedGrokExecutionCode([]byte(statusError.Error()))
return &GrokExecutionError{
status: status, code: code, retryAfter: retryAfter,
summary: grokExecutionSummary(status, code),
}
}
func boundedGrokExecutionCode(raw []byte) string {
var payload struct {
Code string `json:"code"`
Error any `json:"error"`
}
if json.Unmarshal(raw, &payload) != nil {
return ""
}
code := strings.TrimSpace(payload.Code)
if code == "" {
if object, ok := payload.Error.(map[string]any); ok {
code, _ = object["code"].(string)
}
}
code = strings.TrimSpace(code)
if code == "" || len(code) > 128 || strings.ContainsAny(code, "\r\n\x00") {
return ""
}
return code
}
func grokExecutionSummary(status int, code string) string {
if strings.Contains(strings.ToLower(code), "free-usage-exhausted") {
return "Grok included free usage is exhausted."
}
return fmt.Sprintf("Grok upstream returned status %d", status)
}
var _ GrokHTTPExecutor = (*grokHTTPExecutor)(nil)
+308
View File
@@ -0,0 +1,308 @@
package embedded
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
internalconfig "github.com/router-for-me/CLIProxyAPI/v7/internal/config"
internalexecutor "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor"
"github.com/tidwall/gjson"
)
func TestGrokExecutorConvertsFourProtocolsUnaryAndStream(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/responses" || r.Header.Get("Authorization") != "Bearer access-secret" {
t.Fatalf("request = %s %s, auth %q", r.Method, r.URL.Path, r.Header.Get("Authorization"))
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: response.completed\n"))
_, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_1","object":"response","created_at":0,"status":"completed","model":"grok-4.3","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}` + "\n\n"))
}))
defer server.Close()
cfg := &internalconfig.Config{}
executor := &grokHTTPExecutor{cfg: cfg, inner: internalexecutor.NewXAIExecutor(cfg), baseURL: server.URL}
credential := testGrokExecutionCredential()
for _, test := range []struct {
name string
format string
payload string
usage string
}{
{name: "responses", format: "openai-response", payload: `{"model":"grok-4.3","input":"hi"}`, usage: "usage.input_tokens"},
{name: "chat", format: "openai", payload: `{"model":"grok-4.3","messages":[{"role":"user","content":"hi"}]}`, usage: "usage.prompt_tokens"},
{name: "anthropic", format: "claude", payload: `{"model":"grok-4.3","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`, usage: "usage.input_tokens"},
{name: "gemini", format: "gemini", payload: `{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, usage: "usageMetadata.promptTokenCount"},
} {
t.Run(test.name+" unary", func(t *testing.T) {
response, err := executor.ExecuteCanonical(t.Context(), "credential-1", credential, ExecuteRequest{
AttemptID: "attempt-1", Model: "grok-4.3", Format: test.format,
Payload: []byte(test.payload), OriginalRequest: []byte(test.payload), ContinuityKey: "tenant-scope",
})
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(response.Payload, []byte("ok")) || gjson.GetBytes(response.Payload, test.usage).Int() != 2 {
t.Fatalf("response = %s", response.Payload)
}
})
t.Run(test.name+" stream", func(t *testing.T) {
response, err := executor.ExecuteStreamCanonical(t.Context(), "credential-1", credential, ExecuteRequest{
AttemptID: "attempt-1", Model: "grok-4.3", Format: test.format,
Payload: []byte(test.payload), OriginalRequest: []byte(test.payload), ContinuityKey: "tenant-scope",
})
if err != nil {
t.Fatal(err)
}
var output []byte
for chunk := range response.Chunks {
if chunk.Err != nil {
t.Fatal(chunk.Err)
}
output = append(output, chunk.Payload...)
}
if len(output) == 0 {
t.Fatal("stream returned no translated chunks")
}
})
}
}
func TestGrokExecutorCountsTokensLocally(t *testing.T) {
executor := NewGrokHTTPExecutor()
for _, test := range []struct {
format string
payload string
path string
}{
{format: "openai-response", payload: `{"model":"grok-4.3","input":"hello world"}`, path: "input_tokens"},
{format: "claude", payload: `{"model":"grok-4.3","messages":[{"role":"user","content":"hello world"}]}`, path: "input_tokens"},
{format: "gemini", payload: `{"contents":[{"role":"user","parts":[{"text":"hello world"}]}]}`, path: "totalTokens"},
} {
response, err := executor.CountTokensCanonical(context.Background(), ExecuteRequest{
Model: "grok-4.3", Format: test.format, Payload: []byte(test.payload), OriginalRequest: []byte(test.payload),
})
if err != nil {
t.Fatal(err)
}
if gjson.GetBytes(response.Payload, test.path).Int() <= 0 || strings.Contains(string(response.Payload), "access-secret") {
t.Fatalf("count response = %s", response.Payload)
}
if test.format == "openai-response" && gjson.GetBytes(response.Payload, "object").String() != "response.input_tokens" {
t.Fatalf("Responses token count = %s", response.Payload)
}
}
}
func TestGrokExecutorPreservesRichChatRequestAndToolResponse(t *testing.T) {
var upstreamBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatal(err)
}
upstreamBody = append([]byte(nil), body...)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("event: response.completed\n"))
_, _ = w.Write([]byte(`data: {"type":"response.completed","response":{"id":"resp_tool","object":"response","status":"completed","model":"grok-4.3","output":[{"type":"reasoning","summary":[{"type":"summary_text","text":"considering"}]},{"type":"function_call","id":"fc_1","call_id":"call_1","name":"lookup","arguments":"{\"q\":\"x\"}","status":"completed"}],"usage":{"input_tokens":5,"output_tokens":3,"total_tokens":8}}}` + "\n\n"))
}))
defer server.Close()
payload := []byte(`{
"model":"grok-4.3","reasoning_effort":"high",
"messages":[
{"role":"system","content":"follow the rule"},
{"role":"user","content":[
{"type":"text","text":"inspect"},
{"type":"image_url","image_url":{"url":"data:image/png;base64,AAAA"}}
]}
],
"tools":[{"type":"function","function":{"name":"lookup","description":"lookup","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}}],
"tool_choice":"auto"
}`)
response, err := testGrokHTTPExecutor(server.URL).ExecuteCanonical(
t.Context(), "credential-1", testGrokExecutionCredential(), ExecuteRequest{
AttemptID: "attempt-1", Model: "grok-4.3", Format: "openai",
Payload: payload, OriginalRequest: payload, ContinuityKey: "tenant-scope",
},
)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(upstreamBody), "follow the rule") ||
gjson.GetBytes(upstreamBody, "tools.0.name").String() != "lookup" ||
gjson.GetBytes(upstreamBody, "reasoning.effort").String() != "high" ||
!strings.Contains(string(upstreamBody), "data:image/png;base64,AAAA") {
t.Fatalf("rich upstream request = %s", upstreamBody)
}
if gjson.GetBytes(response.Payload, "choices.0.message.tool_calls.0.function.name").String() != "lookup" ||
gjson.GetBytes(response.Payload, "choices.0.finish_reason").String() != "tool_calls" ||
gjson.GetBytes(response.Payload, "usage.prompt_tokens").Int() != 5 ||
gjson.GetBytes(response.Payload, "usage.completion_tokens").Int() != 3 ||
response.AppliedReasoningEffort != "high" {
t.Fatalf("rich downstream response = %s, applied=%q", response.Payload, response.AppliedReasoningEffort)
}
}
func TestGrokExecutionOnlyBridgeUsesOneDispatchAndRejectsRedirects(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.Header().Set("Location", "https://example.invalid/redirected")
w.WriteHeader(http.StatusTemporaryRedirect)
}))
defer server.Close()
executor := testGrokHTTPExecutor(server.URL)
_, err := executor.ExecuteCanonical(t.Context(), "credential-1", testGrokExecutionCredential(), testGrokRequest())
if !errors.Is(err, ErrRedirectNotAllowed) || calls.Load() != 1 {
t.Fatalf("redirect result = %v, calls = %d", err, calls.Load())
}
}
func TestGrokExecutionOnlyBridgeDoesNotRetryOrRefreshPreparedCredential(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusTooManyRequests)
_, _ = w.Write([]byte(`{"code":"subscription:free-usage-exhausted","error":"exhausted"}`))
}))
defer server.Close()
credential := testGrokExecutionCredential()
credential.Expire = time.Now().UTC().Add(time.Second).Format(time.RFC3339)
_, err := testGrokHTTPExecutor(server.URL).ExecuteCanonical(t.Context(), "credential-1", credential, testGrokRequest())
var upstream *GrokExecutionError
if !errors.As(err, &upstream) || upstream.StatusCode() != http.StatusTooManyRequests ||
upstream.ErrorCode() != "subscription:free-usage-exhausted" || calls.Load() != 1 {
t.Fatalf("execution result = %#v, calls = %d", err, calls.Load())
}
if retry := upstream.RetryAfter(); retry == nil || *retry != 24*time.Hour {
t.Fatalf("RetryAfter = %v", retry)
}
}
func TestGrokExecutionOnlyBridgeMapsBadCredentialsToUnauthorized(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"code":"unauthenticated:bad-credentials","error":"invalid OAuth access token"}`))
}))
defer server.Close()
_, err := testGrokHTTPExecutor(server.URL).ExecuteCanonical(
t.Context(), "credential-1", testGrokExecutionCredential(), testGrokRequest(),
)
var upstream *GrokExecutionError
if !errors.As(err, &upstream) || upstream.StatusCode() != http.StatusUnauthorized ||
upstream.ErrorCode() != "unauthenticated:bad-credentials" {
t.Fatalf("bad credentials error = %#v", err)
}
}
func TestGrokExecutionRequestRemovesCallerContinuity(t *testing.T) {
prepared := prepareGrokExecutionRequest(ExecuteRequest{
AttemptID: "attempt-1", ContinuityKey: "private-scope",
Payload: []byte(`{"input":"keep","session_id":"caller","prompt_cache_key":"caller-cache","metadata":{"sessionId":"nested"}}`),
OriginalRequest: []byte(`{"input":"keep","sessionId":"caller","prompt_cache_key":"caller-cache","metadata":{"session_id":"nested"}}`),
Headers: http.Header{"Session-Id": {"caller"}, "X-Grok-Conv-Id": {"caller-conversation"}},
})
for _, raw := range [][]byte{prepared.Payload, prepared.OriginalRequest} {
if !json.Valid(raw) || strings.Contains(string(raw), "caller") || !strings.Contains(string(raw), "keep") {
t.Fatalf("prepared payload = %s", raw)
}
}
if prepared.Headers.Get("Session-Id") != "" || prepared.Headers.Get("X-Grok-Conv-Id") != "" ||
prepared.ContinuityKey != "private-scope" {
t.Fatalf("prepared request = %#v", prepared)
}
}
func TestGrokExecutionOnlyBridgePropagatesCancellation(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
close(started)
select {
case <-r.Context().Done():
case <-release:
}
}))
defer func() {
close(release)
server.Close()
}()
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() {
_, err := testGrokHTTPExecutor(server.URL).ExecuteCanonical(ctx, "credential-1", testGrokExecutionCredential(), testGrokRequest())
done <- err
}()
<-started
cancel()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("cancellation error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("canceled Grok request did not return")
}
}
type grokWrappedStatusError struct {
status int
body string
}
func (err grokWrappedStatusError) Error() string { return err.body }
func (err grokWrappedStatusError) StatusCode() int { return err.status }
func (err grokWrappedStatusError) RetryAfter() *time.Duration {
value := 3 * time.Second
return &value
}
func TestNormalizeGrokExecutionErrorPreservesWrappedMetadata(t *testing.T) {
err := normalizeGrokExecutionError(fmt.Errorf("execute Grok request: %w", grokWrappedStatusError{
status: http.StatusTooManyRequests,
body: `{"code":"subscription:free-usage-exhausted"}`,
}))
var normalized *GrokExecutionError
if !errors.As(err, &normalized) || normalized.StatusCode() != http.StatusTooManyRequests ||
normalized.ErrorCode() != "subscription:free-usage-exhausted" || normalized.RetryAfter() == nil ||
*normalized.RetryAfter() != 3*time.Second {
t.Fatalf("normalized error = %#v", err)
}
}
func testGrokHTTPExecutor(baseURL string) *grokHTTPExecutor {
cfg := &internalconfig.Config{}
return &grokHTTPExecutor{cfg: cfg, inner: internalexecutor.NewXAIExecutor(cfg), baseURL: baseURL}
}
func testGrokRequest() ExecuteRequest {
payload := []byte(`{"model":"grok-4.3","input":"hello"}`)
return ExecuteRequest{
AttemptID: "attempt-1", Model: "grok-4.3", Format: "openai-response",
Payload: payload, OriginalRequest: append([]byte(nil), payload...), ContinuityKey: "tenant-scope",
}
}
func testGrokExecutionCredential() GrokCredential {
return GrokCredential{
Type: ProviderGrok, AccessToken: "access-secret", RefreshToken: "refresh-secret",
TokenType: "Bearer", AccountID: "account-1", Email: "owner@example.com",
TokenEndpoint: "https://auth.x.ai/oauth/token",
Expire: time.Now().UTC().Add(time.Hour).Format(time.RFC3339),
}
}
+78
View File
@@ -0,0 +1,78 @@
package embedded
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"sort"
"strings"
xaiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/xai"
)
const (
grokClientVersion = "0.2.120"
grokModelsURL = xaiauth.CLIChatProxyBaseURL + "/models"
)
func DiscoverGrokModels(ctx context.Context, credential GrokCredential, options GrokOptions) ([]string, error) {
endpoint := strings.TrimSpace(options.ModelsURL)
if endpoint == "" {
endpoint = grokModelsURL
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(credential.AccessToken))
request.Header.Set("X-XAI-Token-Auth", "xai-grok-cli")
request.Header.Set("x-grok-client-version", grokClientVersion)
request.Header.Set("User-Agent", "xai-grok-workspace/"+grokClientVersion)
request.Header.Set("x-grok-client-identifier", "grok-shell")
request.Header.Set("x-authenticateresponse", "authenticate-response")
response, err := grokHTTPClient(options).Do(request)
if err != nil {
return nil, err
}
defer func() { _ = response.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(response.Body, maxTokenResponse+1))
if err != nil {
return nil, err
}
defer clear(body)
if len(body) > maxTokenResponse {
return nil, fmt.Errorf("Grok models response is too large")
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return nil, &GrokUpstreamHTTPError{Operation: "models", StatusCode: response.StatusCode}
}
var payload struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, fmt.Errorf("decode Grok models: %w", err)
}
seen := make(map[string]struct{}, len(payload.Data))
models := make([]string, 0, len(payload.Data))
for _, item := range payload.Data {
id := strings.TrimSpace(item.ID)
if id == "" || len(id) > 256 || strings.ContainsAny(id, "\r\n\x00") {
continue
}
if _, duplicate := seen[id]; duplicate {
continue
}
seen[id] = struct{}{}
models = append(models, id)
}
if len(models) == 0 {
return nil, fmt.Errorf("Grok models response has no usable model IDs")
}
sort.Strings(models)
return models, nil
}
+577
View File
@@ -0,0 +1,577 @@
package embedded
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
xaiauth "github.com/router-for-me/CLIProxyAPI/v7/internal/auth/xai"
)
const grokDefaultPollInterval = 5 * time.Second
type grokDiscovery struct {
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"userinfo_endpoint"`
}
type grokTokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Error string `json:"error"`
}
type grokUserInfo struct {
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}
type grokImportFile struct {
Type string `json:"type"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Expire string `json:"expired"`
LastRefresh string `json:"last_refresh"`
AccountID string `json:"account_id"`
Subject string `json:"sub"`
Email string `json:"email"`
TokenEndpoint string `json:"token_endpoint"`
BaseURL string `json:"base_url"`
RedirectURI string `json:"redirect_uri"`
AuthKind string `json:"auth_kind"`
}
func beginGrokDeviceAuthorization(ctx context.Context, options GrokOptions) (GrokDeviceAuthorization, error) {
discovery, err := discoverGrokOAuth(ctx, options)
if err != nil {
return GrokDeviceAuthorization{}, err
}
form := url.Values{"client_id": {xaiauth.ClientID}, "scope": {xaiauth.Scope}}
body, status, err := doGrokForm(ctx, options, discovery.DeviceAuthorizationEndpoint, form)
if err != nil {
return GrokDeviceAuthorization{}, fmt.Errorf("begin Grok device authorization: %w", err)
}
defer clear(body)
if status != http.StatusOK {
return GrokDeviceAuthorization{}, &GrokUpstreamHTTPError{Operation: "device authorization", StatusCode: status}
}
var payload struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return GrokDeviceAuthorization{}, fmt.Errorf("decode Grok device authorization: %w", err)
}
verificationURL := strings.TrimSpace(payload.VerificationURIComplete)
if verificationURL == "" {
verificationURL = strings.TrimSpace(payload.VerificationURI)
}
if strings.TrimSpace(payload.DeviceCode) == "" || strings.TrimSpace(payload.UserCode) == "" ||
verificationURL == "" || payload.ExpiresIn <= 0 {
return GrokDeviceAuthorization{}, fmt.Errorf("Grok device authorization response is incomplete")
}
interval := time.Duration(payload.Interval) * time.Second
if interval < grokDefaultPollInterval {
interval = grokDefaultPollInterval
}
if interval > time.Minute {
return GrokDeviceAuthorization{}, fmt.Errorf("Grok device authorization interval is invalid")
}
expiresIn := time.Duration(payload.ExpiresIn) * time.Second
if expiresIn > xaiauth.MaxPollDuration {
expiresIn = xaiauth.MaxPollDuration
}
return GrokDeviceAuthorization{
VerificationURL: verificationURL,
UserCode: strings.TrimSpace(payload.UserCode),
State: GrokDeviceState{
DeviceCode: strings.TrimSpace(payload.DeviceCode), TokenEndpoint: discovery.TokenEndpoint,
UserInfoEndpoint: discovery.UserInfoEndpoint, PollIntervalSeconds: int(interval / time.Second),
},
ExpiresAt: grokNow(options).Add(expiresIn), PollInterval: interval,
}, nil
}
func pollGrokDeviceAuthorizationOnce(ctx context.Context, state GrokDeviceState, options GrokOptions) (GrokDevicePoll, error) {
raw, err := json.Marshal(state)
if err != nil {
return GrokDevicePoll{}, err
}
state, err = decodeGrokDeviceState(raw)
clear(raw)
if err != nil {
return GrokDevicePoll{}, err
}
form := url.Values{
"grant_type": {xaiauth.DeviceCodeGrantType}, "device_code": {state.DeviceCode}, "client_id": {xaiauth.ClientID},
}
body, status, err := doGrokForm(ctx, options, state.TokenEndpoint, form)
if err != nil {
return GrokDevicePoll{}, fmt.Errorf("poll Grok device authorization: %w", err)
}
defer clear(body)
var token grokTokenResponse
if err := json.Unmarshal(body, &token); err != nil {
return GrokDevicePoll{}, fmt.Errorf("decode Grok device token response: %w", err)
}
code := strings.ToLower(boundedGrokOAuthCode(token.Error))
interval := time.Duration(state.PollIntervalSeconds) * time.Second
if code != "" {
switch code {
case "authorization_pending":
return GrokDevicePoll{Status: GrokDevicePending, State: state, PollInterval: interval}, nil
case "slow_down":
interval += grokDefaultPollInterval
if interval > time.Minute {
interval = time.Minute
}
state.PollIntervalSeconds = int(interval / time.Second)
return GrokDevicePoll{Status: GrokDevicePending, State: state, PollInterval: interval}, nil
case "access_denied":
return GrokDevicePoll{Status: GrokDeviceDenied, State: state}, nil
case "expired_token":
return GrokDevicePoll{Status: GrokDeviceExpired, State: state}, nil
default:
return GrokDevicePoll{}, &GrokTokenEndpointError{StatusCode: status, Code: code}
}
}
if status != http.StatusOK {
return GrokDevicePoll{}, &GrokTokenEndpointError{StatusCode: status}
}
credential, err := grokCredentialFromToken(ctx, token, "", state.TokenEndpoint, state.UserInfoEndpoint, options, true)
if err != nil {
return GrokDevicePoll{}, err
}
return GrokDevicePoll{Status: GrokDeviceAuthorized, State: state, Credential: credential}, nil
}
func importGrokCredential(ctx context.Context, raw []byte, options GrokOptions) (GrokCredential, error) {
if len(raw) == 0 || len(raw) > maxCredentialBytes {
return GrokCredential{}, fmt.Errorf("credential JSON size is invalid")
}
allowed := grokCanonicalFields()
for _, field := range []string{"sub", "base_url", "redirect_uri", "auth_kind"} {
allowed[field] = struct{}{}
}
allowCPAAuthFileControlFields(allowed)
if err := validateClaudeCredentialObject(raw, allowed); err != nil {
return GrokCredential{}, err
}
if err := validateCPAAuthFileControlMetadata(raw); err != nil {
return GrokCredential{}, err
}
var imported grokImportFile
if err := json.Unmarshal(raw, &imported); err != nil {
return GrokCredential{}, fmt.Errorf("decode Grok credential: %w", err)
}
imported.Type = strings.ToLower(strings.TrimSpace(imported.Type))
if imported.Type != ProviderGrok && imported.Type != "xai" {
return GrokCredential{}, fmt.Errorf("credential type must be grok or xai")
}
if imported.AuthKind != "" && !strings.EqualFold(strings.TrimSpace(imported.AuthKind), "oauth") {
return GrokCredential{}, fmt.Errorf("credential auth_kind must be oauth")
}
if imported.TokenType != "" && !strings.EqualFold(strings.TrimSpace(imported.TokenType), "Bearer") {
return GrokCredential{}, fmt.Errorf("credential token_type must be Bearer")
}
if imported.ExpiresIn < 0 {
return GrokCredential{}, fmt.Errorf("credential expires_in is invalid")
}
if strings.TrimSpace(imported.AccountID) != "" && strings.TrimSpace(imported.Subject) != "" &&
strings.TrimSpace(imported.AccountID) != strings.TrimSpace(imported.Subject) {
return GrokCredential{}, ErrGrokCredentialIdentityChanged
}
if imported.BaseURL != "" {
baseURL := strings.TrimRight(strings.TrimSpace(imported.BaseURL), "/")
if baseURL != strings.TrimRight(xaiauth.DefaultAPIBaseURL, "/") && baseURL != strings.TrimRight(xaiauth.CLIChatProxyBaseURL, "/") {
return GrokCredential{}, fmt.Errorf("credential base_url is not supported")
}
}
if imported.RedirectURI != "" {
if _, err := validateGrokOAuthEndpoint(imported.RedirectURI, "redirect_uri", false); err != nil {
return GrokCredential{}, fmt.Errorf("credential redirect_uri is invalid")
}
}
if strings.TrimSpace(imported.AccessToken) == "" || strings.TrimSpace(imported.RefreshToken) == "" {
return GrokCredential{}, fmt.Errorf("credential tokens are required")
}
discovery, err := resolveGrokTokenAndUserInfo(ctx, imported.TokenEndpoint, options)
if err != nil {
return GrokCredential{}, err
}
candidate := GrokCredential{
Type: ProviderGrok, AccessToken: imported.AccessToken, RefreshToken: imported.RefreshToken,
IDToken: imported.IDToken, TokenType: imported.TokenType, ExpiresIn: imported.ExpiresIn,
Expire: imported.Expire, LastRefresh: imported.LastRefresh,
AccountID: firstNonEmptyString(imported.AccountID, imported.Subject), Email: imported.Email,
TokenEndpoint: discovery.TokenEndpoint,
}
normalizeGrokCredential(&candidate)
refreshed := false
if expiresAt, ok := GrokCredentialExpiresAt(candidate); !ok || !expiresAt.After(grokNow(options).Add(5*time.Minute)) {
candidate, err = refreshGrokTokensOnly(ctx, candidate, options)
if err != nil {
return GrokCredential{}, err
}
refreshed = true
}
profile, err := fetchGrokUserInfo(ctx, discovery.UserInfoEndpoint, candidate.AccessToken, options)
if err != nil && !refreshed {
var upstream *GrokUpstreamHTTPError
if errors.As(err, &upstream) && upstream.StatusCode == http.StatusUnauthorized {
candidate, err = refreshGrokTokensOnly(ctx, candidate, options)
if err == nil {
profile, err = fetchGrokUserInfo(ctx, discovery.UserInfoEndpoint, candidate.AccessToken, options)
}
}
}
if err != nil {
return GrokCredential{}, err
}
expectedID := firstNonEmptyString(imported.AccountID, imported.Subject)
if expectedID != "" && profile.Subject != expectedID {
return GrokCredential{}, ErrGrokCredentialIdentityChanged
}
if expectedID == "" && strings.TrimSpace(imported.Email) != "" && !strings.EqualFold(strings.TrimSpace(imported.Email), profile.Email) {
return GrokCredential{}, ErrGrokCredentialIdentityChanged
}
candidate.AccountID = profile.Subject
candidate.Email = profile.Email
if candidate.Expire == "" {
return GrokCredential{}, fmt.Errorf("credential expired is required")
}
normalizeGrokCredential(&candidate)
if err := validateGrokCredentialWithOptions(candidate, options); err != nil {
return GrokCredential{}, err
}
return candidate, nil
}
func refreshGrokCredentialOnce(ctx context.Context, current GrokCredential, options GrokOptions) (GrokCredential, error) {
normalizeGrokCredential(&current)
if err := validateGrokCredentialWithOptions(current, options); err != nil {
return GrokCredential{}, err
}
refreshed, err := refreshGrokTokensOnly(ctx, current, options)
if err != nil {
return GrokCredential{}, err
}
if subject := grokJWTSubject(refreshed.IDToken); subject != "" && subject != current.AccountID {
return GrokCredential{}, ErrGrokCredentialIdentityChanged
}
discovery, discoveryErr := resolveGrokTokenAndUserInfo(ctx, refreshed.TokenEndpoint, options)
if discoveryErr == nil {
if profile, profileErr := fetchGrokUserInfo(ctx, discovery.UserInfoEndpoint, refreshed.AccessToken, options); profileErr == nil {
if profile.Subject != current.AccountID {
return GrokCredential{}, ErrGrokCredentialIdentityChanged
}
refreshed.Email = profile.Email
}
}
refreshed.AccountID = current.AccountID
normalizeGrokCredential(&refreshed)
if err := validateGrokCredentialWithOptions(refreshed, options); err != nil {
return GrokCredential{}, err
}
return refreshed, nil
}
func refreshGrokTokensOnly(ctx context.Context, current GrokCredential, options GrokOptions) (GrokCredential, error) {
if strings.TrimSpace(current.RefreshToken) == "" {
return GrokCredential{}, fmt.Errorf("Grok refresh token is required")
}
discovery, err := resolveGrokTokenAndUserInfo(ctx, current.TokenEndpoint, options)
if err != nil {
return GrokCredential{}, err
}
form := url.Values{
"grant_type": {"refresh_token"}, "client_id": {xaiauth.ClientID}, "refresh_token": {strings.TrimSpace(current.RefreshToken)},
}
body, status, err := doGrokForm(ctx, options, discovery.TokenEndpoint, form)
if err != nil {
return GrokCredential{}, fmt.Errorf("refresh Grok credential: %w", err)
}
defer clear(body)
var token grokTokenResponse
if err := json.Unmarshal(body, &token); err != nil {
return GrokCredential{}, fmt.Errorf("decode Grok refresh response: %w", err)
}
if status != http.StatusOK {
return GrokCredential{}, &GrokTokenEndpointError{StatusCode: status, Code: boundedGrokOAuthCode(token.Error)}
}
if strings.TrimSpace(token.AccessToken) == "" {
return GrokCredential{}, fmt.Errorf("Grok refresh response has no access token")
}
now := grokNow(options)
refreshed := current
refreshed.Type = ProviderGrok
refreshed.AccessToken = strings.TrimSpace(token.AccessToken)
if strings.TrimSpace(token.RefreshToken) != "" {
refreshed.RefreshToken = strings.TrimSpace(token.RefreshToken)
}
if strings.TrimSpace(token.IDToken) != "" {
refreshed.IDToken = strings.TrimSpace(token.IDToken)
}
if strings.TrimSpace(token.TokenType) != "" {
refreshed.TokenType = strings.TrimSpace(token.TokenType)
}
if token.ExpiresIn > 0 {
refreshed.ExpiresIn = token.ExpiresIn
refreshed.Expire = now.Add(time.Duration(token.ExpiresIn) * time.Second).Format(time.RFC3339)
}
refreshed.LastRefresh = now.Format(time.RFC3339)
refreshed.TokenEndpoint = discovery.TokenEndpoint
return refreshed, nil
}
func grokCredentialFromToken(
ctx context.Context,
token grokTokenResponse,
expectedID string,
tokenEndpoint string,
userInfoEndpoint string,
options GrokOptions,
requireRefreshToken bool,
) (GrokCredential, error) {
if strings.TrimSpace(token.AccessToken) == "" || (requireRefreshToken && strings.TrimSpace(token.RefreshToken) == "") {
return GrokCredential{}, fmt.Errorf("Grok token response is incomplete")
}
profile, err := fetchGrokUserInfo(ctx, userInfoEndpoint, token.AccessToken, options)
if err != nil {
return GrokCredential{}, err
}
if expectedID != "" && profile.Subject != expectedID {
return GrokCredential{}, ErrGrokCredentialIdentityChanged
}
if subject := grokJWTSubject(token.IDToken); subject != "" && subject != profile.Subject {
return GrokCredential{}, ErrGrokCredentialIdentityChanged
}
now := grokNow(options)
credential := GrokCredential{
Type: ProviderGrok, AccessToken: token.AccessToken, RefreshToken: token.RefreshToken,
IDToken: token.IDToken, TokenType: token.TokenType, ExpiresIn: token.ExpiresIn,
LastRefresh: now.Format(time.RFC3339), AccountID: profile.Subject, Email: profile.Email,
TokenEndpoint: tokenEndpoint,
}
if token.ExpiresIn > 0 {
credential.Expire = now.Add(time.Duration(token.ExpiresIn) * time.Second).Format(time.RFC3339)
}
normalizeGrokCredential(&credential)
if err := validateGrokCredentialWithOptions(credential, options); err != nil {
return GrokCredential{}, err
}
return credential, nil
}
func discoverGrokOAuth(ctx context.Context, options GrokOptions) (grokDiscovery, error) {
discoveryURL := strings.TrimSpace(options.DiscoveryURL)
allowTestEndpoint := discoveryURL != ""
if discoveryURL == "" {
discoveryURL = xaiauth.DiscoveryURL
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
if err != nil {
return grokDiscovery{}, err
}
request.Header.Set("Accept", "application/json")
response, err := grokHTTPClient(options).Do(request)
if err != nil {
return grokDiscovery{}, err
}
defer func() { _ = response.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(response.Body, maxTokenResponse+1))
if err != nil {
return grokDiscovery{}, err
}
defer clear(body)
if len(body) > maxTokenResponse {
return grokDiscovery{}, fmt.Errorf("Grok discovery response is too large")
}
if response.StatusCode != http.StatusOK {
return grokDiscovery{}, &GrokUpstreamHTTPError{Operation: "discovery", StatusCode: response.StatusCode}
}
var discovery grokDiscovery
if err := json.Unmarshal(body, &discovery); err != nil {
return grokDiscovery{}, fmt.Errorf("decode Grok discovery: %w", err)
}
for field, raw := range map[string]string{
"device_authorization_endpoint": discovery.DeviceAuthorizationEndpoint,
"token_endpoint": discovery.TokenEndpoint,
"userinfo_endpoint": discovery.UserInfoEndpoint,
} {
validated, err := validateGrokOAuthEndpoint(raw, field, allowTestEndpoint)
if err != nil {
return grokDiscovery{}, err
}
switch field {
case "device_authorization_endpoint":
discovery.DeviceAuthorizationEndpoint = validated
case "token_endpoint":
discovery.TokenEndpoint = validated
case "userinfo_endpoint":
discovery.UserInfoEndpoint = validated
}
}
return discovery, nil
}
func resolveGrokTokenAndUserInfo(ctx context.Context, tokenEndpoint string, options GrokOptions) (grokDiscovery, error) {
userInfo := strings.TrimSpace(options.UserInfoURL)
allowTestEndpoint := strings.TrimSpace(options.DiscoveryURL) != "" || userInfo != ""
if strings.TrimSpace(tokenEndpoint) != "" && userInfo != "" {
validatedToken, err := validateGrokOAuthEndpoint(tokenEndpoint, "token_endpoint", allowTestEndpoint)
if err != nil {
return grokDiscovery{}, err
}
validatedUserInfo, err := validateGrokOAuthEndpoint(userInfo, "userinfo_endpoint", allowTestEndpoint)
if err != nil {
return grokDiscovery{}, err
}
return grokDiscovery{TokenEndpoint: validatedToken, UserInfoEndpoint: validatedUserInfo}, nil
}
discovery, err := discoverGrokOAuth(ctx, options)
if err != nil {
return grokDiscovery{}, err
}
if strings.TrimSpace(tokenEndpoint) != "" {
validated, err := validateGrokOAuthEndpoint(tokenEndpoint, "token_endpoint", allowTestEndpoint)
if err != nil {
return grokDiscovery{}, err
}
discovery.TokenEndpoint = validated
}
if userInfo != "" {
validated, err := validateGrokOAuthEndpoint(userInfo, "userinfo_endpoint", allowTestEndpoint)
if err != nil {
return grokDiscovery{}, err
}
discovery.UserInfoEndpoint = validated
}
return discovery, nil
}
func validateGrokOAuthEndpoint(raw, field string, allowTestEndpoint bool) (string, error) {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil || !parsed.IsAbs() || parsed.User != nil || parsed.Fragment != "" {
return "", fmt.Errorf("Grok %s is invalid", field)
}
if allowTestEndpoint {
return raw, nil
}
return xaiauth.ValidateOAuthEndpoint(raw, field)
}
func fetchGrokUserInfo(ctx context.Context, endpoint, accessToken string, options GrokOptions) (grokUserInfo, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(endpoint), nil)
if err != nil {
return grokUserInfo{}, err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(accessToken))
response, err := grokHTTPClient(options).Do(request)
if err != nil {
return grokUserInfo{}, err
}
defer func() { _ = response.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(response.Body, maxTokenResponse+1))
if err != nil {
return grokUserInfo{}, err
}
defer clear(body)
if len(body) > maxTokenResponse {
return grokUserInfo{}, fmt.Errorf("Grok userinfo response is too large")
}
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
return grokUserInfo{}, &GrokUpstreamHTTPError{Operation: "userinfo", StatusCode: response.StatusCode}
}
var profile grokUserInfo
if err := json.Unmarshal(body, &profile); err != nil {
return grokUserInfo{}, fmt.Errorf("decode Grok userinfo: %w", err)
}
profile.Subject = strings.TrimSpace(profile.Subject)
profile.Email = strings.TrimSpace(profile.Email)
if profile.Subject == "" || profile.Email == "" || !profile.EmailVerified ||
strings.ContainsAny(profile.Subject+profile.Email, "\r\n\x00") {
return grokUserInfo{}, fmt.Errorf("Grok userinfo identity is incomplete")
}
return profile, nil
}
func doGrokForm(ctx context.Context, options GrokOptions, endpoint string, form url.Values) ([]byte, int, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimSpace(endpoint), strings.NewReader(form.Encode()))
if err != nil {
return nil, 0, err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
response, err := grokHTTPClient(options).Do(request)
if err != nil {
return nil, 0, err
}
defer func() { _ = response.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(response.Body, maxTokenResponse+1))
if err != nil {
return nil, response.StatusCode, err
}
if len(body) > maxTokenResponse {
clear(body)
return nil, response.StatusCode, fmt.Errorf("Grok OAuth response is too large")
}
return body, response.StatusCode, nil
}
func grokJWTSubject(token string) string {
parts := strings.Split(strings.TrimSpace(token), ".")
if len(parts) < 2 {
return ""
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return ""
}
defer clear(payload)
var claims struct {
Subject string `json:"sub"`
}
if json.Unmarshal(payload, &claims) != nil {
return ""
}
return strings.TrimSpace(claims.Subject)
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}
func boundedGrokOAuthCode(value string) string {
value = strings.TrimSpace(value)
if value == "" || len(value) > 128 || strings.ContainsAny(value, "\r\n\x00") {
return ""
}
return value
}
+279
View File
@@ -0,0 +1,279 @@
package embedded
import (
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestBeginGrokDeviceAuthorizationUsesDiscoveryAndReturnsOneChallenge(t *testing.T) {
now := time.Date(2026, time.August, 18, 8, 0, 0, 0, time.UTC)
var deviceCalls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/discovery":
_ = json.NewEncoder(w).Encode(map[string]string{
"device_authorization_endpoint": serverURL(r) + "/device",
"token_endpoint": serverURL(r) + "/token",
"userinfo_endpoint": serverURL(r) + "/userinfo",
})
case "/device":
deviceCalls.Add(1)
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
if r.Form.Get("client_id") == "" || !strings.Contains(r.Form.Get("scope"), "offline_access") {
t.Fatalf("device form = %v", r.Form)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"device_code": "device-secret", "user_code": "ABCD-EFGH",
"verification_uri": "https://auth.x.ai/device",
"verification_uri_complete": "https://auth.x.ai/device?user_code=ABCD-EFGH",
"expires_in": 1200, "interval": 5,
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
challenge, err := BeginGrokDeviceAuthorization(t.Context(), GrokOptions{
DiscoveryURL: server.URL + "/discovery", HTTPClient: server.Client(), Now: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
if challenge.VerificationURL != "https://auth.x.ai/device?user_code=ABCD-EFGH" || challenge.UserCode != "ABCD-EFGH" ||
challenge.ExpiresAt != now.Add(20*time.Minute) || challenge.PollInterval != 5*time.Second ||
challenge.State.DeviceCode != "device-secret" || deviceCalls.Load() != 1 {
t.Fatalf("challenge = %#v, calls = %d", challenge, deviceCalls.Load())
}
}
func TestParseGrokCredentialRejectsUntrustedTokenEndpoint(t *testing.T) {
raw := []byte(`{
"type":"grok","access_token":"access","refresh_token":"refresh",
"account_id":"account-1","email":"owner@example.com",
"expired":"2028-01-01T00:00:00Z","token_endpoint":"http://127.0.0.1/token"
}`)
if _, err := ParseGrokCredentialJSON(raw); err == nil {
t.Fatal("untrusted token endpoint was accepted")
}
}
func TestPollGrokDeviceAuthorizationPerformsExactlyOneRequest(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"slow_down"}`))
}))
defer server.Close()
result, err := PollGrokDeviceAuthorizationOnce(t.Context(), GrokDeviceState{
DeviceCode: "device-secret", TokenEndpoint: server.URL, UserInfoEndpoint: server.URL + "/userinfo",
PollIntervalSeconds: 5,
}, GrokOptions{HTTPClient: server.Client()})
if err != nil {
t.Fatal(err)
}
if result.Status != GrokDevicePending || result.PollInterval != 10*time.Second ||
result.State.PollIntervalSeconds != 10 || calls.Load() != 1 {
t.Fatalf("poll = %#v, calls = %d", result, calls.Load())
}
}
func TestPollGrokDeviceAuthorizationMapsTerminalDeviceStates(t *testing.T) {
for _, test := range []struct {
name string
code string
status GrokDeviceStatus
}{
{name: "denied", code: "access_denied", status: GrokDeviceDenied},
{name: "expired", code: "expired_token", status: GrokDeviceExpired},
} {
t.Run(test.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(map[string]string{"error": test.code})
}))
defer server.Close()
result, err := PollGrokDeviceAuthorizationOnce(t.Context(), GrokDeviceState{
DeviceCode: "device-secret", TokenEndpoint: server.URL,
UserInfoEndpoint: server.URL + "/userinfo", PollIntervalSeconds: 5,
}, GrokOptions{HTTPClient: server.Client()})
if err != nil || result.Status != test.status {
t.Fatalf("poll = %#v, %v", result, err)
}
})
}
}
func TestPollGrokDeviceAuthorizationVerifiesUserInfoIdentity(t *testing.T) {
now := time.Date(2026, time.August, 18, 8, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "access-secret", "refresh_token": "refresh-secret",
"id_token": testUnsignedJWT(map[string]any{"sub": "account-1"}),
"token_type": "Bearer", "expires_in": 3600,
})
case "/userinfo":
if r.Header.Get("Authorization") != "Bearer access-secret" {
t.Fatalf("Authorization = %q", r.Header.Get("Authorization"))
}
_ = json.NewEncoder(w).Encode(map[string]any{
"sub": "account-1", "email": "owner@example.com", "email_verified": true,
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
result, err := PollGrokDeviceAuthorizationOnce(t.Context(), GrokDeviceState{
DeviceCode: "device-secret", TokenEndpoint: server.URL + "/token",
UserInfoEndpoint: server.URL + "/userinfo", PollIntervalSeconds: 5,
}, GrokOptions{HTTPClient: server.Client(), Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
if result.Status != GrokDeviceAuthorized || result.Credential.AccountID != "account-1" ||
result.Credential.Email != "owner@example.com" || result.Credential.Type != ProviderGrok ||
result.Credential.Expire != now.Add(time.Hour).Format(time.RFC3339) {
t.Fatalf("poll = %#v", result)
}
}
func TestImportGrokCanonicalUsesAccountIDAndUpdatesEmail(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"sub": "account-1", "email": "new@example.com", "email_verified": true,
})
}))
defer server.Close()
raw := []byte(`{
"type":"grok","access_token":"access","refresh_token":"refresh",
"account_id":"account-1","email":"old@example.com","expired":"2028-01-01T00:00:00Z",
"token_endpoint":"https://auth.x.ai/oauth/token"
}`)
credential, err := ImportGrokCredential(t.Context(), raw, GrokOptions{
UserInfoURL: server.URL, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if credential.AccountID != "account-1" || credential.Email != "new@example.com" {
t.Fatalf("credential = %#v", credential)
}
}
func TestImportGrokNativeCPAFileVerifiesSubjectAndStripsControlFields(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer access" {
t.Fatalf("Authorization = %q", r.Header.Get("Authorization"))
}
_ = json.NewEncoder(w).Encode(map[string]any{
"sub": "account-1", "email": "owner@example.com", "email_verified": true,
})
}))
defer server.Close()
raw := []byte(`{
"type":"xai","access_token":"access","refresh_token":"refresh",
"sub":"account-1","email":"owner@example.com","expired":"2028-01-01T00:00:00Z",
"token_endpoint":"https://auth.x.ai/oauth2/token","base_url":"https://api.x.ai/v1",
"auth_kind":"oauth","disabled":false,"websockets":true,"prefix":"legacy","note":"backup"
}`)
credential, err := ImportGrokCredential(t.Context(), raw, GrokOptions{
UserInfoURL: server.URL, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
canonical, err := MarshalGrokCredential(credential)
if err != nil {
t.Fatal(err)
}
for _, discarded := range []string{"base_url", "auth_kind", "disabled", "websockets", "prefix", "note", `"sub"`} {
if strings.Contains(string(canonical), discarded) {
t.Fatalf("canonical credential retained %q: %s", discarded, canonical)
}
}
}
func TestRefreshGrokCredentialOncePreservesRotatingTokensAndIdentity(t *testing.T) {
now := time.Date(2026, time.August, 18, 8, 0, 0, 0, time.UTC)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "new-access", "refresh_token": "new-refresh",
"token_type": "Bearer", "expires_in": 3600,
})
case "/userinfo":
_ = json.NewEncoder(w).Encode(map[string]any{
"sub": "account-1", "email": "new@example.com", "email_verified": true,
})
default:
http.NotFound(w, r)
}
}))
defer server.Close()
current := GrokCredential{
Type: ProviderGrok, AccessToken: "old-access", RefreshToken: "old-refresh", IDToken: "old-id",
AccountID: "account-1", Email: "old@example.com", Expire: "2026-08-18T07:00:00Z",
TokenEndpoint: server.URL + "/token",
}
refreshed, err := RefreshGrokCredentialOnce(t.Context(), current, GrokOptions{
UserInfoURL: server.URL + "/userinfo", HTTPClient: server.Client(), Now: func() time.Time { return now },
})
if err != nil {
t.Fatal(err)
}
if refreshed.AccessToken != "new-access" || refreshed.RefreshToken != "new-refresh" ||
refreshed.IDToken != "old-id" || refreshed.AccountID != "account-1" || refreshed.Email != "new@example.com" {
t.Fatalf("refreshed = %#v", refreshed)
}
}
func TestDiscoverGrokModelsUsesOAuthExecutionIdentity(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer access-secret" ||
r.Header.Get("X-XAI-Token-Auth") != "xai-grok-cli" ||
r.Header.Get("x-grok-client-identifier") != "grok-shell" {
t.Fatalf("model headers = %#v", r.Header)
}
_, _ = w.Write([]byte(`{"data":[{"id":"grok-4.3"},{"id":"grok-code-fast-1"},{"id":"grok-4.3"}]}`))
}))
defer server.Close()
models, err := DiscoverGrokModels(t.Context(), GrokCredential{AccessToken: "access-secret"}, GrokOptions{
ModelsURL: server.URL, HTTPClient: server.Client(),
})
if err != nil {
t.Fatal(err)
}
if len(models) != 2 || models[0] != "grok-4.3" || models[1] != "grok-code-fast-1" {
t.Fatalf("models = %v", models)
}
}
func serverURL(r *http.Request) string {
return "http://" + r.Host
}
func testUnsignedJWT(claims map[string]any) string {
header, _ := json.Marshal(map[string]string{"alg": "none"})
payload, _ := json.Marshal(claims)
return base64RawURL(header) + "." + base64RawURL(payload) + "."
}
func base64RawURL(value []byte) string {
return base64.RawURLEncoding.EncodeToString(value)
}
+116
View File
@@ -419,6 +419,122 @@ func selectLiveAntigravityModel(models []AntigravityModel) string {
return models[0].ID
}
// TestLiveGrokContract is opt-in because it consumes a real Grok subscription.
// It uses a prepared canonical credential and never refreshes or logs it.
func TestLiveGrokContract(t *testing.T) {
credentialFile := strings.TrimSpace(os.Getenv("CPA_LIVE_GROK_CREDENTIAL_FILE"))
model := strings.TrimSpace(os.Getenv("CPA_LIVE_GROK_MODEL"))
if credentialFile == "" {
t.Skip("CPA_LIVE_GROK_CREDENTIAL_FILE is not set")
}
raw, err := os.ReadFile(credentialFile)
if err != nil {
t.Fatalf("read live Grok credential file: %v", err)
}
credential, err := ParseGrokCredentialJSON(raw)
clear(raw)
if err != nil {
t.Fatalf("parse live Grok credential: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
models, err := DiscoverGrokModels(ctx, credential, GrokOptions{})
if err != nil {
t.Fatalf("discover live Grok models: %v", err)
}
if len(models) == 0 {
t.Fatal("live Grok model list is empty")
}
if model == "" {
model = models[0]
}
payloads := []struct {
name string
format string
body map[string]any
}{
{name: "OpenAI Responses", format: "openai-response", body: map[string]any{
"model": model, "input": "Reply exactly OK.", "store": false,
}},
{name: "OpenAI Chat Completions", format: "openai", body: map[string]any{
"model": model, "messages": []map[string]any{{"role": "user", "content": "Reply exactly OK."}},
}},
{name: "Anthropic Messages", format: "claude", body: map[string]any{
"model": model, "max_tokens": 16,
"messages": []map[string]any{{"role": "user", "content": "Reply exactly OK."}},
}},
{name: "Gemini GenerateContent", format: "gemini", body: map[string]any{
"contents": []map[string]any{{"role": "user", "parts": []map[string]any{{"text": "Reply exactly OK."}}}},
}},
}
executor := NewGrokHTTPExecutor()
for _, request := range payloads {
payload, marshalErr := json.Marshal(request.body)
if marshalErr != nil {
t.Fatal(marshalErr)
}
t.Run(request.name+" unary", func(t *testing.T) {
response, executeErr := executor.ExecuteCanonical(ctx, "live-grok-contract", credential, ExecuteRequest{
Model: model, Format: request.format, Payload: payload, OriginalRequest: payload,
})
if executeErr != nil {
t.Fatalf("execute live request: %v", executeErr)
}
if !json.Valid(response.Payload) {
t.Fatal("live response is not JSON")
}
})
t.Run(request.name+" stream", func(t *testing.T) {
streamBody := make(map[string]any, len(request.body)+1)
for key, value := range request.body {
streamBody[key] = value
}
streamBody["stream"] = true
streamPayload, marshalErr := json.Marshal(streamBody)
if marshalErr != nil {
t.Fatal(marshalErr)
}
stream, streamErr := executor.ExecuteStreamCanonical(ctx, "live-grok-contract", credential, ExecuteRequest{
Model: model, Format: request.format, Payload: streamPayload, OriginalRequest: streamPayload,
})
if streamErr != nil {
t.Fatalf("start live stream: %v", streamErr)
}
chunks := 0
for chunk := range stream.Chunks {
if chunk.Err != nil {
t.Fatalf("read live stream: %v", chunk.Err)
}
if len(chunk.Payload) > 0 {
chunks++
}
}
if chunks == 0 {
t.Fatal("live stream returned no chunks")
}
})
}
for _, request := range []struct {
format string
body map[string]any
}{
{format: "openai-response", body: payloads[0].body},
{format: "claude", body: payloads[2].body},
{format: "gemini", body: payloads[3].body},
} {
payload, marshalErr := json.Marshal(request.body)
if marshalErr != nil {
t.Fatal(marshalErr)
}
response, countErr := executor.CountTokensCanonical(ctx, ExecuteRequest{
Model: model, Format: request.format, Payload: payload, OriginalRequest: payload,
})
if countErr != nil || !json.Valid(response.Payload) {
t.Fatalf("count live Grok tokens: %v / %s", countErr, response.Payload)
}
}
}
func selectLiveClaudeModel(models []ClaudeModel) string {
for _, preferred := range []string{"claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5-20251001"} {
for _, model := range models {
+1 -1
View File
@@ -18,7 +18,7 @@ import {
export type ChannelFieldInputKind = 'text' | 'url' | 'secret'
export type ChannelConnectionType = 'api_key' | 'subscription'
export type ChannelAuthorizationMethod = 'browser_oauth' | 'oauth_file'
export type ChannelAuthorizationMethod = 'browser_oauth' | 'device_oauth' | 'oauth_file'
export type ChannelCredentialAction = 'reset_credit'
export type ChannelNoticeID = 'claude_oauth_risk' | 'antigravity_oauth_risk'
export type ChannelNoticeTone = 'warning'
@@ -30,8 +30,11 @@ export interface CredentialStageAccount {
export interface CredentialStage {
stage_id: string
status: CredentialStageStatus
authorization_method?: 'browser_oauth' | 'device_oauth' | 'oauth_file'
authorization_url?: string
redirect_uri?: string
user_code?: string
next_poll_at_ms?: number
account: CredentialStageAccount
expires_at_ms: number
error_code?: string
@@ -46,8 +49,11 @@ export interface CredentialConnectResult {
const stageFields = [
'stage_id',
'status',
'authorization_method',
'authorization_url',
'redirect_uri',
'user_code',
'next_poll_at_ms',
'account',
'expires_at_ms',
'error_code',
@@ -63,6 +69,7 @@ const stageStatuses = [
'expired',
'outcome_unknown',
] as const
const authorizationMethods = ['browser_oauth', 'device_oauth', 'oauth_file'] as const
function invalidResponse(): never {
throw new InvalidResponseError()
@@ -105,11 +112,27 @@ export function projectCredentialStage(value: unknown): CredentialStage {
record.authorization_url === undefined ? undefined : projectHTTPURL(record.authorization_url)
const redirectURI =
record.redirect_uri === undefined ? undefined : projectHTTPURL(record.redirect_uri)
const authorizationMethod =
record.authorization_method === undefined
? undefined
: projectEnum(record.authorization_method, authorizationMethods)
const userCode = record.user_code === undefined ? undefined : projectString(record.user_code)
if (
userCode !== undefined &&
(!/^[\x21-\x7e ]{1,128}$/u.test(userCode) || userCode.trim() !== userCode)
) {
invalidResponse()
}
return {
stage_id: projectStageID(record.stage_id),
status: projectEnum(record.status, stageStatuses),
...(authorizationMethod === undefined ? {} : { authorization_method: authorizationMethod }),
...(authorizationURL === undefined ? {} : { authorization_url: authorizationURL }),
...(redirectURI === undefined ? {} : { redirect_uri: redirectURI }),
...(userCode === undefined ? {} : { user_code: userCode }),
...(record.next_poll_at_ms === undefined
? {}
: { next_poll_at_ms: projectEpochMilliseconds(record.next_poll_at_ms) }),
account: projectAccount(record.account),
expires_at_ms: projectEpochMilliseconds(record.expires_at_ms),
...(record.error_code === undefined
@@ -158,6 +181,20 @@ export async function completeCredentialAuthorization(
)
}
export async function pollCredentialDeviceAuthorization(
client: ApiClient,
stageID: string,
signal?: AbortSignal,
): Promise<CredentialStage> {
const id = projectStageID(stageID)
return projectCredentialStage(
await client.request(`/api/credential-stages/${id}/device-poll`, {
method: 'POST',
signal,
}),
)
}
export async function importCredentialStage(
client: ApiClient,
channelID: string,
@@ -10,6 +10,7 @@ import {
completeCredentialAuthorization,
getCredentialStage,
importCredentialStage,
pollCredentialDeviceAuthorization,
type CredentialStage,
} from '@/app/resources/credential-stages'
import type { ChannelAuthorizationMethod, ChannelNoticeDto } from '@/app/resources/channels'
@@ -76,8 +77,12 @@ const readyCount = computed(
const hasAccounts = computed(() => props.modelValue.length > 0)
const entryBusy = computed(() => props.disabled || Boolean(busyAction.value))
const supportsBrowserOAuth = computed(() => props.authorizationMethods.includes('browser_oauth'))
const supportsDeviceOAuth = computed(() => props.authorizationMethods.includes('device_oauth'))
const supportsOAuthFile = computed(() => props.authorizationMethods.includes('oauth_file'))
const hasEntryMethod = computed(() => supportsBrowserOAuth.value || supportsOAuthFile.value)
const supportsInteractiveOAuth = computed(
() => supportsBrowserOAuth.value || supportsDeviceOAuth.value,
)
const hasEntryMethod = computed(() => supportsInteractiveOAuth.value || supportsOAuthFile.value)
const channelLabel = computed(() => props.channelName.trim() || props.channelId)
function replaceStage(stage: CredentialStage): void {
@@ -87,6 +92,9 @@ function replaceStage(stage: CredentialStage): void {
...stage,
authorization_url: stage.authorization_url ?? existing.authorization_url,
redirect_uri: stage.redirect_uri ?? existing.redirect_uri,
authorization_method: stage.authorization_method ?? existing.authorization_method,
user_code: stage.user_code ?? existing.user_code,
next_poll_at_ms: stage.next_poll_at_ms ?? existing.next_poll_at_ms,
}
: stage
const index = props.modelValue.findIndex((item) => item.stage_id === stage.stage_id)
@@ -174,8 +182,15 @@ function schedulePoll(stage: CredentialStage): void {
const poll = async () => {
const controller = new AbortController()
state.controller = controller
let polledStage: CredentialStage | undefined
try {
const next = await getCredentialStage(client, stage.stage_id, controller.signal)
const current = props.modelValue.find((item) => item.stage_id === stage.stage_id) ?? stage
const next =
current.authorization_method === 'device_oauth' &&
current.status === 'pending_authorization'
? await pollCredentialDeviceAuthorization(client, stage.stage_id, controller.signal)
: await getCredentialStage(client, stage.stage_id, controller.signal)
polledStage = next
state.failures = 0
if (feedbackKey.value === 'import.subscription.pollFailed') feedbackKey.value = ''
replaceStage(next)
@@ -199,10 +214,23 @@ function schedulePoll(stage: CredentialStage): void {
state.controller = undefined
}
if (polling.has(stage.stage_id)) {
state.timer = window.setTimeout(poll, 1_200 * Math.min(state.failures + 1, 4))
const latest =
polledStage ?? props.modelValue.find((item) => item.stage_id === stage.stage_id)
const providerDelay =
latest?.authorization_method === 'device_oauth' && latest.next_poll_at_ms
? Math.max(250, latest.next_poll_at_ms - Date.now())
: 1_200
state.timer = window.setTimeout(
poll,
state.failures ? 1_200 * Math.min(state.failures + 1, 4) : providerDelay,
)
}
}
state.timer = window.setTimeout(poll, 800)
const initialDelay =
stage.authorization_method === 'device_oauth' && stage.next_poll_at_ms
? Math.max(250, stage.next_poll_at_ms - Date.now())
: 800
state.timer = window.setTimeout(poll, initialDelay)
}
watch(
@@ -229,7 +257,7 @@ function openAuthorizationPopup(): Window | null {
}
async function beginAuthorization(existingPopup?: Window | null): Promise<void> {
if (!supportsBrowserOAuth.value || props.disabled || busyAction.value) {
if (!supportsInteractiveOAuth.value || props.disabled || busyAction.value) {
existingPopup?.close()
return
}
@@ -324,7 +352,7 @@ function callbackPlaceholder(stage: CredentialStage): string {
}
async function restartAuthorization(stage: CredentialStage): Promise<void> {
if (!supportsBrowserOAuth.value || props.disabled || busyAction.value) return
if (!supportsInteractiveOAuth.value || props.disabled || busyAction.value) return
const popup = openAuthorizationPopup()
await removeStage(stage)
await nextTick()
@@ -380,6 +408,7 @@ function stageErrorKey(code: string): string {
const known: Readonly<Record<string, string>> = {
authorization_denied: 'import.subscription.stageError.authorizationDenied',
authorization_failed: 'import.subscription.stageError.authorizationFailed',
authorization_expired: 'import.subscription.stageError.authorizationExpired',
authorization_exchange_rejected: 'import.subscription.stageError.exchangeRejected',
authorization_exchange_unknown: 'import.subscription.stageError.exchangeUnknown',
authorization_exchange_interrupted: 'import.subscription.stageError.exchangeInterrupted',
@@ -464,10 +493,18 @@ onBeforeUnmount(() => {
{{
stage.status === 'exchanging'
? t('import.subscription.exchanging')
: t('import.subscription.waiting')
: stage.authorization_method === 'device_oauth'
? t('import.subscription.deviceWaiting')
: t('import.subscription.waiting')
}}
</strong>
<span>{{ t('import.subscription.waitingHelp', { channel: channelLabel }) }}</span>
<span>
{{
stage.authorization_method === 'device_oauth'
? t('import.subscription.deviceWaitingHelp', { channel: channelLabel })
: t('import.subscription.waitingHelp', { channel: channelLabel })
}}
</span>
</div>
<span
v-if="stage.status === 'pending_authorization'"
@@ -519,7 +556,7 @@ onBeforeUnmount(() => {
<div v-if="isRecoverable(stage)" class="subscription-stager__recover" role="alert">
<span>{{ stageErrorMessage(stage) }}</span>
<AppButton
v-if="supportsBrowserOAuth"
v-if="supportsInteractiveOAuth"
size="compact"
:disabled="disabled || Boolean(busyAction)"
:busy="busyAction === 'authorize'"
@@ -543,7 +580,11 @@ onBeforeUnmount(() => {
{{ t('import.subscription.popupBlocked') }}
</InlineFeedback>
<p class="subscription-stager__manual-hint">
{{ t('import.subscription.manualHint', { redirectUri: callbackEndpoint(stage) }) }}
{{
stage.authorization_method === 'device_oauth'
? t('import.subscription.deviceInstructions')
: t('import.subscription.manualHint', { redirectUri: callbackEndpoint(stage) })
}}
</p>
<div class="subscription-stager__link-field">
@@ -566,7 +607,30 @@ onBeforeUnmount(() => {
</div>
</div>
<form class="subscription-stager__callback" @submit.prevent="submitCallback(stage)">
<div
v-if="stage.authorization_method === 'device_oauth' && stage.user_code"
class="subscription-stager__link-field"
>
<span class="subscription-stager__field-label">
{{ t('import.subscription.userCode') }}
</span>
<div class="subscription-stager__device-code">
<code>{{ stage.user_code }}</code>
<CopyChip
layout="icon"
:value="stage.user_code"
:label="t('import.subscription.copyUserCode')"
:success-label="t('common.copied')"
:failure-label="t('common.copyFailed')"
/>
</div>
</div>
<form
v-if="stage.authorization_method === 'browser_oauth'"
class="subscription-stager__callback"
@submit.prevent="submitCallback(stage)"
>
<FormField
:id="`oauth-callback-${stage.stage_id}`"
:label="t('import.subscription.callbackLabel')"
@@ -604,7 +668,7 @@ onBeforeUnmount(() => {
</form>
<AppButton
v-if="supportsBrowserOAuth"
v-if="supportsInteractiveOAuth"
class="subscription-stager__restart"
variant="link"
size="inline"
@@ -618,7 +682,7 @@ onBeforeUnmount(() => {
</div>
<div v-if="hasEntryMethod" class="subscription-stager__entry">
<div v-if="supportsBrowserOAuth" class="subscription-stager__entry-primary">
<div v-if="supportsInteractiveOAuth" class="subscription-stager__entry-primary">
<AppButton
class="subscription-stager__primary"
:variant="hasAccounts ? 'secondary' : 'primary'"
@@ -642,12 +706,12 @@ onBeforeUnmount(() => {
<DisclosurePanel
v-if="supportsOAuthFile"
:summary="
supportsBrowserOAuth
supportsInteractiveOAuth
? t('import.subscription.orImport')
: t('import.subscription.oauthJSONLabel')
"
:open="!supportsBrowserOAuth || jsonImportOpen"
@update:open="jsonImportOpen = supportsBrowserOAuth ? $event : true"
:open="!supportsInteractiveOAuth || jsonImportOpen"
@update:open="jsonImportOpen = supportsInteractiveOAuth ? $event : true"
>
<div class="subscription-stager__json">
<FormField
@@ -891,6 +955,25 @@ onBeforeUnmount(() => {
font-weight: 600;
text-decoration: none;
}
.subscription-stager__device-code {
display: grid;
grid-template-columns: minmax(0, max-content) auto;
align-items: center;
justify-content: start;
gap: var(--space-1);
}
.subscription-stager__device-code code {
border: 1px solid var(--color-border-control);
border-radius: var(--radius-control);
background: var(--color-surface-sunken);
color: var(--color-text);
padding: 9px 12px;
font-family: var(--font-mono);
font-size: var(--text-body);
font-weight: 700;
letter-spacing: 0.08em;
white-space: nowrap;
}
.subscription-stager__callback {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
+8
View File
@@ -129,6 +129,9 @@ export default {
waiting: 'Waiting for the browser to finish authorization',
waitingHelp:
'{channel} opened in a new window; this page continues automatically when it is done',
deviceWaiting: 'Waiting for device authorization',
deviceWaitingHelp:
'Enter the user code below on the {channel} authorization page; this page continues automatically',
exchanging: 'Finishing authorization…',
sessionRemaining: 'Authorization session time left',
restart: 'Start a new authorization',
@@ -138,9 +141,13 @@ export default {
},
manualHint:
'A local deployment finishes on its own. For a remote deployment where the browser cannot reach {redirectUri}, authorize with the link below and paste back the final browser URL.',
deviceInstructions:
'Open the authorization link and enter the user code. Keep this page open; it continues automatically when authorization finishes.',
authorizationLink: 'Authorization link',
copyAuthorization: 'Copy link',
openAuthorization: 'Open',
userCode: 'User code',
copyUserCode: 'Copy user code',
callbackLabel: 'URL your browser ended on',
callbackEndpointFallback: 'the local OAuth callback',
callbackPlaceholder: 'http://localhost:<port>/callback?code=...&state=...',
@@ -171,6 +178,7 @@ export default {
stageError: {
authorizationDenied: '{channel} authorization was cancelled; start again',
authorizationFailed: '{channel} could not finish authorization; start again',
authorizationExpired: 'The device authorization code expired; start again',
exchangeRejected: '{channel} rejected the authorization code; authorize again',
exchangeUnknown: 'The authorization exchange outcome is unknown; authorize again',
exchangeInterrupted: 'The authorization exchange was interrupted; authorize again',
+9
View File
@@ -126,6 +126,9 @@ export default {
'新しいウィンドウがブロックされました。下の認証リンクから手動で開いてください。',
waiting: 'ブラウザーでの認証完了を待っています',
waitingHelp: '新しいウィンドウで {channel} を開きました。完了すると自動で続行します',
deviceWaiting: 'デバイス認証を待っています',
deviceWaitingHelp:
'{channel} の認証ページで下のユーザーコードを入力してください。完了すると自動で続行します',
exchanging: '認証を完了しています…',
sessionRemaining: '認証セッションの残り時間',
restart: '認証をやり直す',
@@ -135,9 +138,13 @@ export default {
},
manualHint:
'ローカル配置では自動的に完了します。リモート配置でブラウザーから {redirectUri} に到達できない場合は、下のリンクで認証し、ブラウザーが最後に表示した完全な URL を貼り付けてください。',
deviceInstructions:
'認証リンクを開いてユーザーコードを入力してください。このページを開いたままにすると、認証完了後に自動で続行します。',
authorizationLink: '認証リンク',
copyAuthorization: 'リンクをコピー',
openAuthorization: '開く',
userCode: 'ユーザーコード',
copyUserCode: 'ユーザーコードをコピー',
callbackLabel: 'ブラウザーが最後に表示した URL',
callbackEndpointFallback: 'ローカル OAuth コールバック',
callbackPlaceholder: 'http://localhost:<port>/callback?code=...&state=...',
@@ -166,6 +173,8 @@ export default {
stageError: {
authorizationDenied: '{channel} 認証がキャンセルされました。最初からやり直してください',
authorizationFailed: '{channel} 認証を完了できませんでした。最初からやり直してください',
authorizationExpired:
'デバイス認証コードの有効期限が切れました。最初からやり直してください',
exchangeRejected: '{channel} が認証コードを拒否しました。再認証してください',
exchangeUnknown: '認証交換の結果を確認できません。再認証してください',
exchangeInterrupted: '認証交換が中断されました。再認証してください',
+6
View File
@@ -119,6 +119,8 @@ export default {
popupBlocked: '新窗口被浏览器拦截了,请用下面的授权链接手动打开',
waiting: '等待浏览器完成授权',
waitingHelp: '已在新窗口打开 {channel},完成后这里会自动继续',
deviceWaiting: '等待设备授权',
deviceWaitingHelp: '请在 {channel} 授权页输入下方用户码,完成后这里会自动继续',
exchanging: '正在完成授权…',
sessionRemaining: '授权会话剩余时间',
restart: '重新开始一次授权',
@@ -128,9 +130,12 @@ export default {
},
manualHint:
'本机部署会自动完成。远程部署若浏览器无法访问 {redirectUri},请打开下面的链接授权,再粘贴浏览器最终停留的完整网址。',
deviceInstructions: '打开授权链接并输入用户码;请保持此页面开启,授权完成后会自动继续。',
authorizationLink: '授权链接',
copyAuthorization: '复制链接',
openAuthorization: '打开',
userCode: '用户码',
copyUserCode: '复制用户码',
callbackLabel: '授权后浏览器停在的网址',
callbackEndpointFallback: '本地 OAuth 回调地址',
callbackPlaceholder: 'http://localhost:<端口>/callback?code=...&state=...',
@@ -158,6 +163,7 @@ export default {
stageError: {
authorizationDenied: '用户取消了 {channel} 授权,请重新开始',
authorizationFailed: '{channel} 未能完成授权,请重新开始',
authorizationExpired: '设备授权码已过期,请重新开始',
exchangeRejected: '{channel} 拒绝了授权码,请重新授权',
exchangeUnknown: '授权交换结果无法确认,请重新授权',
exchangeInterrupted: '授权交换被中断,请重新授权',