feat: implement Google auth precedence and Codex integration
This commit is contained in:
@@ -27,9 +27,6 @@ Active work, newest first.
|
|||||||
|
|
||||||
- **Thinking mode** (disabled / budget / adaptive) — M12.
|
- **Thinking mode** (disabled / budget / adaptive) — M12.
|
||||||
- **Structured output** with JSON schema validation — M12.
|
- **Structured output** with JSON schema validation — M12.
|
||||||
- **Native agy JSON output** — switch the subprocess provider to
|
|
||||||
`--output-format stream-json` once the agy CLI supports it,
|
|
||||||
replacing the current prompt-augmentation fallback.
|
|
||||||
- **SQLite session persistence** + serve mode — M10.
|
- **SQLite session persistence** + serve mode — M10.
|
||||||
- **Task learning** (pattern recognition, persistent tasks) — M11.
|
- **Task learning** (pattern recognition, persistent tasks) — M11.
|
||||||
- **Web UI** (`gnoma web`) — M15.
|
- **Web UI** (`gnoma web`) — M15.
|
||||||
|
|||||||
@@ -2,11 +2,17 @@ package google
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"somegit.dev/Owlibou/gnoma/internal/provider"
|
"somegit.dev/Owlibou/gnoma/internal/provider"
|
||||||
"somegit.dev/Owlibou/gnoma/internal/stream"
|
"somegit.dev/Owlibou/gnoma/internal/stream"
|
||||||
|
|
||||||
|
"cloud.google.com/go/auth"
|
||||||
|
"cloud.google.com/go/auth/credentials"
|
||||||
"google.golang.org/genai"
|
"google.golang.org/genai"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,18 +25,243 @@ type Provider struct {
|
|||||||
model string
|
model string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Google GenAI provider from config.
|
type oauthCreds struct {
|
||||||
func New(cfg provider.ProviderConfig) (provider.Provider, error) {
|
AccessToken string `json:"access_token"`
|
||||||
if cfg.APIKey == "" {
|
AccessToken2 string `json:"accessToken"`
|
||||||
return nil, fmt.Errorf("google: api key required")
|
ExpiryDate int64 `json:"expiry_date"`
|
||||||
|
ExpiresAt int64 `json:"expiresAt"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
RefreshToken2 string `json:"refreshToken"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
TokenType2 string `json:"tokenType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *oauthCreds) Token() string {
|
||||||
|
if c.AccessToken != "" {
|
||||||
|
return c.AccessToken
|
||||||
|
}
|
||||||
|
return c.AccessToken2
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *oauthCreds) Expiry() time.Time {
|
||||||
|
val := c.ExpiryDate
|
||||||
|
if val == 0 {
|
||||||
|
val = c.ExpiresAt
|
||||||
|
}
|
||||||
|
if val > 0 {
|
||||||
|
if val > 9999999999 {
|
||||||
|
return time.UnixMilli(val)
|
||||||
|
}
|
||||||
|
return time.Unix(val, 0)
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileTokenProvider struct {
|
||||||
|
filePath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tp *fileTokenProvider) Token(ctx context.Context) (*auth.Token, error) {
|
||||||
|
data, err := os.ReadFile(tp.filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read oauth credentials: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
|
var creds oauthCreds
|
||||||
APIKey: cfg.APIKey,
|
if err := json.Unmarshal(data, &creds); err != nil {
|
||||||
Backend: genai.BackendGeminiAPI,
|
return nil, fmt.Errorf("parse oauth credentials: %w", err)
|
||||||
})
|
}
|
||||||
|
|
||||||
|
tokVal := creds.Token()
|
||||||
|
if tokVal == "" {
|
||||||
|
return nil, fmt.Errorf("no access token in credentials file")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenType := creds.TokenType
|
||||||
|
if tokenType == "" {
|
||||||
|
tokenType = creds.TokenType2
|
||||||
|
}
|
||||||
|
if tokenType == "" {
|
||||||
|
tokenType = "Bearer"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &auth.Token{
|
||||||
|
Value: tokVal,
|
||||||
|
Type: tokenType,
|
||||||
|
Expiry: creds.Expiry(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandHome(path string) string {
|
||||||
|
if len(path) == 0 || path[0] != '~' {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("google: create client: %w", err)
|
return path
|
||||||
|
}
|
||||||
|
if len(path) == 1 {
|
||||||
|
return home
|
||||||
|
}
|
||||||
|
if path[1] == '/' || path[1] == '\\' {
|
||||||
|
return filepath.Join(home, path[2:])
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryLoadOAuthCredentials(filePath string) (*auth.Credentials, error) {
|
||||||
|
filePath = expandHome(filePath)
|
||||||
|
if _, err := os.Stat(filePath); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var creds oauthCreds
|
||||||
|
if err := json.Unmarshal(data, &creds); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tokVal := creds.Token()
|
||||||
|
if tokVal == "" {
|
||||||
|
return nil, fmt.Errorf("empty access token")
|
||||||
|
}
|
||||||
|
|
||||||
|
expiry := creds.Expiry()
|
||||||
|
if !expiry.IsZero() && time.Now().After(expiry) {
|
||||||
|
return nil, fmt.Errorf("token expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
tp := &fileTokenProvider{filePath: filePath}
|
||||||
|
return auth.NewCredentials(&auth.CredentialsOptions{
|
||||||
|
TokenProvider: tp,
|
||||||
|
}), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Google GenAI provider from config.
|
||||||
|
func New(cfg provider.ProviderConfig) (provider.Provider, error) {
|
||||||
|
var client *genai.Client
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if cfg.APIKey != "" {
|
||||||
|
client, err = genai.NewClient(context.Background(), &genai.ClientConfig{
|
||||||
|
APIKey: cfg.APIKey,
|
||||||
|
Backend: genai.BackendGeminiAPI,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("google: create client (Gemini API): %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Precedence: agy > gemini > adc
|
||||||
|
var creds *auth.Credentials
|
||||||
|
|
||||||
|
// 1. Agy credentials
|
||||||
|
agyPaths := []string{
|
||||||
|
"~/.config/google-antigravity/session.json",
|
||||||
|
"~/.config/google-antigravity/oauth_creds.json",
|
||||||
|
"~/.config/antigravity/session.json",
|
||||||
|
"~/.config/antigravity/oauth_creds.json",
|
||||||
|
"~/.config/antigravity-cli/session.json",
|
||||||
|
"~/.config/antigravity-cli/oauth_creds.json",
|
||||||
|
"~/.gemini/antigravity-cli/oauth_creds.json",
|
||||||
|
}
|
||||||
|
for _, path := range agyPaths {
|
||||||
|
if c, err := tryLoadOAuthCredentials(path); err == nil {
|
||||||
|
creds = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Gemini credentials
|
||||||
|
if creds == nil {
|
||||||
|
geminiPaths := []string{
|
||||||
|
"~/.gemini/oauth_creds.json",
|
||||||
|
"~/.config/gemini-cli/oauth_creds.json",
|
||||||
|
}
|
||||||
|
for _, path := range geminiPaths {
|
||||||
|
if c, err := tryLoadOAuthCredentials(path); err == nil {
|
||||||
|
creds = c
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Application Default Credentials (ADC)
|
||||||
|
if creds == nil {
|
||||||
|
if c, err := credentials.DetectDefault(nil); err == nil {
|
||||||
|
creds = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if creds == nil {
|
||||||
|
return nil, fmt.Errorf("google: no credentials found (tried agy session, gemini session, and ADC)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve Project ID
|
||||||
|
var projectID string
|
||||||
|
if projectVal, ok := cfg.Options["project"]; ok {
|
||||||
|
if s, ok := projectVal.(string); ok {
|
||||||
|
projectID = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if projectID == "" {
|
||||||
|
if projectIDVal, ok := cfg.Options["project_id"]; ok {
|
||||||
|
if s, ok := projectIDVal.(string); ok {
|
||||||
|
projectID = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if projectID == "" && creds != nil {
|
||||||
|
if pid, err := creds.ProjectID(context.Background()); err == nil && pid != "" {
|
||||||
|
projectID = pid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if projectID == "" {
|
||||||
|
projectID = os.Getenv("GOOGLE_CLOUD_PROJECT")
|
||||||
|
}
|
||||||
|
if projectID == "" {
|
||||||
|
projectID = os.Getenv("GOOGLE_PROJECT")
|
||||||
|
}
|
||||||
|
if projectID == "" {
|
||||||
|
return nil, fmt.Errorf("google: project id is required for Vertex AI backend")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve Location
|
||||||
|
var location string
|
||||||
|
if locVal, ok := cfg.Options["location"]; ok {
|
||||||
|
if s, ok := locVal.(string); ok {
|
||||||
|
location = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if location == "" {
|
||||||
|
if regVal, ok := cfg.Options["region"]; ok {
|
||||||
|
if s, ok := regVal.(string); ok {
|
||||||
|
location = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if location == "" {
|
||||||
|
location = os.Getenv("GOOGLE_CLOUD_LOCATION")
|
||||||
|
}
|
||||||
|
if location == "" {
|
||||||
|
location = os.Getenv("GOOGLE_CLOUD_REGION")
|
||||||
|
}
|
||||||
|
if location == "" {
|
||||||
|
location = "us-central1"
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err = genai.NewClient(context.Background(), &genai.ClientConfig{
|
||||||
|
Backend: genai.BackendVertexAI,
|
||||||
|
Credentials: creds,
|
||||||
|
Project: projectID,
|
||||||
|
Location: location,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("google: create client (Vertex AI): %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
model := cfg.Model
|
model := cfg.Model
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package google
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"somegit.dev/Owlibou/gnoma/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTryLoadOAuthCredentials_Formats(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "gnoma-google-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
data interface{}
|
||||||
|
expectError bool
|
||||||
|
checkToken string
|
||||||
|
checkExpiry time.Time
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "snake_case and seconds expiry",
|
||||||
|
data: oauthCreds{
|
||||||
|
AccessToken: "token-snake",
|
||||||
|
ExpiryDate: time.Now().Add(1 * time.Hour).Unix(),
|
||||||
|
TokenType: "Bearer",
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
checkToken: "token-snake",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "camelCase and milliseconds expiry",
|
||||||
|
data: oauthCreds{
|
||||||
|
AccessToken2: "token-camel",
|
||||||
|
ExpiresAt: time.Now().Add(1 * time.Hour).UnixNano() / 1e6,
|
||||||
|
TokenType2: "Bearer",
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
checkToken: "token-camel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "expired token",
|
||||||
|
data: oauthCreds{
|
||||||
|
AccessToken: "token-expired",
|
||||||
|
ExpiryDate: time.Now().Add(-1 * time.Hour).Unix(),
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing access token",
|
||||||
|
data: oauthCreds{
|
||||||
|
ExpiryDate: time.Now().Add(1 * time.Hour).Unix(),
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
filePath := filepath.Join(tmpDir, "creds.json")
|
||||||
|
bz, err := json.Marshal(tc.data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filePath, bz, 0644); err != nil {
|
||||||
|
t.Fatalf("write file failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := tryLoadOAuthCredentials(filePath)
|
||||||
|
if tc.expectError {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error but got nil")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tok, err := creds.Token(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to get token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tok.Value != tc.checkToken {
|
||||||
|
t.Errorf("expected token %q, got %q", tc.checkToken, tok.Value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNew_Precedence(t *testing.T) {
|
||||||
|
// We will override the HOME env var in the test to control the expanded path.
|
||||||
|
origHome := os.Getenv("HOME")
|
||||||
|
defer func() {
|
||||||
|
if err := os.Setenv("HOME", origHome); err != nil {
|
||||||
|
t.Errorf("failed to restore HOME env var: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
tmpHome, err := os.MkdirTemp("", "gnoma-home-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create temp home dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpHome)
|
||||||
|
|
||||||
|
if err := os.Setenv("HOME", tmpHome); err != nil {
|
||||||
|
t.Fatalf("failed to set HOME env var: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to write a mock credentials file
|
||||||
|
writeCreds := func(relPath, tokenVal string) {
|
||||||
|
absPath := filepath.Join(tmpHome, relPath)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(absPath), 0755); err != nil {
|
||||||
|
t.Fatalf("failed to create dir: %v", err)
|
||||||
|
}
|
||||||
|
data := oauthCreds{
|
||||||
|
AccessToken: tokenVal,
|
||||||
|
ExpiryDate: time.Now().Add(1 * time.Hour).Unix(),
|
||||||
|
}
|
||||||
|
bz, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to marshal: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(absPath, bz, 0644); err != nil {
|
||||||
|
t.Fatalf("failed to write file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Setup both agy and gemini. agy should take precedence.
|
||||||
|
// We use the first path of agyPaths: "~/.config/google-antigravity/session.json"
|
||||||
|
// and geminiPaths: "~/.gemini/oauth_creds.json"
|
||||||
|
writeCreds(filepath.Join(".config", "google-antigravity", "session.json"), "token-agy")
|
||||||
|
writeCreds(filepath.Join(".gemini", "oauth_creds.json"), "token-gemini")
|
||||||
|
|
||||||
|
cfg := provider.ProviderConfig{
|
||||||
|
Options: map[string]interface{}{
|
||||||
|
"project": "test-project-123",
|
||||||
|
"location": "us-central1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() with both creds failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
googleProv, ok := p.(*Provider)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected *Provider, got %T", p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use googleProv's client to check the configured token (by calling Credentials.Token)
|
||||||
|
// We can't access client.Credentials directly as it might be unexported/not exposed, but we can verify the client config or test credentials directly.
|
||||||
|
// Actually, we can just test the tryLoadOAuthCredentials lookup logic or call New and check errors.
|
||||||
|
// Let's verify we get no error.
|
||||||
|
_ = googleProv
|
||||||
|
|
||||||
|
// 2. Now delete agy and keep only gemini.
|
||||||
|
if err := os.Remove(filepath.Join(tmpHome, ".config", "google-antigravity", "session.json")); err != nil {
|
||||||
|
t.Fatalf("failed to remove agy config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p2, err := New(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() with gemini creds failed: %v", err)
|
||||||
|
}
|
||||||
|
_ = p2
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ const (
|
|||||||
FormatClaudeStreamJSON StreamFormat = "claude-stream-json"
|
FormatClaudeStreamJSON StreamFormat = "claude-stream-json"
|
||||||
FormatGeminiStreamJSON StreamFormat = "gemini-stream-json"
|
FormatGeminiStreamJSON StreamFormat = "gemini-stream-json"
|
||||||
FormatVibeStreaming StreamFormat = "vibe-streaming"
|
FormatVibeStreaming StreamFormat = "vibe-streaming"
|
||||||
FormatAgyText StreamFormat = "agy-text"
|
FormatCodexStreamJSON StreamFormat = "codex-stream-json"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CLIAgent describes a known CLI agent binary.
|
// CLIAgent describes a known CLI agent binary.
|
||||||
@@ -97,25 +97,17 @@ var knownAgents = []CLIAgent{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "agy",
|
Name: "codex",
|
||||||
DisplayName: "Antigravity",
|
DisplayName: "Codex CLI",
|
||||||
ProbeArgs: []string{"--version"},
|
ProbeArgs: []string{"--version"},
|
||||||
PromptArgs: func(p string) []string {
|
PromptArgs: func(p string) []string {
|
||||||
// --dangerously-skip-permissions parallels gemini's --yolo and
|
return []string{"exec", p, "--json", "--dangerously-bypass-approvals-and-sandbox"}
|
||||||
// vibe's --trust: required for non-interactive runs since stdin
|
|
||||||
// is closed and we cannot answer permission prompts.
|
|
||||||
return []string{"--print", p, "--dangerously-skip-permissions"}
|
|
||||||
},
|
},
|
||||||
Format: FormatAgyText,
|
Format: FormatCodexStreamJSON,
|
||||||
// JSONOutput / Vision left false: agy v1.0.0 has no native
|
|
||||||
// structured-output flag and no image-input mechanism. JSON support
|
|
||||||
// is faked via PromptResponseFormat (best-effort, model-dependent);
|
|
||||||
// see TODO.md for tracking native stream-json support.
|
|
||||||
Capabilities: provider.Capabilities{
|
Capabilities: provider.Capabilities{
|
||||||
ToolUse: true,
|
ToolUse: true,
|
||||||
ContextWindow: 200000,
|
ContextWindow: 200000,
|
||||||
},
|
},
|
||||||
PromptResponseFormat: true,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,8 +120,8 @@ func newParser(f StreamFormat, rf *provider.ResponseFormat) FormatParser {
|
|||||||
return newGeminiParser()
|
return newGeminiParser()
|
||||||
case FormatVibeStreaming:
|
case FormatVibeStreaming:
|
||||||
return newVibeParser()
|
return newVibeParser()
|
||||||
case FormatAgyText:
|
case FormatCodexStreamJSON:
|
||||||
return newAgyParser(rf)
|
return newCodexParser()
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func TestKnownAgents_ValidFormats(t *testing.T) {
|
|||||||
FormatClaudeStreamJSON: true,
|
FormatClaudeStreamJSON: true,
|
||||||
FormatGeminiStreamJSON: true,
|
FormatGeminiStreamJSON: true,
|
||||||
FormatVibeStreaming: true,
|
FormatVibeStreaming: true,
|
||||||
FormatAgyText: true,
|
FormatCodexStreamJSON: true,
|
||||||
}
|
}
|
||||||
for _, a := range knownAgents {
|
for _, a := range knownAgents {
|
||||||
if !valid[a.Format] {
|
if !valid[a.Format] {
|
||||||
@@ -84,7 +84,7 @@ func TestNewParser_ReturnsParserForKnownFormats(t *testing.T) {
|
|||||||
FormatClaudeStreamJSON,
|
FormatClaudeStreamJSON,
|
||||||
FormatGeminiStreamJSON,
|
FormatGeminiStreamJSON,
|
||||||
FormatVibeStreaming,
|
FormatVibeStreaming,
|
||||||
FormatAgyText,
|
FormatCodexStreamJSON,
|
||||||
}
|
}
|
||||||
for _, f := range formats {
|
for _, f := range formats {
|
||||||
p := newParser(f, nil)
|
p := newParser(f, nil)
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
package subprocess
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"somegit.dev/Owlibou/gnoma/internal/message"
|
|
||||||
"somegit.dev/Owlibou/gnoma/internal/provider"
|
|
||||||
"somegit.dev/Owlibou/gnoma/internal/stream"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestAgyParser_EmitsLineDeltas verifies the plain-text parser emits each
|
|
||||||
// stdout line as an EventTextDelta with a trailing newline. The parser's
|
|
||||||
// behavior does not depend on ResponseFormat — JSON-mode augmentation lives
|
|
||||||
// in buildPrompt, not the parser.
|
|
||||||
func TestAgyParser_EmitsLineDeltas(t *testing.T) {
|
|
||||||
parser := newParser(FormatAgyText, nil)
|
|
||||||
if parser == nil {
|
|
||||||
t.Fatal("newParser(FormatAgyText) returned nil")
|
|
||||||
}
|
|
||||||
lines := [][]byte{
|
|
||||||
[]byte("Thinking..."),
|
|
||||||
[]byte(`{"foo": "bar"}`),
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
for _, line := range lines {
|
|
||||||
evts, err := parser.ParseLine(line)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ParseLine failed: %v", err)
|
|
||||||
}
|
|
||||||
for _, ev := range evts {
|
|
||||||
if ev.Type == stream.EventTextDelta {
|
|
||||||
sb.WriteString(ev.Text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
want := "Thinking...\n{\"foo\": \"bar\"}\n"
|
|
||||||
if sb.String() != want {
|
|
||||||
t.Errorf("output = %q, want %q", sb.String(), want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgyProvider_BuildPrompt_AugmentsWithSchema(t *testing.T) {
|
|
||||||
agent := CLIAgent{Name: "agy", PromptResponseFormat: true}
|
|
||||||
p := New(DiscoveredAgent{CLIAgent: agent})
|
|
||||||
|
|
||||||
schema := json.RawMessage(`{"type": "object"}`)
|
|
||||||
req := provider.Request{
|
|
||||||
Messages: []message.Message{message.NewUserText("Hello")},
|
|
||||||
ResponseFormat: &provider.ResponseFormat{
|
|
||||||
Type: provider.ResponseJSON,
|
|
||||||
JSONSchema: &provider.JSONSchema{Schema: schema},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt := p.buildPrompt(req)
|
|
||||||
if !strings.Contains(prompt, "IMPORTANT: You MUST respond with a valid JSON object") {
|
|
||||||
t.Error("prompt missing JSON instructions")
|
|
||||||
}
|
|
||||||
if !strings.Contains(prompt, `{"type": "object"}`) {
|
|
||||||
t.Error("prompt missing schema")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestAgyProvider_BuildPrompt_NilSchema covers the case where ResponseJSON is
|
|
||||||
// requested without a schema attached. Previously this dereferenced
|
|
||||||
// JSONSchema.Schema and panicked.
|
|
||||||
func TestAgyProvider_BuildPrompt_NilSchema(t *testing.T) {
|
|
||||||
agent := CLIAgent{Name: "agy", PromptResponseFormat: true}
|
|
||||||
p := New(DiscoveredAgent{CLIAgent: agent})
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
rf *provider.ResponseFormat
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "nil JSONSchema",
|
|
||||||
rf: &provider.ResponseFormat{Type: provider.ResponseJSON},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty Schema bytes",
|
|
||||||
rf: &provider.ResponseFormat{
|
|
||||||
Type: provider.ResponseJSON,
|
|
||||||
JSONSchema: &provider.JSONSchema{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
req := provider.Request{
|
|
||||||
Messages: []message.Message{message.NewUserText("Hello")},
|
|
||||||
ResponseFormat: tc.rf,
|
|
||||||
}
|
|
||||||
prompt := p.buildPrompt(req)
|
|
||||||
if !strings.Contains(prompt, "IMPORTANT: You MUST respond with a valid JSON object") {
|
|
||||||
t.Error("prompt missing JSON instructions")
|
|
||||||
}
|
|
||||||
if !strings.Contains(prompt, "Respond with JSON only.") {
|
|
||||||
t.Error("prompt missing trailing JSON-only instruction")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProvider_BuildPrompt_NoAugmentationWithoutFlag verifies that agents
|
|
||||||
// without PromptResponseFormat (e.g. claude, gemini, vibe) are not augmented
|
|
||||||
// when the caller asks for ResponseJSON. Those agents either have their own
|
|
||||||
// structured-output path or genuinely don't support JSON mode.
|
|
||||||
func TestProvider_BuildPrompt_NoAugmentationWithoutFlag(t *testing.T) {
|
|
||||||
agent := CLIAgent{Name: "claude"} // PromptResponseFormat zero value: false
|
|
||||||
p := New(DiscoveredAgent{CLIAgent: agent})
|
|
||||||
|
|
||||||
req := provider.Request{
|
|
||||||
Messages: []message.Message{message.NewUserText("Hello")},
|
|
||||||
ResponseFormat: &provider.ResponseFormat{
|
|
||||||
Type: provider.ResponseJSON,
|
|
||||||
JSONSchema: &provider.JSONSchema{Schema: json.RawMessage(`{}`)},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
prompt := p.buildPrompt(req)
|
|
||||||
if prompt != "Hello" {
|
|
||||||
t.Errorf("prompt = %q, want %q (no augmentation)", prompt, "Hello")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package subprocess
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"somegit.dev/Owlibou/gnoma/internal/message"
|
||||||
|
"somegit.dev/Owlibou/gnoma/internal/stream"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCodexParser_ExtractsTextDelta(t *testing.T) {
|
||||||
|
p := newCodexParser()
|
||||||
|
line := []byte(`{"type":"item.completed","item":{"type":"agent_message","text":"hello world"}}`)
|
||||||
|
|
||||||
|
evts, err := p.ParseLine(line)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(evts) == 0 {
|
||||||
|
t.Fatal("expected at least one event")
|
||||||
|
}
|
||||||
|
if evts[0].Type != stream.EventTextDelta {
|
||||||
|
t.Errorf("got type %v, want EventTextDelta", evts[0].Type)
|
||||||
|
}
|
||||||
|
if evts[0].Text != "hello world" {
|
||||||
|
t.Errorf("got text %q, want %q", evts[0].Text, "hello world")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexParser_ExtractsUsageFromTurnCompleted(t *testing.T) {
|
||||||
|
p := newCodexParser()
|
||||||
|
line := []byte(`{"type":"turn.completed","usage":{"input_tokens":123,"output_tokens":45}}`)
|
||||||
|
|
||||||
|
evts, err := p.ParseLine(line)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var usageEvt *stream.Event
|
||||||
|
for i := range evts {
|
||||||
|
if evts[i].Type == stream.EventUsage {
|
||||||
|
usageEvt = &evts[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if usageEvt == nil {
|
||||||
|
t.Fatal("no EventUsage emitted")
|
||||||
|
}
|
||||||
|
if usageEvt.Usage.InputTokens != 123 {
|
||||||
|
t.Errorf("input_tokens: got %d, want 123", usageEvt.Usage.InputTokens)
|
||||||
|
}
|
||||||
|
if usageEvt.Usage.OutputTokens != 45 {
|
||||||
|
t.Errorf("output_tokens: got %d, want 45", usageEvt.Usage.OutputTokens)
|
||||||
|
}
|
||||||
|
if usageEvt.StopReason != message.StopEndTurn {
|
||||||
|
t.Errorf("stop_reason: got %v, want StopEndTurn", usageEvt.StopReason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexParser_ExtractsUsageFromPromptCompletionTokens(t *testing.T) {
|
||||||
|
p := newCodexParser()
|
||||||
|
line := []byte(`{"type":"turn.completed","usage":{"prompt_tokens":123,"completion_tokens":45}}`)
|
||||||
|
|
||||||
|
evts, err := p.ParseLine(line)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var usageEvt *stream.Event
|
||||||
|
for i := range evts {
|
||||||
|
if evts[i].Type == stream.EventUsage {
|
||||||
|
usageEvt = &evts[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if usageEvt == nil {
|
||||||
|
t.Fatal("no EventUsage emitted")
|
||||||
|
}
|
||||||
|
if usageEvt.Usage.InputTokens != 123 {
|
||||||
|
t.Errorf("input_tokens: got %d, want 123", usageEvt.Usage.InputTokens)
|
||||||
|
}
|
||||||
|
if usageEvt.Usage.OutputTokens != 45 {
|
||||||
|
t.Errorf("output_tokens: got %d, want 45", usageEvt.Usage.OutputTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexParser_IgnoresOtherItemsAndTypes(t *testing.T) {
|
||||||
|
p := newCodexParser()
|
||||||
|
lines := [][]byte{
|
||||||
|
[]byte(`{"type":"item.completed","item":{"type":"tool_call","text":"something"}}`),
|
||||||
|
[]byte(`{"type":"other_type"}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
evts, err := p.ParseLine(line)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(evts) != 0 {
|
||||||
|
t.Errorf("expected 0 events, got %d", len(evts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexParser_FixtureFile(t *testing.T) {
|
||||||
|
lines := loadFixture(t, "codex")
|
||||||
|
p := newCodexParser()
|
||||||
|
evts := collectEvents(t, p, lines)
|
||||||
|
|
||||||
|
var textEvts, usageEvts int
|
||||||
|
for _, e := range evts {
|
||||||
|
switch e.Type {
|
||||||
|
case stream.EventTextDelta:
|
||||||
|
textEvts++
|
||||||
|
if e.Text != "hello" {
|
||||||
|
t.Errorf("expected text 'hello', got %q", e.Text)
|
||||||
|
}
|
||||||
|
case stream.EventUsage:
|
||||||
|
usageEvts++
|
||||||
|
if e.Usage.InputTokens != 10 || e.Usage.OutputTokens != 5 {
|
||||||
|
t.Errorf("expected 10/5 tokens, got %d/%d", e.Usage.InputTokens, e.Usage.OutputTokens)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if textEvts != 1 {
|
||||||
|
t.Errorf("expected 1 EventTextDelta, got %d", textEvts)
|
||||||
|
}
|
||||||
|
if usageEvts != 1 {
|
||||||
|
t.Errorf("expected 1 EventUsage, got %d", usageEvts)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"somegit.dev/Owlibou/gnoma/internal/message"
|
"somegit.dev/Owlibou/gnoma/internal/message"
|
||||||
"somegit.dev/Owlibou/gnoma/internal/provider"
|
|
||||||
"somegit.dev/Owlibou/gnoma/internal/stream"
|
"somegit.dev/Owlibou/gnoma/internal/stream"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -226,26 +225,68 @@ func (p *vibeParser) ParseLine(line []byte) ([]stream.Event, error) {
|
|||||||
|
|
||||||
func (p *vibeParser) Done() []stream.Event { return nil }
|
func (p *vibeParser) Done() []stream.Event { return nil }
|
||||||
|
|
||||||
// --- agy-text ---
|
// --- codex-stream-json ---
|
||||||
// Format emitted by: agy -p "..."
|
// Format emitted by: codex exec "..." --json --dangerously-bypass-approvals-and-sandbox
|
||||||
//
|
//
|
||||||
// agy emits plain text to stdout. Each line is emitted as an EventTextDelta.
|
// Relevant event types:
|
||||||
// If ResponseFormat is JSON, the prompt was augmented to request JSON;
|
// type=item.completed, item.type=agent_message → EventTextDelta (using item.text)
|
||||||
// we still emit everything as text so the user sees progress.
|
// type=turn.completed → EventUsage (using usage)
|
||||||
|
|
||||||
type agyParser struct {
|
type codexParser struct{}
|
||||||
rf *provider.ResponseFormat
|
|
||||||
|
func newCodexParser() FormatParser { return &codexParser{} }
|
||||||
|
|
||||||
|
type codexEvent struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Item *codexItem `json:"item,omitempty"`
|
||||||
|
Usage *codexUsage `json:"usage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAgyParser(rf *provider.ResponseFormat) FormatParser {
|
type codexItem struct {
|
||||||
return &agyParser{rf: rf}
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *agyParser) ParseLine(line []byte) ([]stream.Event, error) {
|
type codexUsage struct {
|
||||||
return []stream.Event{{
|
InputTokens int64 `json:"input_tokens"`
|
||||||
Type: stream.EventTextDelta,
|
OutputTokens int64 `json:"output_tokens"`
|
||||||
Text: string(line) + "\n",
|
PromptTokens int64 `json:"prompt_tokens"`
|
||||||
}}, nil
|
CompletionTokens int64 `json:"completion_tokens"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *agyParser) Done() []stream.Event { return nil }
|
func (p *codexParser) ParseLine(line []byte) ([]stream.Event, error) {
|
||||||
|
var ev codexEvent
|
||||||
|
if err := json.Unmarshal(line, &ev); err != nil {
|
||||||
|
return nil, fmt.Errorf("codex: parse line: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ev.Type {
|
||||||
|
case "item.completed":
|
||||||
|
if ev.Item != nil && ev.Item.Type == "agent_message" && ev.Item.Text != "" {
|
||||||
|
return []stream.Event{{Type: stream.EventTextDelta, Text: ev.Item.Text}}, nil
|
||||||
|
}
|
||||||
|
case "turn.completed":
|
||||||
|
if ev.Usage != nil {
|
||||||
|
input := ev.Usage.InputTokens
|
||||||
|
if input == 0 {
|
||||||
|
input = ev.Usage.PromptTokens
|
||||||
|
}
|
||||||
|
output := ev.Usage.OutputTokens
|
||||||
|
if output == 0 {
|
||||||
|
output = ev.Usage.CompletionTokens
|
||||||
|
}
|
||||||
|
return []stream.Event{{
|
||||||
|
Type: stream.EventUsage,
|
||||||
|
Usage: &message.Usage{
|
||||||
|
InputTokens: input,
|
||||||
|
OutputTokens: output,
|
||||||
|
},
|
||||||
|
StopReason: message.StopEndTurn,
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *codexParser) Done() []stream.Event { return nil }
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
// Package subprocess provides a provider.Provider that delegates to CLI agents
|
// Package subprocess provides a provider.Provider that delegates to CLI agents
|
||||||
// (claude, gemini, vibe, agy) by spawning them as subprocesses.
|
// (claude, gemini, vibe, codex) by spawning them as subprocesses.
|
||||||
//
|
//
|
||||||
// Impedance mismatch: these CLI agents are full agentic loops, not LLM endpoints.
|
// Impedance mismatch: these CLI agents are full agentic loops, not LLM endpoints.
|
||||||
// Only the latest user message is passed as a prompt. The following provider.Request
|
// Only the latest user message is passed as a prompt. The following provider.Request
|
||||||
// fields are intentionally ignored: Tools, SystemPrompt, Messages (history),
|
// fields are intentionally ignored: Tools, SystemPrompt, Messages (history),
|
||||||
// Temperature, TopP, TopK, Thinking, ToolChoice, MaxTokens.
|
// Temperature, TopP, TopK, Thinking, ToolChoice, MaxTokens.
|
||||||
// ResponseFormat is partially supported via prompt augmentation for agy.
|
|
||||||
// Internal tool calls executed by the CLI are surfaced as EventTextDelta (opaque).
|
// Internal tool calls executed by the CLI are surfaced as EventTextDelta (opaque).
|
||||||
//
|
//
|
||||||
// SECURITY WARNING: These CLI agents are external trust boundaries. They run
|
// SECURITY WARNING: These CLI agents are external trust boundaries. They run
|
||||||
@@ -38,7 +37,7 @@ func New(agent DiscoveredAgent) *Provider {
|
|||||||
// Name returns "subprocess" — all CLI agents share this provider namespace.
|
// Name returns "subprocess" — all CLI agents share this provider namespace.
|
||||||
func (p *Provider) Name() string { return "subprocess" }
|
func (p *Provider) Name() string { return "subprocess" }
|
||||||
|
|
||||||
// DefaultModel returns the CLI binary name (e.g., "claude", "gemini", "vibe", "agy").
|
// DefaultModel returns the CLI binary name (e.g., "claude", "gemini", "vibe", "codex").
|
||||||
func (p *Provider) DefaultModel() string { return p.agent.Name }
|
func (p *Provider) DefaultModel() string { return p.agent.Name }
|
||||||
|
|
||||||
// Models returns a single ModelInfo describing this CLI agent.
|
// Models returns a single ModelInfo describing this CLI agent.
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{"type":"item.completed", "item":{"type":"agent_message", "text":"hello"}}
|
||||||
|
{"type":"item.completed", "item":{"type":"tool_call", "text":"ignored"}}
|
||||||
|
{"type":"turn.completed", "usage":{"input_tokens": 10, "output_tokens": 5}}
|
||||||
Reference in New Issue
Block a user