diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0d63f842df..e433a4cd48 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -80,6 +80,7 @@ jobs: # Main repo directories for completeness in case other files are # touched: - "agent/**" + - "aibridge/**" - "cli/**" - "cmd/**" - "coderd/**" diff --git a/.github/workflows/typos.toml b/.github/workflows/typos.toml index 0f7f9a6cc9..fd962da6dc 100644 --- a/.github/workflows/typos.toml +++ b/.github/workflows/typos.toml @@ -57,4 +57,6 @@ extend-exclude = [ # notifications' golden files confuse the detector because of quoted-printable encoding "coderd/notifications/testdata/**", "agent/agentcontainers/testdata/devcontainercli/**", + # aibridge fixtures contain truncated streaming chunks that look like typos + "aibridge/fixtures/**", ] diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 47a1cea3e5..a11ea20f94 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -1,7 +1,7 @@ rules: cache-poisoning: ignore: - - "ci.yaml:187" + - "ci.yaml:188" dangerous-triggers: ignore: # Both workflows use pull_request_target intentionally: they need diff --git a/.golangci.yaml b/.golangci.yaml index f03007f81e..07c12dac4f 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -6,6 +6,21 @@ linters-settings: # goal: 100 threshold: 412 + depguard: + rules: + aibridge_import_isolation: + list-mode: lax + files: + - "aibridge/*.go" + - "aibridge/**/*.go" + allow: + - $gostd + - github.com/coder/coder/v2/aibridge + - github.com/coder/coder/v2/buildinfo + deny: + - pkg: github.com/coder/coder/v2 + desc: aibridge code must not import coder packages outside aibridge; buildinfo is the only exception + exhaustruct: include: # Gradually extend to cover more of the codebase. @@ -227,6 +242,7 @@ linters: - asciicheck - bidichk - bodyclose + - depguard - dogsled - errcheck - errname diff --git a/aibridge/AGENTS.md b/aibridge/AGENTS.md new file mode 100644 index 0000000000..0ee141a295 --- /dev/null +++ b/aibridge/AGENTS.md @@ -0,0 +1,118 @@ +# AI Agent Guidelines for aibridge + +> For local overrides, create `AGENTS.local.md` (gitignored). + +You are an experienced, pragmatic software engineer. Simple solutions +over clever ones. Readability is a primary concern. + +## Tone & Relationship + +We're colleagues. Push back on bad ideas and speak up when something +doesn't make sense. Honesty over agreeableness. + +- Disagree when I'm wrong. Act as a critical peer reviewer. +- Call out bad ideas, unreasonable expectations, and mistakes. +- **Ask for clarification** instead of assuming. Say when you don't know something. +- Architectural decisions require discussion; routine fixes do not. + +## Foundational Rules + +- Doing it right is better than doing it fast. +- YAGNI. Don't add features we don't need right now. +- Make the smallest reasonable changes to achieve the goal. +- Reduce code duplication, even if it takes extra effort. +- Match the style of surrounding code. Consistency within a file matters. +- Fix bugs immediately when you find them. + +## Essential Commands + +| Task | Command | Notes | +|-------------|------------------|--------------------------------------------| +| Test | `make test` | All tests, no race detector | +| Test (race) | `make test-race` | CGO_ENABLED=1, use for CI | +| Coverage | `make coverage` | Prints summary to stdout | +| Format | `make fmt` | gofumpt; single file: `make fmt FILE=path` | +| Mocks | `make mocks` | Regenerate from `mcp/api.go` | + +**Always use these commands** instead of running `go test` or `gofumpt` directly. + +## Code Navigation + +Use LSP tools (go to definition, find references, hover) **before** resorting to grep. +This codebase has 90+ Go files across multiple packages, so LSP is faster and more accurate. + +## Architecture Overview + +AI Bridge is a smart gateway that sits between AI clients (Claude Code, Cursor, +etc.) and upstream providers (Anthropic, OpenAI). It intercepts all AI traffic +to provide centralized authn/z, auditing, token attribution, and MCP tool +administration. It runs as part of `coderd` (the Coder control plane). Users +authenticate with their Coder session tokens. + +``` +┌─────────────┐ ┌──────────────────────────────────────────┐ +│ AI Client │ │ aibridge │ +│ (Claude Code,│────▶│ RequestBridge (http.Handler) │ +│ Cursor) │ │ ├── Provider (Anthropic/OpenAI) │ +└─────────────┘ │ ├── Interceptor (streaming/blocking) │ + │ ├── Recorder (tokens, prompts, tools) │ + │ └── MCP Proxy (tool injection) │ + └──────────────┬───────────────────────────┘ + │ + ▼ + ┌──────────────┐ + │ Upstream API │ + │ (Anthropic, │ + │ OpenAI) │ + └──────────────┘ +``` + +Key packages: +- `intercept/`: request/response interception, per-provider subdirs (`messages/`, `responses/`, `chatcompletions/`) +- `provider/`: upstream provider definitions (Anthropic, OpenAI, Copilot) +- `mcp/`: MCP protocol integration +- `circuitbreaker/`: circuit breaker for upstream calls +- `context/`: request-scoped context helpers +- `internal/integrationtest/`: integration tests with mock upstreams + +## Go Patterns + +- Follow the [Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md). +- Use `gofumpt` for formatting (enforced by `make fmt`). +- Prefer table-driven tests. +- **Never use `time.Sleep` in tests**. Use `github.com/coder/quartz` or channels/contexts for synchronization. +- Use unique identifiers in tests: `fmt.Sprintf("test-%s-%d", t.Name(), time.Now().UnixNano())`. +- Test observable behavior, not implementation details. + +## Streaming Code + +This codebase heavily uses SSE streaming. When modifying interceptors: +- Always handle both blocking and streaming paths. +- Test with `*_test.go` files in the same package. They cover edge cases for chunked responses. +- Be careful with goroutine lifecycle. Ensure proper cleanup on context cancellation. + +## Commit Style + +``` +type(scope): message +``` + +- `scope` = real package path (e.g., `intercept/messages`, `provider`, `circuitbreaker`) +- Comments: full sentences, max 80 chars, explain **why** not what. + +## Do NOT + +- Rewrite comments or refactor code that isn't related to your task. +- Remove context from error messages. +- Use `--no-verify` on git operations. +- Add `//nolint` without a justification comment. +- Introduce new dependencies without discussion. + +## Common Pitfalls + +| Problem | Fix | +|-------------------------------|------------------------------------------------------------------| +| Race in streaming tests | Use `t.Cleanup()` and proper synchronization, never `time.Sleep` | +| Mock not updated | Run `make mocks` after changing `mcp/api.go` | +| Formatting failures | Run `make fmt` before committing | +| `retract` directive in go.mod | Don't remove. It's intentional (v1.0.8 conflict marker) | diff --git a/aibridge/README.md b/aibridge/README.md new file mode 100644 index 0000000000..acd54c0dcf --- /dev/null +++ b/aibridge/README.md @@ -0,0 +1,93 @@ +# aibridge + +aibridge is an HTTP gateway that sits between AI clients and upstream AI providers (Anthropic, OpenAI). It intercepts requests to record token usage, prompts, and tool invocations per user. Optionally supports centralized [MCP](https://modelcontextprotocol.io/) tool injection with allowlist/denylist filtering. + +## Architecture + +``` +┌─────────────────┐ ┌───────────────────────────────────────────┐ +│ AI Client │ │ aibridge │ +│ (Claude Code, │────▶│ ┌─────────────────┐ ┌─────────────┐ │ +│ Cursor, etc.) │ │ │ RequestBridge │───▶│ Providers │ │ +└─────────────────┘ │ │ (http.Handler) │ │ (Anthropic │ │ + │ └─────────────────┘ │ OpenAI) │ │ + │ └──────┬──────┘ │ + │ │ │ + │ ▼ │ ┌─────────────┐ + │ ┌─────────────────┐ ┌─────────────┐ │ │ Upstream │ + │ │ Recorder │◀───│ Interceptor │─── ───▶│ API │ + │ │ (tokens, tools, │ │ (streaming/ │ │ │ (Anthropic │ + │ │ prompts) │ │ blocking) │ │ │ OpenAI) │ + │ └────────┬────────┘ └──────┬──────┘ │ └─────────────┘ + │ │ │ │ + │ ▼ ┌──────▼──────┐ │ + │ ┌ ─ ─ ─ ─ ─ ─ ─ ┐ │ MCP Proxy │ │ + │ │ Database │ │ (tools) │ │ + │ └ ─ ─ ─ ─ ─ ─ ─ ┘ └─────────────┘ │ + └───────────────────────────────────────────┘ +``` + +### Components + +- **RequestBridge**: The main `http.Handler` that routes requests to providers +- **Provider**: Defines bridged routes (intercepted) and passthrough routes (proxied) +- **Interceptor**: Handles request/response processing and streaming +- **Recorder**: Interface for capturing usage data (tokens, prompts, tools) +- **MCP Proxy** (optional): Connects to MCP servers to list tool, inject them into requests, and invoke them in an inner agentic loop + +## Request Flow + +1. Client sends request to `/anthropic/v1/messages` or `/openai/v1/chat/completions` +2. **Actor extraction**: Request must have an actor in context (via `AsActor()`). +3. **Upstream call**: Request forwarded to the AI provider +4. **Response relay**: Response streamed/sent to client +5. **Recording**: Token usage, prompts, and tool invocations recorded + +**With MCP enabled**: Tools from configured MCP servers are centrally defined and injected into requests (prefixed `bmcp_`). Allowlist/denylist regex patterns control which tools are available. When the model selects an injected tool, the gateway invokes it in an inner agentic loop, and continues the conversation loop until complete. + +Passthrough routes (`/v1/models`, `/v1/messages/count_tokens`) are reverse-proxied directly. + +## Observability + +### Prometheus Metrics + +Create metrics with `NewMetrics(prometheus.Registerer)`: + +| Metric | Type | Description | +|-----------------------------------|-----------|-------------------------------| +| `interceptions_total` | Counter | Intercepted request count | +| `interceptions_inflight` | Gauge | Currently processing requests | +| `interceptions_duration_seconds` | Histogram | Request duration | +| `tokens_total` | Counter | Token usage (input/output) | +| `prompts_total` | Counter | User prompt count | +| `injected_tool_invocations_total` | Counter | MCP tool invocations | +| `passthrough_total` | Counter | Non-intercepted requests | + +### Recorder Interface + +Implement `Recorder` to persist usage data to your database: + +- `aibridge_interceptions` - request metadata (provider, model, initiator, timestamps) +- `aibridge_token_usages` - input/output token counts per response +- `aibridge_user_prompts` - user prompts +- `aibridge_tool_usages` - tool invocations (injected and client-defined) + +```go +type Recorder interface { + RecordInterception(ctx context.Context, req *InterceptionRecord) error + RecordInterceptionEnded(ctx context.Context, req *InterceptionRecordEnded) error + RecordTokenUsage(ctx context.Context, req *TokenUsageRecord) error + RecordPromptUsage(ctx context.Context, req *PromptUsageRecord) error + RecordToolUsage(ctx context.Context, req *ToolUsageRecord) error +} +``` + +## Supported Routes + +| Provider | Route | Type | +|-----------|---------------------------------------|-----------------------| +| Anthropic | `/anthropic/v1/messages` | Bridged (intercepted) | +| Anthropic | `/anthropic/v1/models` | Passthrough | +| Anthropic | `/anthropic/v1/messages/count_tokens` | Passthrough | +| OpenAI | `/openai/v1/chat/completions` | Bridged (intercepted) | +| OpenAI | `/openai/v1/models` | Passthrough | diff --git a/aibridge/api.go b/aibridge/api.go new file mode 100644 index 0000000000..809d452fe9 --- /dev/null +++ b/aibridge/api.go @@ -0,0 +1,66 @@ +package aibridge + +import ( + "context" + + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel/trace" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/metrics" + "github.com/coder/coder/v2/aibridge/provider" + "github.com/coder/coder/v2/aibridge/recorder" +) + +// Const + Type + function aliases for backwards compatibility. +const ( + ProviderAnthropic = config.ProviderAnthropic + ProviderOpenAI = config.ProviderOpenAI + ProviderCopilot = config.ProviderCopilot +) + +type ( + Metrics = metrics.Metrics + + Provider = provider.Provider + + InterceptionRecord = recorder.InterceptionRecord + InterceptionRecordEnded = recorder.InterceptionRecordEnded + TokenUsageRecord = recorder.TokenUsageRecord + PromptUsageRecord = recorder.PromptUsageRecord + ToolUsageRecord = recorder.ToolUsageRecord + ModelThoughtRecord = recorder.ModelThoughtRecord + Recorder = recorder.Recorder + Metadata = recorder.Metadata + + AnthropicConfig = config.Anthropic + AWSBedrockConfig = config.AWSBedrock + OpenAIConfig = config.OpenAI + CopilotConfig = config.Copilot +) + +func AsActor(ctx context.Context, actorID string, metadata recorder.Metadata) context.Context { + return aibcontext.AsActor(ctx, actorID, metadata) +} + +func NewAnthropicProvider(cfg config.Anthropic, bedrockCfg *config.AWSBedrock) provider.Provider { + return provider.NewAnthropic(cfg, bedrockCfg) +} + +func NewOpenAIProvider(cfg config.OpenAI) provider.Provider { + return provider.NewOpenAI(cfg) +} + +func NewCopilotProvider(cfg config.Copilot) provider.Provider { + return provider.NewCopilot(cfg) +} + +func NewMetrics(reg prometheus.Registerer) *metrics.Metrics { + return metrics.NewMetrics(reg) +} + +func NewRecorder(logger slog.Logger, tracer trace.Tracer, clientFn func() (Recorder, error)) Recorder { + return recorder.NewWrappedRecorder(logger, tracer, clientFn) +} diff --git a/aibridge/bridge.go b/aibridge/bridge.go new file mode 100644 index 0000000000..f604d0a38a --- /dev/null +++ b/aibridge/bridge.go @@ -0,0 +1,365 @@ +package aibridge + +import ( + "context" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/hashicorp/go-multierror" + "github.com/sony/gobreaker/v2" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/circuitbreaker" + aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/metrics" + "github.com/coder/coder/v2/aibridge/provider" + "github.com/coder/coder/v2/aibridge/recorder" + "github.com/coder/coder/v2/aibridge/tracing" +) + +const ( + // The duration after which an async recording will be aborted. + recordingTimeout = time.Second * 5 +) + +// RequestBridge is an [http.Handler] which is capable of masquerading as AI providers' APIs; +// specifically, OpenAI's & Anthropic's at present. +// RequestBridge intercepts requests to - and responses from - these upstream services to provide +// a centralized governance layer. +// +// RequestBridge has no concept of authentication or authorization. It does have a concept of identity, +// in the narrow sense that it expects an [actor] to be defined in the context, to record the initiator +// of each interception. +// +// RequestBridge is safe for concurrent use. +type RequestBridge struct { + mux *http.ServeMux + logger slog.Logger + + mcpProxy mcp.ServerProxier + + inflightReqs atomic.Int32 + inflightWG sync.WaitGroup // For graceful shutdown. + + inflightCtx context.Context + inflightCancel func() + + shutdownOnce sync.Once + closed chan struct{} +} + +var _ http.Handler = &RequestBridge{} + +// validProviderName matches names containing only lowercase alphanumeric characters and hyphens. +var validProviderName = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +// validateProviders checks that provider names are valid and unique. +func validateProviders(providers []provider.Provider) error { + names := make(map[string]bool, len(providers)) + for _, prov := range providers { + name := prov.Name() + if !validProviderName.MatchString(name) { + return xerrors.Errorf("invalid provider name %q: must contain only lowercase alphanumeric characters and hyphens", name) + } + if names[name] { + return xerrors.Errorf("duplicate provider name: %q", name) + } + names[name] = true + } + return nil +} + +// NewRequestBridge creates a new *[RequestBridge] and registers the HTTP routes defined by the given providers. +// Any routes which are requested but not registered will be reverse-proxied to the upstream service. +// +// A [intercept.Recorder] is also required to record prompt, tool, and token use. +// +// mcpProxy will be closed when the [RequestBridge] is closed. +// +// Circuit breaker configuration is obtained from each provider's CircuitBreakerConfig() method. +// Providers returning nil will not have circuit breaker protection. +func NewRequestBridge(ctx context.Context, providers []provider.Provider, rec recorder.Recorder, mcpProxy mcp.ServerProxier, logger slog.Logger, m *metrics.Metrics, tracer trace.Tracer) (*RequestBridge, error) { + if err := validateProviders(providers); err != nil { + return nil, err + } + + mux := http.NewServeMux() + + for _, prov := range providers { + // Create per-provider circuit breaker if configured + cfg := prov.CircuitBreakerConfig() + providerName := prov.Name() + onChange := func(endpoint, model string, from, to gobreaker.State) { + logger.Info(context.Background(), "circuit breaker state change", + slog.F("provider", providerName), + slog.F("endpoint", endpoint), + slog.F("model", model), + slog.F("from", from.String()), + slog.F("to", to.String()), + ) + if m != nil { + m.CircuitBreakerState.WithLabelValues(providerName, endpoint, model).Set(circuitbreaker.StateToGaugeValue(to)) + if to == gobreaker.StateOpen { + m.CircuitBreakerTrips.WithLabelValues(providerName, endpoint, model).Inc() + } + } + } + cbs := circuitbreaker.NewProviderCircuitBreakers(providerName, cfg, onChange, m) + + // Add the known provider-specific routes which are bridged (i.e. intercepted and augmented). + for _, path := range prov.BridgedRoutes() { + handler := newInterceptionProcessor(prov, cbs, rec, mcpProxy, logger, m, tracer) + route, err := url.JoinPath(prov.RoutePrefix(), path) + if err != nil { + logger.Error(ctx, "failed to join path", + slog.Error(err), + slog.F("provider", providerName), + slog.F("prefix", prov.RoutePrefix()), + slog.F("path", path), + ) + return nil, xerrors.Errorf("failed to configure provider '%v': failed to join bridged path: %w", providerName, err) + } + mux.Handle(route, handler) + } + + // Any requests which passthrough to this will be reverse-proxied to the upstream. + // + // We have to whitelist the known-safe routes because an API key with elevated privileges (i.e. admin) might be + // configured, so we should just reverse-proxy known-safe routes. + ftr := newPassthroughRouter(prov, logger.Named(fmt.Sprintf("passthrough.%s", prov.Name())), m, tracer) + for _, path := range prov.PassthroughRoutes() { + route, err := url.JoinPath(prov.RoutePrefix(), path) + if err != nil { + logger.Error(ctx, "failed to join path", + slog.Error(err), + slog.F("provider", providerName), + slog.F("prefix", prov.RoutePrefix()), + slog.F("path", path), + ) + return nil, xerrors.Errorf("failed to configure provider '%v': failed to join passed through path: %w", providerName, err) + } + mux.Handle(route, http.StripPrefix(prov.RoutePrefix(), ftr)) + } + } + + // Catch-all. + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + logger.Warn(r.Context(), "route not supported", slog.F("path", r.URL.Path), slog.F("method", r.Method)) + http.Error(w, fmt.Sprintf("route not supported: %s %s", r.Method, r.URL.Path), http.StatusNotFound) + }) + + inflightCtx, cancel := context.WithCancel(context.Background()) + return &RequestBridge{ + mux: mux, + logger: logger, + mcpProxy: mcpProxy, + inflightCtx: inflightCtx, + inflightCancel: cancel, + + closed: make(chan struct{}, 1), + }, nil +} + +// newInterceptionProcessor returns an [http.HandlerFunc] which is capable of creating a new interceptor and processing a given request +// using [Provider] p, recording all usage events using [Recorder] rec. +// If cbs is non-nil, circuit breaker protection is applied per endpoint/model tuple. +func newInterceptionProcessor(p provider.Provider, cbs *circuitbreaker.ProviderCircuitBreakers, rec recorder.Recorder, mcpProxy mcp.ServerProxier, logger slog.Logger, m *metrics.Metrics, tracer trace.Tracer) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := tracer.Start(r.Context(), "Intercept") + defer span.End() + + // We execute this before CreateInterceptor since the interceptors + // read the request body and don't reset them. + client := GuessClient(r) + sessionID := GuessSessionID(client, r) + + interceptor, err := p.CreateInterceptor(w, r.WithContext(ctx), tracer) + if err != nil { + span.SetStatus(codes.Error, fmt.Sprintf("failed to create interceptor: %v", err)) + logger.Warn(ctx, "failed to create interceptor", slog.Error(err), slog.F("path", r.URL.Path)) + http.Error(w, fmt.Sprintf("failed to create %q interceptor", r.URL.Path), http.StatusInternalServerError) + return + } + + if m != nil { + start := time.Now() + defer func() { + m.InterceptionDuration.WithLabelValues(p.Name(), interceptor.Model()).Observe(time.Since(start).Seconds()) + }() + } + + actor := aibcontext.ActorFromContext(ctx) + if actor == nil { + logger.Warn(ctx, "no actor found in context") + http.Error(w, "no actor found", http.StatusBadRequest) + return + } + + traceAttrs := interceptor.TraceAttributes(r) + span.SetAttributes(traceAttrs...) + ctx = tracing.WithInterceptionAttributesInContext(ctx, traceAttrs) + r = r.WithContext(ctx) + + // Record usage in the background to not block request flow. + asyncRecorder := recorder.NewAsyncRecorder(logger, rec, recordingTimeout) + asyncRecorder.WithMetrics(m) + asyncRecorder.WithProvider(p.Name()) + asyncRecorder.WithModel(interceptor.Model()) + asyncRecorder.WithInitiatorID(actor.ID) + asyncRecorder.WithClient(string(client)) + interceptor.Setup(logger, asyncRecorder, mcpProxy) + + cred := interceptor.Credential() + if err := rec.RecordInterception(ctx, &recorder.InterceptionRecord{ + ID: interceptor.ID().String(), + InitiatorID: actor.ID, + Metadata: actor.Metadata, + Model: interceptor.Model(), + Provider: p.Type(), + ProviderName: p.Name(), + UserAgent: r.UserAgent(), + Client: string(client), + ClientSessionID: sessionID, + CorrelatingToolCallID: interceptor.CorrelatingToolCallID(), + CredentialKind: string(cred.Kind), + CredentialHint: cred.Hint, + }); err != nil { + span.SetStatus(codes.Error, fmt.Sprintf("failed to record interception: %v", err)) + logger.Warn(ctx, "failed to record interception", slog.Error(err)) + http.Error(w, "failed to record interception", http.StatusInternalServerError) + return + } + + route := strings.TrimPrefix(r.URL.Path, fmt.Sprintf("/%s", p.Name())) + log := logger.With( + slog.F("route", route), + slog.F("provider", p.Name()), + slog.F("interception_id", interceptor.ID()), + slog.F("user_agent", r.UserAgent()), + slog.F("streaming", interceptor.Streaming()), + slog.F("credential_kind", string(cred.Kind)), + slog.F("credential_hint", cred.Hint), + slog.F("credential_length", cred.Length), + ) + + log.Debug(ctx, "interception started") + if m != nil { + m.InterceptionsInflight.WithLabelValues(p.Name(), interceptor.Model(), route).Add(1) + defer func() { + m.InterceptionsInflight.WithLabelValues(p.Name(), interceptor.Model(), route).Sub(1) + }() + } + + // Process request with circuit breaker protection if configured + if err := cbs.Execute(route, interceptor.Model(), w, func(rw http.ResponseWriter) error { + return interceptor.ProcessRequest(rw, r) + }); err != nil { + if m != nil { + m.InterceptionCount.WithLabelValues(p.Name(), interceptor.Model(), metrics.InterceptionCountStatusFailed, route, r.Method, actor.ID, string(client)).Add(1) + } + span.SetStatus(codes.Error, fmt.Sprintf("interception failed: %v", err)) + log.Warn(ctx, "interception failed", slog.Error(err)) + } else { + if m != nil { + m.InterceptionCount.WithLabelValues(p.Name(), interceptor.Model(), metrics.InterceptionCountStatusCompleted, route, r.Method, actor.ID, string(client)).Add(1) + } + log.Debug(ctx, "interception ended") + } + + _ = asyncRecorder.RecordInterceptionEnded(ctx, &recorder.InterceptionRecordEnded{ID: interceptor.ID().String()}) + + // Ensure all recording have completed before completing request. + asyncRecorder.Wait() + } +} + +// ServeHTTP exposes the internal http.Handler, which has all [Provider]s' routes registered. +// It also tracks inflight requests. +func (b *RequestBridge) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + select { + case <-b.closed: + http.Error(rw, "server closed", http.StatusInternalServerError) + return + default: + } + + // We want to abide by the context passed in without losing any of its + // functionality, but we still want to link our shutdown context to each + // request. + ctx := mergeContexts(r.Context(), b.inflightCtx) + + b.inflightReqs.Add(1) + b.inflightWG.Add(1) + defer func() { + b.inflightReqs.Add(-1) + b.inflightWG.Done() + }() + + b.mux.ServeHTTP(rw, r.WithContext(ctx)) +} + +// Shutdown will attempt to gracefully shutdown. This entails waiting for all requests to +// complete, and shutting down the MCP server proxier. +// TODO: add tests. +func (b *RequestBridge) Shutdown(ctx context.Context) error { + var err error + b.shutdownOnce.Do(func() { + // Prevent any new requests from being accepted. + close(b.closed) + + // Wait for inflight requests to complete or context cancellation. + done := make(chan struct{}) + go func() { + b.inflightWG.Wait() + close(done) + }() + + select { + case <-ctx.Done(): + // Cancel all inflight requests, if any are still running. + b.logger.Debug(ctx, "shutdown context canceled; canceling inflight requests", slog.Error(ctx.Err())) + b.inflightCancel() + <-done + err = ctx.Err() + case <-done: + } + + if b.mcpProxy != nil { + // It's ok that we reuse the ctx here even if it's done, since the + // Shutdown method will just immediately use the more aggressive close + // since the ctx is already expired. + err = multierror.Append(err, b.mcpProxy.Shutdown(ctx)) + } + }) + + return err +} + +func (b *RequestBridge) InflightRequests() int32 { + return b.inflightReqs.Load() +} + +// mergeContexts merges two contexts together, so that if either is canceled +// the returned context is canceled. The context values will only be used from +// the first context. +func mergeContexts(base, other context.Context) context.Context { + ctx, cancel := context.WithCancel(base) + go func() { + defer cancel() + select { + case <-base.Done(): + case <-other.Done(): + } + }() + return ctx +} diff --git a/aibridge/bridge_test.go b/aibridge/bridge_test.go new file mode 100644 index 0000000000..f2657ab80f --- /dev/null +++ b/aibridge/bridge_test.go @@ -0,0 +1,207 @@ +package aibridge_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/internal/testutil" + "github.com/coder/coder/v2/aibridge/provider" +) + +var bridgeTestTracer = otel.Tracer("bridge_test") + +func TestValidateProviders(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, nil) + + tests := []struct { + name string + providers []provider.Provider + expectErr string + }{ + { + name: "all_supported_providers", + providers: []provider.Provider{ + aibridge.NewOpenAIProvider(config.OpenAI{Name: "openai", BaseURL: "https://api.openai.com/v1/"}), + aibridge.NewAnthropicProvider(config.Anthropic{Name: "anthropic", BaseURL: "https://api.anthropic.com/"}, nil), + aibridge.NewCopilotProvider(config.Copilot{Name: "copilot", BaseURL: "https://api.individual.githubcopilot.com"}), + aibridge.NewCopilotProvider(config.Copilot{Name: "copilot-business", BaseURL: "https://api.business.githubcopilot.com"}), + aibridge.NewCopilotProvider(config.Copilot{Name: "copilot-enterprise", BaseURL: "https://api.enterprise.githubcopilot.com"}), + }, + }, + { + name: "default_names_and_base_urls", + providers: []provider.Provider{ + aibridge.NewOpenAIProvider(config.OpenAI{}), + aibridge.NewAnthropicProvider(config.Anthropic{}, nil), + aibridge.NewCopilotProvider(config.Copilot{}), + }, + }, + { + name: "multiple_copilot_instances", + providers: []provider.Provider{ + aibridge.NewCopilotProvider(config.Copilot{}), + aibridge.NewCopilotProvider(config.Copilot{Name: "copilot-business", BaseURL: "https://api.business.githubcopilot.com"}), + aibridge.NewCopilotProvider(config.Copilot{Name: "copilot-enterprise", BaseURL: "https://api.enterprise.githubcopilot.com"}), + }, + }, + { + name: "name_with_slashes", + providers: []provider.Provider{ + aibridge.NewCopilotProvider(config.Copilot{Name: "copilot/business", BaseURL: "https://api.business.githubcopilot.com"}), + }, + expectErr: "invalid provider name", + }, + { + name: "name_with_spaces", + providers: []provider.Provider{ + aibridge.NewCopilotProvider(config.Copilot{Name: "copilot business", BaseURL: "https://api.business.githubcopilot.com"}), + }, + expectErr: "invalid provider name", + }, + { + name: "name_with_uppercase", + providers: []provider.Provider{ + aibridge.NewCopilotProvider(config.Copilot{Name: "Copilot", BaseURL: "https://api.business.githubcopilot.com"}), + }, + expectErr: "invalid provider name", + }, + { + name: "unique_names", + providers: []provider.Provider{ + aibridge.NewCopilotProvider(config.Copilot{Name: "copilot", BaseURL: "https://api.individual.githubcopilot.com"}), + aibridge.NewCopilotProvider(config.Copilot{Name: "copilot-business", BaseURL: "https://api.business.githubcopilot.com"}), + }, + }, + { + name: "duplicate_base_url_different_names", + providers: []provider.Provider{ + aibridge.NewCopilotProvider(config.Copilot{Name: "copilot", BaseURL: "https://api.individual.githubcopilot.com"}), + aibridge.NewCopilotProvider(config.Copilot{Name: "copilot-business", BaseURL: "https://api.individual.githubcopilot.com"}), + }, + }, + { + name: "duplicate_name", + providers: []provider.Provider{ + aibridge.NewCopilotProvider(config.Copilot{Name: "copilot", BaseURL: "https://api.individual.githubcopilot.com"}), + aibridge.NewCopilotProvider(config.Copilot{Name: "copilot", BaseURL: "https://api.business.githubcopilot.com"}), + }, + expectErr: "duplicate provider name", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, err := aibridge.NewRequestBridge(t.Context(), tc.providers, nil, nil, logger, nil, bridgeTestTracer) + if tc.expectErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectErr) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestPassthroughRoutesForProviders(t *testing.T) { + t.Parallel() + + upstreamRespBody := "upstream response" + tests := []struct { + name string + baseURLPath string + requestPath string + provider func(string) provider.Provider + expectPath string + }{ + { + name: "openAI_no_base_path", + requestPath: "/openai/v1/conversations", + provider: func(baseURL string) provider.Provider { + return aibridge.NewOpenAIProvider(config.OpenAI{BaseURL: baseURL}) + }, + expectPath: "/conversations", + }, + { + name: "openAI_with_base_path", + baseURLPath: "/v1", + requestPath: "/openai/v1/conversations", + provider: func(baseURL string) provider.Provider { + return aibridge.NewOpenAIProvider(config.OpenAI{BaseURL: baseURL}) + }, + expectPath: "/v1/conversations", + }, + { + name: "anthropic_no_base_path", + requestPath: "/anthropic/v1/models", + provider: func(baseURL string) provider.Provider { + return aibridge.NewAnthropicProvider(config.Anthropic{BaseURL: baseURL}, nil) + }, + expectPath: "/v1/models", + }, + { + name: "anthropic_with_base_path", + baseURLPath: "/v1", + requestPath: "/anthropic/v1/models", + provider: func(baseURL string) provider.Provider { + return aibridge.NewAnthropicProvider(config.Anthropic{BaseURL: baseURL}, nil) + }, + expectPath: "/v1/v1/models", + }, + { + name: "copilot_no_base_path", + requestPath: "/copilot/models", + provider: func(baseURL string) provider.Provider { + return aibridge.NewCopilotProvider(config.Copilot{BaseURL: baseURL}) + }, + expectPath: "/models", + }, + { + name: "copilot_with_base_path", + baseURLPath: "/v1", + requestPath: "/copilot/models", + provider: func(baseURL string) provider.Provider { + return aibridge.NewCopilotProvider(config.Copilot{BaseURL: baseURL}) + }, + expectPath: "/v1/models", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, nil) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, tc.expectPath, r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(upstreamRespBody)) + })) + t.Cleanup(upstream.Close) + + rec := testutil.MockRecorder{} + prov := tc.provider(upstream.URL + tc.baseURLPath) + bridge, err := aibridge.NewRequestBridge(t.Context(), []provider.Provider{prov}, &rec, nil, logger, nil, bridgeTestTracer) + require.NoError(t, err) + + req := httptest.NewRequest("", tc.requestPath, nil) + resp := httptest.NewRecorder() + bridge.ServeHTTP(resp, req) + + assert.Equal(t, http.StatusOK, resp.Code) + assert.Contains(t, resp.Body.String(), upstreamRespBody) + }) + } +} diff --git a/aibridge/circuitbreaker/circuitbreaker.go b/aibridge/circuitbreaker/circuitbreaker.go new file mode 100644 index 0000000000..0f0880b192 --- /dev/null +++ b/aibridge/circuitbreaker/circuitbreaker.go @@ -0,0 +1,217 @@ +package circuitbreaker + +import ( + "bufio" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "github.com/sony/gobreaker/v2" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/metrics" +) + +// ErrCircuitOpen is returned by Execute when the circuit breaker is open +// and the request was rejected without calling the handler. +var ErrCircuitOpen = xerrors.New("circuit breaker is open") + +// DefaultIsFailure returns true for standard HTTP status codes that typically +// indicate upstream overload. +func DefaultIsFailure(statusCode int) bool { + switch statusCode { + case http.StatusTooManyRequests, // 429 + http.StatusServiceUnavailable, // 503 + http.StatusGatewayTimeout: // 504 + return true + default: + return false + } +} + +// ProviderCircuitBreakers manages per-endpoint/model circuit breakers for a single provider. +type ProviderCircuitBreakers struct { + provider string + config config.CircuitBreaker + breakers sync.Map // "endpoint:model" -> *gobreaker.CircuitBreaker[struct{}] + onChange func(endpoint, model string, from, to gobreaker.State) + metrics *metrics.Metrics +} + +// NewProviderCircuitBreakers creates circuit breakers for a single provider. +// Returns nil if cfg is nil (no circuit breaker protection). +// onChange is called when circuit state changes. +// metrics is used to record circuit breaker reject counts (can be nil). +func NewProviderCircuitBreakers(provider string, cfg *config.CircuitBreaker, onChange func(endpoint, model string, from, to gobreaker.State), m *metrics.Metrics) *ProviderCircuitBreakers { + if cfg == nil { + return nil + } + return &ProviderCircuitBreakers{ + provider: provider, + config: *cfg, + onChange: onChange, + metrics: m, + } +} + +// isFailure checks if the status code should count as a failure. +// Falls back to DefaultIsFailure if no custom function is configured. +func (p *ProviderCircuitBreakers) isFailure(statusCode int) bool { + if p.config.IsFailure != nil { + return p.config.IsFailure(statusCode) + } + return DefaultIsFailure(statusCode) +} + +// openErrBody returns the error response body when the circuit is open. +func (p *ProviderCircuitBreakers) openErrBody() []byte { + if p.config.OpenErrorResponse != nil { + return p.config.OpenErrorResponse() + } + return []byte(`{"error":"circuit breaker is open"}`) +} + +// Get returns the circuit breaker for an endpoint/model tuple, creating it if needed. +func (p *ProviderCircuitBreakers) Get(endpoint, model string) *gobreaker.CircuitBreaker[struct{}] { + key := endpoint + ":" + model + if v, ok := p.breakers.Load(key); ok { + return v.(*gobreaker.CircuitBreaker[struct{}]) //nolint:forcetypeassert // sync.Map always stores this type + } + + settings := gobreaker.Settings{ + Name: p.provider + ":" + key, + MaxRequests: p.config.MaxRequests, + Interval: p.config.Interval, + Timeout: p.config.Timeout, + ReadyToTrip: func(counts gobreaker.Counts) bool { + return counts.ConsecutiveFailures >= p.config.FailureThreshold + }, + OnStateChange: func(_ string, from, to gobreaker.State) { + if p.onChange != nil { + p.onChange(endpoint, model, from, to) + } + }, + } + + cb := gobreaker.NewCircuitBreaker[struct{}](settings) + actual, _ := p.breakers.LoadOrStore(key, cb) + return actual.(*gobreaker.CircuitBreaker[struct{}]) //nolint:forcetypeassert // sync.Map always stores this type +} + +// statusCapturingWriter wraps http.ResponseWriter to capture the status code. +// It implements http.Flusher to support streaming and http.Hijacker to +// satisfy the FullResponseWriter lint rule. +type statusCapturingWriter struct { + http.ResponseWriter + statusCode int + headerWritten bool +} + +func (w *statusCapturingWriter) WriteHeader(code int) { + if !w.headerWritten { + w.statusCode = code + w.headerWritten = true + } + w.ResponseWriter.WriteHeader(code) +} + +func (w *statusCapturingWriter) Write(b []byte) (int, error) { + if !w.headerWritten { + w.statusCode = http.StatusOK + w.headerWritten = true + } + return w.ResponseWriter.Write(b) +} + +func (w *statusCapturingWriter) Flush() { + if f, ok := w.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +func (w *statusCapturingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + h, ok := w.ResponseWriter.(http.Hijacker) + if !ok { + return nil, nil, xerrors.New("upstream ResponseWriter does not support hijacking") + } + return h.Hijack() +} + +// Unwrap returns the underlying ResponseWriter for interface checks. +func (w *statusCapturingWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + +// Execute runs the given handler function within circuit breaker protection. +// If the circuit is open, the request is rejected with a 503 response, metrics are recorded, +// and ErrCircuitOpen is returned. +// Otherwise, it returns the handler's error (or nil on success). +// The handler receives a wrapped ResponseWriter that captures the status code. +// If the receiver is nil (no circuit breaker configured), the handler is called directly. +func (p *ProviderCircuitBreakers) Execute(endpoint, model string, w http.ResponseWriter, handler func(http.ResponseWriter) error) error { + if p == nil { + return handler(w) + } + + cb := p.Get(endpoint, model) + + // Wrap response writer to capture status code + sw := &statusCapturingWriter{ResponseWriter: w, statusCode: http.StatusOK} + + var handlerErr error + _, err := cb.Execute(func() (struct{}, error) { + handlerErr = handler(sw) + if p.isFailure(sw.statusCode) { + return struct{}{}, xerrors.Errorf("upstream error: %d", sw.statusCode) + } + return struct{}{}, nil + }) + + if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) { + if p.metrics != nil { + p.metrics.CircuitBreakerRejects.WithLabelValues(p.provider, endpoint, model).Inc() + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Retry-After", fmt.Sprintf("%d", int64(p.config.Timeout.Seconds()))) + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write(p.openErrBody()) + return ErrCircuitOpen + } + + return handlerErr +} + +// Timeout returns the configured timeout duration for this circuit breaker. +func (p *ProviderCircuitBreakers) Timeout() time.Duration { + return p.config.Timeout +} + +// Provider returns the provider name for this circuit breaker. +func (p *ProviderCircuitBreakers) Provider() string { + return p.provider +} + +// OpenErrorResponse returns the error response body when the circuit is open. +// This is exposed for handlers to use when responding to rejected requests. +func (p *ProviderCircuitBreakers) OpenErrorResponse() []byte { + return p.openErrBody() +} + +// StateToGaugeValue converts gobreaker.State to a gauge value. +// closed=0, half-open=0.5, open=1 +func StateToGaugeValue(s gobreaker.State) float64 { + switch s { + case gobreaker.StateClosed: + return 0 + case gobreaker.StateHalfOpen: + return 0.5 + case gobreaker.StateOpen: + return 1 + default: + return 0 + } +} diff --git a/aibridge/circuitbreaker/circuitbreaker_test.go b/aibridge/circuitbreaker/circuitbreaker_test.go new file mode 100644 index 0000000000..b80bfa2deb --- /dev/null +++ b/aibridge/circuitbreaker/circuitbreaker_test.go @@ -0,0 +1,223 @@ +package circuitbreaker_test + +import ( + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/sony/gobreaker/v2" + "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/aibridge/circuitbreaker" + "github.com/coder/coder/v2/aibridge/config" +) + +func TestExecute_PerModelIsolation(t *testing.T) { + t.Parallel() + + sonnetCalls := atomic.Int32{} + haikuCalls := atomic.Int32{} + + cbs := circuitbreaker.NewProviderCircuitBreakers("test", &config.CircuitBreaker{ + FailureThreshold: 1, + Interval: time.Minute, + Timeout: time.Minute, + MaxRequests: 1, + }, func(endpoint, model string, from, to gobreaker.State) {}, nil) + + endpoint := "/v1/messages" + sonnetModel := "claude-sonnet-4-20250514" + haikuModel := "claude-3-5-haiku-20241022" + + // Trip circuit on sonnet model (returns 429) + w := httptest.NewRecorder() + err := cbs.Execute(endpoint, sonnetModel, w, func(rw http.ResponseWriter) error { + sonnetCalls.Add(1) + rw.WriteHeader(http.StatusTooManyRequests) + return nil + }) + assert.NoError(t, err) + assert.Equal(t, int32(1), sonnetCalls.Load()) + + // Second sonnet request should be blocked by circuit breaker + w = httptest.NewRecorder() + err = cbs.Execute(endpoint, sonnetModel, w, func(rw http.ResponseWriter) error { + sonnetCalls.Add(1) + rw.WriteHeader(http.StatusOK) + return nil + }) + assert.True(t, errors.Is(err, circuitbreaker.ErrCircuitOpen)) + assert.Equal(t, int32(1), sonnetCalls.Load()) // No new call + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + + // Haiku model on same endpoint should still work (independent circuit) + w = httptest.NewRecorder() + err = cbs.Execute(endpoint, haikuModel, w, func(rw http.ResponseWriter) error { + haikuCalls.Add(1) + rw.WriteHeader(http.StatusOK) + return nil + }) + assert.NoError(t, err) + assert.Equal(t, int32(1), haikuCalls.Load()) +} + +func TestExecute_PerEndpointIsolation(t *testing.T) { + t.Parallel() + + messagesCalls := atomic.Int32{} + completionsCalls := atomic.Int32{} + + cbs := circuitbreaker.NewProviderCircuitBreakers("test", &config.CircuitBreaker{ + FailureThreshold: 1, + Interval: time.Minute, + Timeout: time.Minute, + MaxRequests: 1, + }, func(endpoint, model string, from, to gobreaker.State) {}, nil) + + model := "test-model" + + // Trip circuit on /v1/messages endpoint (returns 429) + w := httptest.NewRecorder() + err := cbs.Execute("/v1/messages", model, w, func(rw http.ResponseWriter) error { + messagesCalls.Add(1) + rw.WriteHeader(http.StatusTooManyRequests) + return nil + }) + assert.NoError(t, err) + assert.Equal(t, int32(1), messagesCalls.Load()) + + // Second /v1/messages request should be blocked + w = httptest.NewRecorder() + err = cbs.Execute("/v1/messages", model, w, func(rw http.ResponseWriter) error { + messagesCalls.Add(1) + rw.WriteHeader(http.StatusOK) + return nil + }) + assert.True(t, errors.Is(err, circuitbreaker.ErrCircuitOpen)) + assert.Equal(t, int32(1), messagesCalls.Load()) // No new call + assert.Equal(t, http.StatusServiceUnavailable, w.Code) + + // /v1/chat/completions on same model should still work (different endpoint) + w = httptest.NewRecorder() + err = cbs.Execute("/v1/chat/completions", model, w, func(rw http.ResponseWriter) error { + completionsCalls.Add(1) + rw.WriteHeader(http.StatusOK) + return nil + }) + assert.NoError(t, err) + assert.Equal(t, int32(1), completionsCalls.Load()) +} + +func TestExecute_CustomIsFailure(t *testing.T) { + t.Parallel() + + var calls atomic.Int32 + + // Custom IsFailure that treats 502 as failure + cbs := circuitbreaker.NewProviderCircuitBreakers("test", &config.CircuitBreaker{ + FailureThreshold: 1, + Interval: time.Minute, + Timeout: time.Minute, + MaxRequests: 1, + IsFailure: func(statusCode int) bool { + return statusCode == http.StatusBadGateway + }, + }, func(endpoint, model string, from, to gobreaker.State) {}, nil) + + // First request returns 502, trips circuit + w := httptest.NewRecorder() + err := cbs.Execute("/v1/messages", "test-model", w, func(rw http.ResponseWriter) error { + calls.Add(1) + rw.WriteHeader(http.StatusBadGateway) + return nil + }) + assert.NoError(t, err) + assert.Equal(t, int32(1), calls.Load()) + + // Second request should be blocked + w = httptest.NewRecorder() + err = cbs.Execute("/v1/messages", "test-model", w, func(rw http.ResponseWriter) error { + calls.Add(1) + rw.WriteHeader(http.StatusOK) + return nil + }) + assert.True(t, errors.Is(err, circuitbreaker.ErrCircuitOpen)) + assert.Equal(t, int32(1), calls.Load()) // No new call + assert.Equal(t, http.StatusServiceUnavailable, w.Code) +} + +func TestExecute_OnStateChange(t *testing.T) { + t.Parallel() + + var stateChanges []struct { + endpoint string + model string + from gobreaker.State + to gobreaker.State + } + + cbs := circuitbreaker.NewProviderCircuitBreakers("test", &config.CircuitBreaker{ + FailureThreshold: 1, + Interval: time.Minute, + Timeout: time.Minute, + MaxRequests: 1, + }, func(endpoint, model string, from, to gobreaker.State) { + stateChanges = append(stateChanges, struct { + endpoint string + model string + from gobreaker.State + to gobreaker.State + }{endpoint, model, from, to}) + }, nil) + + endpoint := "/v1/messages" + model := "claude-sonnet-4-20250514" + + // Trip circuit + w := httptest.NewRecorder() + err := cbs.Execute(endpoint, model, w, func(rw http.ResponseWriter) error { + rw.WriteHeader(http.StatusTooManyRequests) + return nil + }) + assert.NoError(t, err) + + // Verify state change callback was called with correct parameters + assert.Len(t, stateChanges, 1) + assert.Equal(t, endpoint, stateChanges[0].endpoint) + assert.Equal(t, model, stateChanges[0].model) + assert.Equal(t, gobreaker.StateClosed, stateChanges[0].from) + assert.Equal(t, gobreaker.StateOpen, stateChanges[0].to) +} + +func TestDefaultIsFailure(t *testing.T) { + t.Parallel() + + tests := []struct { + statusCode int + isFailure bool + }{ + {http.StatusOK, false}, + {http.StatusBadRequest, false}, + {http.StatusUnauthorized, false}, + {http.StatusTooManyRequests, true}, // 429 + {http.StatusInternalServerError, false}, + {http.StatusBadGateway, false}, + {http.StatusServiceUnavailable, true}, // 503 + {http.StatusGatewayTimeout, true}, // 504 + } + + for _, tt := range tests { + assert.Equal(t, tt.isFailure, circuitbreaker.DefaultIsFailure(tt.statusCode), "status code %d", tt.statusCode) + } +} + +func TestStateToGaugeValue(t *testing.T) { + t.Parallel() + + assert.Equal(t, float64(0), circuitbreaker.StateToGaugeValue(gobreaker.StateClosed)) + assert.Equal(t, float64(0.5), circuitbreaker.StateToGaugeValue(gobreaker.StateHalfOpen)) + assert.Equal(t, float64(1), circuitbreaker.StateToGaugeValue(gobreaker.StateOpen)) +} diff --git a/aibridge/client.go b/aibridge/client.go new file mode 100644 index 0000000000..3e9e277bec --- /dev/null +++ b/aibridge/client.go @@ -0,0 +1,60 @@ +package aibridge + +import ( + "net/http" + "strings" +) + +type Client string + +const ( + // Possible values for the "client" field in interception records. + // Must be kept in sync with documentation: https://github.com/coder/coder/blob/90c11f3386578da053ec5cd9f1475835b980e7c7/docs/ai-coder/ai-bridge/monitoring.md?plain=1#L36-L44 + ClientClaudeCode Client = "Claude Code" + ClientCodex Client = "Codex" + ClientZed Client = "Zed" + ClientCopilotVSC Client = "GitHub Copilot (VS Code)" + ClientCopilotCLI Client = "GitHub Copilot (CLI)" + ClientKilo Client = "Kilo Code" + ClientCoderAgents Client = "Coder Agents" + ClientCrush Client = "Charm Crush" + ClientMux Client = "Mux" + ClientRoo Client = "Roo Code" + ClientCursor Client = "Cursor" + ClientUnknown Client = "Unknown" +) + +// GuessClient attempts to guess the client application from the request headers. +// Not all clients set proper user agent headers, so this is a best-effort approach. +// Based on https://github.com/coder/aibridge/issues/20#issuecomment-3769444101. +func GuessClient(r *http.Request) Client { + userAgent := strings.ToLower(r.UserAgent()) + originator := r.Header.Get("originator") + + // Must be kept in sync with documentation: https://github.com/coder/coder/blob/90c11f3386578da053ec5cd9f1475835b980e7c7/docs/ai-coder/ai-bridge/monitoring.md?plain=1#L36-L44 + switch { + case strings.HasPrefix(userAgent, "mux/"): + return ClientMux + case strings.HasPrefix(userAgent, "claude"): + return ClientClaudeCode + case strings.HasPrefix(userAgent, "codex"): + return ClientCodex + case strings.HasPrefix(userAgent, "zed/"): + return ClientZed + case strings.HasPrefix(userAgent, "githubcopilotchat/"): + return ClientCopilotVSC + case strings.HasPrefix(userAgent, "copilot/"): + return ClientCopilotCLI + case strings.HasPrefix(userAgent, "kilo-code/") || originator == "kilo-code": + return ClientKilo + case strings.HasPrefix(userAgent, "roo-code/") || originator == "roo-code": + return ClientRoo + case strings.HasPrefix(userAgent, "coder-agents/"): + return ClientCoderAgents + case strings.HasPrefix(userAgent, "charm crush/"): + return ClientCrush + case r.Header.Get("x-cursor-client-version") != "": + return ClientCursor + } + return ClientUnknown +} diff --git a/aibridge/client_test.go b/aibridge/client_test.go new file mode 100644 index 0000000000..e3fa82866e --- /dev/null +++ b/aibridge/client_test.go @@ -0,0 +1,125 @@ +package aibridge_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/aibridge" +) + +func TestGuessClient(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + userAgent string + headers map[string]string + wantClient aibridge.Client + }{ + { + name: "mux", + userAgent: "mux/0.19.0-next.2.gcceff159 ai-sdk/openai/3.0.36 ai-sdk/provider-utils/4.0.15 runtime/node.js/22", + wantClient: aibridge.ClientMux, + }, + { + name: "claude_code", + userAgent: "claude-cli/2.0.67 (external, cli)", + wantClient: aibridge.ClientClaudeCode, + }, + { + name: "codex_cli", + userAgent: "codex_cli_rs/0.87.0 (Mac OS 26.2.0; arm64) ghostty/1.3.0-main_250877ef", + wantClient: aibridge.ClientCodex, + }, + { + name: "zed", + userAgent: "Zed/0.219.4+stable.119.abc123 (macos; aarch64)", + wantClient: aibridge.ClientZed, + }, + { + name: "github_copilot_vsc", + userAgent: "GitHubCopilotChat/0.37.2026011603", + wantClient: aibridge.ClientCopilotVSC, + }, + { + name: "github_copilot_cli", + userAgent: "copilot/0.0.403 (client/cli linux v24.11.1)", + wantClient: aibridge.ClientCopilotCLI, + }, + { + name: "kilo_code_user_agent", + userAgent: "kilo-code/5.1.0 (darwin 25.2.0; arm64) node/22.21.1", + wantClient: aibridge.ClientKilo, + }, + { + name: "kilo_code_originator", + headers: map[string]string{"Originator": "kilo-code"}, + wantClient: aibridge.ClientKilo, + }, + { + name: "roo_code_user_agent", + userAgent: "roo-code/3.45.0 (darwin 25.2.0; arm64) node/22.21.1", + wantClient: aibridge.ClientRoo, + }, + { + name: "roo_code_originator", + headers: map[string]string{"Originator": "roo-code"}, + wantClient: aibridge.ClientRoo, + }, + { + name: "coder_agents", + userAgent: "coder-agents/v2.24.0 (linux/amd64)", + wantClient: aibridge.ClientCoderAgents, + }, + { + name: "coder_agents_dev", + userAgent: "coder-agents/v0.0.0-devel (darwin/arm64)", + wantClient: aibridge.ClientCoderAgents, + }, + { + name: "charm_crush", + userAgent: "Charm Crush/0.1.11", + wantClient: aibridge.ClientCrush, + }, + { + name: "cursor_x_cursor_client_version", + userAgent: "connect-es/1.6.1", + headers: map[string]string{"X-Cursor-client-version": "0.50.0"}, + wantClient: aibridge.ClientCursor, + }, + { + name: "cursor_x_cursor_some_other_header", + headers: map[string]string{"x-cursor-client-version": "abc123"}, + wantClient: aibridge.ClientCursor, + }, + { + name: "unknown_client", + userAgent: "ccclaude-cli/calude-with-wrong-prefix", + wantClient: aibridge.ClientUnknown, + }, + { + name: "empty_user_agent", + userAgent: "", + wantClient: aibridge.ClientUnknown, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "", nil) + require.NoError(t, err) + + req.Header.Set("User-Agent", tt.userAgent) + for key, value := range tt.headers { + req.Header.Set(key, value) + } + + got := aibridge.GuessClient(req) + require.Equal(t, tt.wantClient, got) + }) + } +} diff --git a/aibridge/config/config.go b/aibridge/config/config.go new file mode 100644 index 0000000000..1af2407b8a --- /dev/null +++ b/aibridge/config/config.go @@ -0,0 +1,93 @@ +package config + +import "time" + +const ( + ProviderAnthropic = "anthropic" + ProviderOpenAI = "openai" + ProviderCopilot = "copilot" +) + +type Anthropic struct { + // Name is the provider instance name. If empty, defaults to "anthropic". + Name string + BaseURL string + Key string + APIDumpDir string + CircuitBreaker *CircuitBreaker + SendActorHeaders bool + ExtraHeaders map[string]string + // BYOKBearerToken is set in BYOK mode when the user authenticates + // with a access token. When set, the access token is used for upstream + // LLM requests instead of the API key. + BYOKBearerToken string + // MaxRetries controls the number of automatic retries the SDK will perform + // on transient errors. If nil, the SDK default (2) is used. + // Set to 0 to disable retries entirely. + MaxRetries *int +} + +type AWSBedrock struct { + Region string + AccessKey, AccessKeySecret string + Model, SmallFastModel string + // If set, requests will be sent to this URL instead of the default AWS Bedrock endpoint + // (https://bedrock-runtime.{region}.amazonaws.com). + // This is useful for routing requests through a proxy or for testing. + BaseURL string +} + +type OpenAI struct { + // Name is the provider instance name. If empty, defaults to "openai". + Name string + BaseURL string + Key string + APIDumpDir string + CircuitBreaker *CircuitBreaker + SendActorHeaders bool + ExtraHeaders map[string]string + // MaxRetries controls the number of automatic retries the SDK will perform + // on transient errors. If nil, the SDK default (2) is used. + // Set to 0 to disable retries entirely. + MaxRetries *int +} + +type Copilot struct { + // Name is the provider instance name. If empty, defaults to "copilot". + Name string + BaseURL string + APIDumpDir string + CircuitBreaker *CircuitBreaker + // MaxRetries controls the number of automatic retries the SDK will perform + // on transient errors. If nil, the SDK default (2) is used. + // Set to 0 to disable retries entirely. + MaxRetries *int +} + +// CircuitBreaker holds configuration for circuit breakers. +type CircuitBreaker struct { + // MaxRequests is the maximum number of requests allowed in half-open state. + MaxRequests uint32 + // Interval is the cyclic period of the closed state for clearing internal counts. + Interval time.Duration + // Timeout is how long the circuit stays open before transitioning to half-open. + Timeout time.Duration + // FailureThreshold is the number of consecutive failures that triggers the circuit to open. + FailureThreshold uint32 + // IsFailure determines if a status code should count as a failure. + // If nil, defaults to DefaultIsFailure. + IsFailure func(statusCode int) bool + // OpenErrorResponse returns the response body when the circuit is open. + // This should match the provider's error format. + OpenErrorResponse func() []byte +} + +// DefaultCircuitBreaker returns sensible defaults for circuit breaker configuration. +func DefaultCircuitBreaker() CircuitBreaker { + return CircuitBreaker{ + FailureThreshold: 5, + Interval: 10 * time.Second, + Timeout: 30 * time.Second, + MaxRequests: 3, + } +} diff --git a/aibridge/context/context.go b/aibridge/context/context.go new file mode 100644 index 0000000000..ecb97d0f94 --- /dev/null +++ b/aibridge/context/context.go @@ -0,0 +1,38 @@ +package context + +import ( + "context" + + "github.com/coder/coder/v2/aibridge/recorder" +) + +type ( + actorContextKey struct{} +) + +type Actor struct { + ID string + Metadata recorder.Metadata +} + +func AsActor(ctx context.Context, actorID string, metadata recorder.Metadata) context.Context { + return context.WithValue(ctx, actorContextKey{}, &Actor{ID: actorID, Metadata: metadata}) +} + +func ActorFromContext(ctx context.Context) *Actor { + a, ok := ctx.Value(actorContextKey{}).(*Actor) + if !ok { + return nil + } + + return a +} + +// ActorIDFromContext safely extracts the actor ID from the context. +// Returns an empty string if no actor is found. +func ActorIDFromContext(ctx context.Context) string { + if actor := ActorFromContext(ctx); actor != nil { + return actor.ID + } + return "" +} diff --git a/aibridge/context/context_test.go b/aibridge/context/context_test.go new file mode 100644 index 0000000000..039b3a9a25 --- /dev/null +++ b/aibridge/context/context_test.go @@ -0,0 +1,89 @@ +package context_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/recorder" +) + +func TestAsActor(t *testing.T) { + t.Parallel() + + // Given: a metadata map + metadata := recorder.Metadata{"key": "value"} + + // When: storing an actor in the context + ctx := aibcontext.AsActor(context.Background(), "actor-123", metadata) + + // Then: the actor should be retrievable with correct ID and metadata + actor := aibcontext.ActorFromContext(ctx) + require.NotNil(t, actor) + assert.Equal(t, "actor-123", actor.ID) + assert.Equal(t, "value", actor.Metadata["key"]) +} + +func TestActorFromContext(t *testing.T) { + t.Parallel() + + t.Run("returns actor when present", func(t *testing.T) { + t.Parallel() + + // Given: a context with an actor + ctx := aibcontext.AsActor(context.Background(), "test-id", recorder.Metadata{}) + + // When: extracting the actor from context + actor := aibcontext.ActorFromContext(ctx) + + // Then: the actor should be returned with correct ID + require.NotNil(t, actor) + assert.Equal(t, "test-id", actor.ID) + }) + + t.Run("returns nil when no actor", func(t *testing.T) { + t.Parallel() + + // Given: a context without an actor + ctx := context.Background() + + // When: extracting the actor from context + actor := aibcontext.ActorFromContext(ctx) + + // Then: nil should be returned + assert.Nil(t, actor) + }) +} + +func TestActorIDFromContext(t *testing.T) { + t.Parallel() + + t.Run("returns actor ID when present", func(t *testing.T) { + t.Parallel() + + // Given: a context with an actor + ctx := aibcontext.AsActor(context.Background(), "test-actor-id", recorder.Metadata{}) + + // When: extracting the actor ID from context + got := aibcontext.ActorIDFromContext(ctx) + + // Then: the actor ID should be returned + assert.Equal(t, "test-actor-id", got) + }) + + t.Run("returns empty string when no actor", func(t *testing.T) { + t.Parallel() + + // Given: a context without an actor + ctx := context.Background() + + // When: extracting the actor ID from context + got := aibcontext.ActorIDFromContext(ctx) + + // Then: an empty string should be returned + assert.Empty(t, got) + }) +} diff --git a/aibridge/fixtures/README.md b/aibridge/fixtures/README.md new file mode 100644 index 0000000000..075eaed0a3 --- /dev/null +++ b/aibridge/fixtures/README.md @@ -0,0 +1,25 @@ +These fixtures were created by adding logging middleware to API calls to view the raw requests/responses. + +```go +... +opts = append(opts, option.WithMiddleware(LoggingMiddleware)) +... + +func LoggingMiddleware(req *http.Request, next option.MiddlewareNext) (res *http.Response, err error) { + reqOut, _ := httputil.DumpRequest(req, true) + + // Forward the request to the next handler + res, err = next(req) + fmt.Printf("[req] %s\n", reqOut) + + // Handle stuff after the request + if err != nil { + return res, err + } + + respOut, _ := httputil.DumpResponse(res, true) + fmt.Printf("[resp] %s\n", respOut) + + return res, err +} +``` diff --git a/aibridge/fixtures/anthropic/fallthrough.txtar b/aibridge/fixtures/anthropic/fallthrough.txtar new file mode 100644 index 0000000000..94e71c462b --- /dev/null +++ b/aibridge/fixtures/anthropic/fallthrough.txtar @@ -0,0 +1,64 @@ +API endpoints not explicitly handled will fallthrough to upstream via reverse-proxy. + +-- non-streaming -- +{ + "data": [ + { + "type": "model", + "id": "claude-opus-4-1-20250805", + "display_name": "Claude Opus 4.1", + "created_at": "2025-08-05T00:00:00Z" + }, + { + "type": "model", + "id": "claude-opus-4-20250514", + "display_name": "Claude Opus 4", + "created_at": "2025-05-22T00:00:00Z" + }, + { + "type": "model", + "id": "claude-sonnet-4-20250514", + "display_name": "Claude Sonnet 4", + "created_at": "2025-05-22T00:00:00Z" + }, + { + "type": "model", + "id": "claude-3-7-sonnet-20250219", + "display_name": "Claude Sonnet 3.7", + "created_at": "2025-02-24T00:00:00Z" + }, + { + "type": "model", + "id": "claude-3-5-sonnet-20241022", + "display_name": "Claude Sonnet 3.5 (New)", + "created_at": "2024-10-22T00:00:00Z" + }, + { + "type": "model", + "id": "claude-3-5-haiku-20241022", + "display_name": "Claude Haiku 3.5", + "created_at": "2024-10-22T00:00:00Z" + }, + { + "type": "model", + "id": "claude-3-5-sonnet-20240620", + "display_name": "Claude Sonnet 3.5 (Old)", + "created_at": "2024-06-20T00:00:00Z" + }, + { + "type": "model", + "id": "claude-3-haiku-20240307", + "display_name": "Claude Haiku 3", + "created_at": "2024-03-07T00:00:00Z" + }, + { + "type": "model", + "id": "claude-3-opus-20240229", + "display_name": "Claude Opus 3", + "created_at": "2024-02-29T00:00:00Z" + } + ], + "has_more": false, + "first_id": "claude-opus-4-1-20250805", + "last_id": "claude-3-opus-20240229" +} diff --git a/aibridge/fixtures/anthropic/haiku_simple.txtar b/aibridge/fixtures/anthropic/haiku_simple.txtar new file mode 100644 index 0000000000..c626c163f9 --- /dev/null +++ b/aibridge/fixtures/anthropic/haiku_simple.txtar @@ -0,0 +1,155 @@ +Simple request using a Haiku model (small/fast model). +Used to validate that prompts are captured for small/fast models like Haiku, +which Claude Code uses for ancillary tasks (e.g. generating session titles, +push notification summaries). + +-- request -- +{ + "max_tokens": 8192, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "how many angels can dance on the head of a pin\n" + } + ] + } + ], + "model": "claude-haiku-4-5", + "temperature": 1 +} + +-- streaming -- +event: message_start +data: {"type":"message_start","message":{"id":"msg_01Pvyf26bY17RcjmWfJsXGBn","type":"message","role":"assistant","model":"claude-haiku-4-5-20251001","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":18,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}} } + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"This is a classic philosophical question about medieval scholasticism. I'll give a thoughtful answer."}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""} } + +event: ping +data: {"type": "ping"} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"This"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" is a famous philosophical question often used to illustrate medieval"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" scholastic debates that seem pointless or ov"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"erly abstract. The question \"How many angels can dance on the head of"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" a pin?\" is typically cited as an example of us"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"eless speculation.\n\nHistorically, medieval theolog"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"ians did debate the nature of angels -"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" whether they were incorporeal beings, how"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" they occupied space, and whether multiple angels could exist"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" in the same location. However, there"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"'s little evidence they literally"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" debated dancing angels on pinheads.\n\nThe question has"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" no factual answer since it depends on assumptions about:"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"\n- The existence and nature of angels\n- Whether"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" incorporeal beings occupy physical space\n- What"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" constitutes \"dancing\" for a spiritual"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" entity\n- The size of both the"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" pin and the angels\n\nIt's become a metaph"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"or for overthinking trivial matters"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" or getting lost in theoretical discussions disconnected from practical reality."} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" Some use it to critique certain types of academic"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" or theological debate, while others defen"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"d the value of exploring fundamental questions about existence an"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"d metaphysics.\n\nSo while u"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"nanswerable literally, it serves as an interesting lens"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" for discussing the nature of philosophical inquiry itself."} } + +event: content_block_stop +data: {"type":"content_block_stop","index":1 } + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":240} } + +event: message_stop +data: {"type":"message_stop" } + +-- non-streaming -- +{ + "id": "msg_01Pvyf26bY17RcjmWfJsXGBn", + "type": "message", + "role": "assistant", + "model": "claude-haiku-4-5-20251001", + "content": [ + { + "type": "thinking", + "thinking": "This is a classic philosophical question about medieval scholasticism. I'll give a thoughtful answer." + }, + { + "type": "text", + "text": "This is a famous philosophical question, often called \"How many angels can dance on the head of a pin?\" It's typically used to represent pointless or overly abstract theological debates.\n\nThe question doesn't have a literal answer because:\n\n1. **Historical context**: It's often attributed to medieval scholastic philosophers, though there's little evidence they actually debated this exact question. It became a popular way to mock what some saw as useless academic arguments.\n\n2. **Philosophical purpose**: The question highlights the difficulty of discussing non-physical beings (angels) in physical terms (space on a pinhead).\n\n3. **Different interpretations**: \n - If angels are purely spiritual, they might not take up physical space at all\n - If they do occupy space, we'd need to know their \"size\"\n - The question might be asking about the nature of space, matter, and spirit\n\nSo the real answer is that it's not meant to be answered literally - it's a thought experiment about the limits of rational inquiry and the sometimes absurd directions theological speculation can take.\n\nWould you like to explore the philosophical implications behind this question, or were you thinking about it in a different context?" + } + ], + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 18, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 254, + "service_tier": "standard" + } +} diff --git a/aibridge/fixtures/anthropic/multi_thinking_builtin_tool.txtar b/aibridge/fixtures/anthropic/multi_thinking_builtin_tool.txtar new file mode 100644 index 0000000000..d27ad63fea --- /dev/null +++ b/aibridge/fixtures/anthropic/multi_thinking_builtin_tool.txtar @@ -0,0 +1,152 @@ +Claude Code has builtin tools to (e.g.) explore the filesystem. +This fixture has two thinking blocks before the tool_use block. + +-- request -- +{ + "model": "claude-sonnet-4-20250514", + "max_tokens": 1024, + "tools": [ + { + "name": "Read", + "description": "Read the contents of a file at the given path.", + "input_schema": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The absolute path to the file to read" + } + }, + "required": ["file_path"] + } + } + ], + "messages": [ + { + "role": "user", + "content": "read the foo file" + } + ] +} + +-- streaming -- +event: message_start +data: {"type":"message_start","message":{"id":"msg_015SQewixvT9s4cABCVvUE6g","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":22,"cache_read_input_tokens":13993,"output_tokens":5,"service_tier":"standard"}} } + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"The user wants me to read a file called \"foo\". Let me find and read it."}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"Eu8BCkYICxgCKkBR++kFr7Za2JhF/9OCpjEc46/EcipL75RK+MEbxJ/VBJPWQTWrNGfwb5khWYJtKEpjjkH07cR/MQvThfb7t7CkEgwU4pKwL7NuZXd1/wgaDILyd0bYMqQovWo3dyIw95Ny7yZPljNBDLsvMBdBr7w+RtbU+AlSftjBuBZHp0VzI54/W+9u6f7qfx0JXsVBKldqqOjFvewT8Xm6Qp/77g6/j0zBiuAQABj/6vS1qATjd8KSIFDg9G/tCtzwmV/T/egmzswWd5CBiAhW6lgJgEDRr+gRUrFSOB7o3hypW8FUnUrr1JtzzwMYAQ=="}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"thinking","thinking":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"thinking_delta","thinking":"I should use the Read tool to access the file contents."}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"signature_delta","signature":"Aa1BCkYICxgCKkBR++kFr7Za2JhF/9OCpjEc46/EcipL75RK+MEbxJ/VBJPWQTWrNGfwb5khWYJtKEpjjkH07cR/MQvThfb7t7CkEgwU4pKwL7NuZXd1/wgaDILyd0bYMqQovWo3dyIw95Ny7yZPljNBDLsvMBdBr7w+RtbU+AlSftjBuBZHp0VzI54/W+9u6f7qfx0JXsVBKldqqOjFvewT8Xm6Qp/77g6/j0zBiuAQABj/6vS1qATjd8KSIFDg9G/tCtzwmV/T/egmzswWd5CBiAhW6lgJgEDRr+gRUrFSOB7o3hypW8FUnUrr1JtzzwMYAQ=="}} + +event: content_block_stop +data: {"type":"content_block_stop","index":1} + +event: content_block_start +data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_01RX68weRSquLx6HUTj65iBo","name":"Read","input":{}}} + +event: ping +data: {"type": "ping"} + +event: content_block_delta +data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":""} } + +event: content_block_delta +data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"file_path\": \"/tmp/blah/foo"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\"}"} } + +event: content_block_stop +data: {"type":"content_block_stop","index":2 } + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":61} } + +event: message_stop +data: {"type":"message_stop" } + + +-- non-streaming -- +{ + "id": "msg_01JHKqEmh7wYuPXqUWUvusfL", + "container": { + "id": "", + "expires_at": "0001-01-01T00:00:00Z" + }, + "content": [ + { + "type": "thinking", + "thinking": "The user wants me to read a file called \"foo\". Let me find and read it.", + "signature": "Eu8BCkYICxgCKkBR++kFr7Za2JhF/9OCpjEc46/EcipL75RK+MEbxJ/VBJPWQTWrNGfwb5khWYJtKEpjjkH07cR/MQvThfb7t7CkEgwU4pKwL7NuZXd1/wgaDILyd0bYMqQovWo3dyIw95Ny7yZPljNBDLsvMBdBr7w+RtbU+AlSftjBuBZHp0VzI54/W+9u6f7qfx0JXsVBKldqqOjFvewT8Xm6Qp/77g6/j0zBiuAQABj/6vS1qATjd8KSIFDg9G/tCtzwmV/T/egmzswWd5CBiAhW6lgJgEDRr+gRUrFSOB7o3hypW8FUnUrr1JtzzwMYAQ==" + }, + { + "type": "thinking", + "thinking": "I should use the Read tool to access the file contents.", + "signature": "Aa1BCkYICxgCKkBR++kFr7Za2JhF/9OCpjEc46/EcipL75RK+MEbxJ/VBJPWQTWrNGfwb5khWYJtKEpjjkH07cR/MQvThfb7t7CkEgwU4pKwL7NuZXd1/wgaDILyd0bYMqQovWo3dyIw95Ny7yZPljNBDLsvMBdBr7w+RtbU+AlSftjBuBZHp0VzI54/W+9u6f7qfx0JXsVBKldqqOjFvewT8Xm6Qp/77g6/j0zBiuAQABj/6vS1qATjd8KSIFDg9G/tCtzwmV/T/egmzswWd5CBiAhW6lgJgEDRr+gRUrFSOB7o3hypW8FUnUrr1JtzzwMYAQ==" + }, + { + "citations": null, + "text": "", + "type": "tool_use", + "id": "toolu_01AusGgY5aKFhzWrFBv9JfHq", + "input": { + "file_path": "/tmp/blah/foo" + }, + "name": "Read", + "content": { + "OfWebSearchResultBlockArray": null, + "OfString": "", + "OfMCPToolResultBlockContent": null, + "error_code": "", + "type": "", + "content": null, + "return_code": 0, + "stderr": "", + "stdout": "" + }, + "tool_use_id": "", + "server_name": "", + "is_error": false, + "file_id": "", + "signature": "", + "thinking": "", + "data": "" + } + ], + "model": "claude-sonnet-4-20250514", + "role": "assistant", + "stop_reason": "tool_use", + "stop_sequence": "", + "type": "message", + "usage": { + "cache_creation": { + "ephemeral_1h_input_tokens": 0, + "ephemeral_5m_input_tokens": 0 + }, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 23490, + "input_tokens": 5, + "output_tokens": 84, + "server_tool_use": { + "web_search_requests": 0 + }, + "service_tier": "standard" + } +} + diff --git a/aibridge/fixtures/anthropic/non_stream_error.txtar b/aibridge/fixtures/anthropic/non_stream_error.txtar new file mode 100644 index 0000000000..76a9347911 --- /dev/null +++ b/aibridge/fixtures/anthropic/non_stream_error.txtar @@ -0,0 +1,35 @@ +Simple request + error which occurs before streaming begins (where applicable). + +-- request -- +{ + "max_tokens": 8192, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "yo" + } + ] + } + ], + "model": "claude-sonnet-4-0", + "temperature": 1 +} + +-- streaming -- +HTTP/2.0 400 Bad Request +Content-Length: 164 +Content-Type: application/json + +{"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long: 205429 tokens > 200000 maximum"},"request_id":"req_011CV5Jab6gR3ZNs9Sj6apiD"} + + +-- non-streaming -- +HTTP/2.0 400 Bad Request +Content-Length: 164 +Content-Type: application/json + +{"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long: 205429 tokens > 200000 maximum"},"request_id":"req_011CV5Jab6gR3ZNs9Sj6apiD"} + diff --git a/aibridge/fixtures/anthropic/simple.txtar b/aibridge/fixtures/anthropic/simple.txtar new file mode 100644 index 0000000000..235138cc46 --- /dev/null +++ b/aibridge/fixtures/anthropic/simple.txtar @@ -0,0 +1,152 @@ +Simple request. + +-- request -- +{ + "max_tokens": 8192, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "how many angels can dance on the head of a pin\n" + } + ] + } + ], + "model": "claude-sonnet-4-0", + "temperature": 1 +} + +-- streaming -- +event: message_start +data: {"type":"message_start","message":{"id":"msg_01Pvyf26bY17RcjmWfJsXGBn","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":18,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}} } + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"This is a classic philosophical question about medieval scholasticism. I'll give a thoughtful answer."}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""} } + +event: ping +data: {"type": "ping"} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"This"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" is a famous philosophical question often used to illustrate medieval"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" scholastic debates that seem pointless or ov"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"erly abstract. The question \"How many angels can dance on the head of"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" a pin?\" is typically cited as an example of us"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"eless speculation.\n\nHistorically, medieval theolog"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"ians did debate the nature of angels -"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" whether they were incorporeal beings, how"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" they occupied space, and whether multiple angels could exist"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" in the same location. However, there"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"'s little evidence they literally"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" debated dancing angels on pinheads.\n\nThe question has"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" no factual answer since it depends on assumptions about:"}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"\n- The existence and nature of angels\n- Whether"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" incorporeal beings occupy physical space\n- What"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" constitutes \"dancing\" for a spiritual"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" entity\n- The size of both the"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" pin and the angels\n\nIt's become a metaph"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"or for overthinking trivial matters"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" or getting lost in theoretical discussions disconnected from practical reality."} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" Some use it to critique certain types of academic"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" or theological debate, while others defen"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"d the value of exploring fundamental questions about existence an"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"d metaphysics.\n\nSo while u"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"nanswerable literally, it serves as an interesting lens"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":" for discussing the nature of philosophical inquiry itself."} } + +event: content_block_stop +data: {"type":"content_block_stop","index":1 } + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":240} } + +event: message_stop +data: {"type":"message_stop" } + +-- non-streaming -- +{ + "id": "msg_01Pvyf26bY17RcjmWfJsXGBn", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [ + { + "type": "thinking", + "thinking": "This is a classic philosophical question about medieval scholasticism. I'll give a thoughtful answer." + }, + { + "type": "text", + "text": "This is a famous philosophical question, often called \"How many angels can dance on the head of a pin?\" It's typically used to represent pointless or overly abstract theological debates.\n\nThe question doesn't have a literal answer because:\n\n1. **Historical context**: It's often attributed to medieval scholastic philosophers, though there's little evidence they actually debated this exact question. It became a popular way to mock what some saw as useless academic arguments.\n\n2. **Philosophical purpose**: The question highlights the difficulty of discussing non-physical beings (angels) in physical terms (space on a pinhead).\n\n3. **Different interpretations**: \n - If angels are purely spiritual, they might not take up physical space at all\n - If they do occupy space, we'd need to know their \"size\"\n - The question might be asking about the nature of space, matter, and spirit\n\nSo the real answer is that it's not meant to be answered literally - it's a thought experiment about the limits of rational inquiry and the sometimes absurd directions theological speculation can take.\n\nWould you like to explore the philosophical implications behind this question, or were you thinking about it in a different context?" + } + ], + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 18, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 254, + "service_tier": "standard" + } +} diff --git a/aibridge/fixtures/anthropic/simple_bedrock.txtar b/aibridge/fixtures/anthropic/simple_bedrock.txtar new file mode 100644 index 0000000000..459793810b --- /dev/null +++ b/aibridge/fixtures/anthropic/simple_bedrock.txtar @@ -0,0 +1,51 @@ +Simple Bedrock request. Tests that fields unsupported by Bedrock are removed +and adaptive thinking is converted to enabled with a budget. Includes all +bedrockUnsupportedFields (metadata, service_tier, container, inference_geo) +and beta-gated fields (output_config, context_management). + +-- request -- +{ + "model": "claude-sonnet-4-6", + "max_tokens": 32000, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Hello." + } + ] + } + ], + "thinking": {"type": "adaptive"}, + "metadata": {"user_id": "session_abc123"}, + "service_tier": "auto", + "container": {"type": "ephemeral"}, + "inference_geo": {"allow": ["us"]}, + "output_config": {"effort": "medium"}, + "context_management": {"edits": [{"type": "clear_thinking_20251015", "keep": "all"}]}, + "stream": true +} + +-- streaming -- +event: message_start +data: {"type":"message_start","message":{"id":"msg_bdrk_01Test","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":4}}} + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello! How can I help?"}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":10}} + +event: message_stop +data: {"type":"message_stop"} + +-- non-streaming -- +{"id":"msg_bdrk_01Test","type":"message","role":"assistant","model":"claude-sonnet-4-5-20250929","content":[{"type":"text","text":"Hello! How can I help?"}],"stop_reason":"end_turn","stop_sequence":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":10}} diff --git a/aibridge/fixtures/anthropic/single_builtin_tool.txtar b/aibridge/fixtures/anthropic/single_builtin_tool.txtar new file mode 100644 index 0000000000..c271cb7cc2 --- /dev/null +++ b/aibridge/fixtures/anthropic/single_builtin_tool.txtar @@ -0,0 +1,181 @@ +Claude Code has builtin tools to (e.g.) explore the filesystem. + +-- request -- +{ + "model": "claude-sonnet-4-20250514", + "max_tokens": 1024, + "tools": [ + { + "name": "Read", + "description": "Read the contents of a file at the given path.", + "input_schema": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The absolute path to the file to read" + } + }, + "required": ["file_path"] + } + } + ], + "messages": [ + { + "role": "user", + "content": "read the foo file" + } + ] +} + +-- streaming -- +event: message_start +data: {"type":"message_start","message":{"id":"msg_015SQewixvT9s4cABCVvUE6g","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":22,"cache_read_input_tokens":13993,"output_tokens":5,"service_tier":"standard"}} } + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"The user wants me to read"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" a"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" file called \""} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"foo\"."} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" Let me find"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" and"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":" read it."} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"Eu8BCkYICxgCKkBR++kFr7Za2JhF/9OCpjEc46/EcipL75RK+MEbxJ/VBJPWQTWrNGfwb5khWYJtKEpjjkH07cR/MQvThfb7t7CkEgwU4pKwL7NuZXd1/wgaDILyd0bYMqQovWo3dyIw95Ny7yZPljNBDLsvMBdBr7w+RtbU+AlSftjBuBZHp0VzI54/W+9u6f7qfx0JXsVBKldqqOjFvewT8Xm6Qp/77g6/j0zBiuAQABj/6vS1qATjd8KSIFDg9G/tCtzwmV/T/egmzswWd5CBiAhW6lgJgEDRr+gRUrFSOB7o3hypW8FUnUrr1JtzzwMYAQ=="}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01RX68weRSquLx6HUTj65iBo","name":"Read","input":{}}} + +event: ping +data: {"type": "ping"} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"file_path\": \"/tmp/blah/foo"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"}"} } + +event: content_block_stop +data: {"type":"content_block_stop","index":1 } + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":61} } + +event: message_stop +data: {"type":"message_stop" } + + +-- non-streaming -- +{ + "id": "msg_01JHKqEmh7wYuPXqUWUvusfL", + "container": { + "id": "", + "expires_at": "0001-01-01T00:00:00Z" + }, + "content": [ + { + "type": "thinking", + "thinking": "The user wants me to read a file called \"foo\". Let me find and read it.", + "signature": "Eu8BCkYICxgCKkBR++kFr7Za2JhF/9OCpjEc46/EcipL75RK+MEbxJ/VBJPWQTWrNGfwb5khWYJtKEpjjkH07cR/MQvThfb7t7CkEgwU4pKwL7NuZXd1/wgaDILyd0bYMqQovWo3dyIw95Ny7yZPljNBDLsvMBdBr7w+RtbU+AlSftjBuBZHp0VzI54/W+9u6f7qfx0JXsVBKldqqOjFvewT8Xm6Qp/77g6/j0zBiuAQABj/6vS1qATjd8KSIFDg9G/tCtzwmV/T/egmzswWd5CBiAhW6lgJgEDRr+gRUrFSOB7o3hypW8FUnUrr1JtzzwMYAQ==" + }, + { + "citations": null, + "text": "I can see there's a file named `foo` in the `/tmp/blah` directory. Let me read it.", + "type": "text", + "id": "", + "input": null, + "name": "", + "content": { + "OfWebSearchResultBlockArray": null, + "OfString": "", + "OfMCPToolResultBlockContent": null, + "error_code": "", + "type": "", + "content": null, + "return_code": 0, + "stderr": "", + "stdout": "" + }, + "tool_use_id": "", + "server_name": "", + "is_error": false, + "file_id": "", + "signature": "", + "thinking": "", + "data": "" + }, + { + "citations": null, + "text": "", + "type": "tool_use", + "id": "toolu_01AusGgY5aKFhzWrFBv9JfHq", + "input": { + "file_path": "/tmp/blah/foo" + }, + "name": "Read", + "content": { + "OfWebSearchResultBlockArray": null, + "OfString": "", + "OfMCPToolResultBlockContent": null, + "error_code": "", + "type": "", + "content": null, + "return_code": 0, + "stderr": "", + "stdout": "" + }, + "tool_use_id": "", + "server_name": "", + "is_error": false, + "file_id": "", + "signature": "", + "thinking": "", + "data": "" + } + ], + "model": "claude-sonnet-4-20250514", + "role": "assistant", + "stop_reason": "tool_use", + "stop_sequence": "", + "type": "message", + "usage": { + "cache_creation": { + "ephemeral_1h_input_tokens": 0, + "ephemeral_5m_input_tokens": 0 + }, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 23490, + "input_tokens": 5, + "output_tokens": 84, + "server_tool_use": { + "web_search_requests": 0 + }, + "service_tier": "standard" + } +} + diff --git a/aibridge/fixtures/anthropic/single_builtin_tool_parallel.txtar b/aibridge/fixtures/anthropic/single_builtin_tool_parallel.txtar new file mode 100644 index 0000000000..9c53ed2cd4 --- /dev/null +++ b/aibridge/fixtures/anthropic/single_builtin_tool_parallel.txtar @@ -0,0 +1,175 @@ +Claude Code has builtin tools to (e.g.) explore the filesystem. +This fixture has a single thinking block followed by two parallel tool_use blocks. +The thinking should only be attributed to the first tool_use. + +-- request -- +{ + "model": "claude-sonnet-4-20250514", + "max_tokens": 1024, + "tools": [ + { + "name": "Read", + "description": "Read the contents of a file at the given path.", + "input_schema": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "The absolute path to the file to read" + } + }, + "required": ["file_path"] + } + } + ], + "messages": [ + { + "role": "user", + "content": "read the foo and bar files" + } + ] +} + +-- streaming -- +event: message_start +data: {"type":"message_start","message":{"id":"msg_01ParallelToolStream","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":2,"cache_creation_input_tokens":22,"cache_read_input_tokens":13993,"output_tokens":5,"service_tier":"standard"}} } + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"The user wants me to read two files: \"foo\" and \"bar\". I'll read both of them."}} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"Eu8BCkYICxgCKkBR++kFr7Za2JhF/9OCpjEc46/EcipL75RK+MEbxJ/VBJPWQTWrNGfwb5khWYJtKEpjjkH07cR/MQvThfb7t7CkEgwU4pKwL7NuZXd1/wgaDILyd0bYMqQovWo3dyIw95Ny7yZPljNBDLsvMBdBr7w+RtbU+AlSftjBuBZHp0VzI54/W+9u6f7qfx0JXsVBKldqqOjFvewT8Xm6Qp/77g6/j0zBiuAQABj/6vS1qATjd8KSIFDg9G/tCtzwmV/T/egmzswWd5CBiAhW6lgJgEDRr+gRUrFSOB7o3hypW8FUnUrr1JtzzwMYAQ=="}} + +event: content_block_stop +data: {"type":"content_block_stop","index":0} + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01ParallelFirst000000000","name":"Read","input":{}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"file_path\": \"/tmp/blah/foo"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"\"}"} } + +event: content_block_stop +data: {"type":"content_block_stop","index":1 } + +event: content_block_start +data: {"type":"content_block_start","index":2,"content_block":{"type":"tool_use","id":"toolu_01ParallelSecond00000000","name":"Read","input":{}}} + +event: content_block_delta +data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":""} } + +event: content_block_delta +data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"file_path\": \"/tmp/blah/bar"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\"}"} } + +event: content_block_stop +data: {"type":"content_block_stop","index":2 } + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":72} } + +event: message_stop +data: {"type":"message_stop" } + + +-- non-streaming -- +{ + "id": "msg_01ParallelToolBlocking", + "container": { + "id": "", + "expires_at": "0001-01-01T00:00:00Z" + }, + "content": [ + { + "type": "thinking", + "thinking": "The user wants me to read two files: \"foo\" and \"bar\". I'll read both of them.", + "signature": "Eu8BCkYICxgCKkBR++kFr7Za2JhF/9OCpjEc46/EcipL75RK+MEbxJ/VBJPWQTWrNGfwb5khWYJtKEpjjkH07cR/MQvThfb7t7CkEgwU4pKwL7NuZXd1/wgaDILyd0bYMqQovWo3dyIw95Ny7yZPljNBDLsvMBdBr7w+RtbU+AlSftjBuBZHp0VzI54/W+9u6f7qfx0JXsVBKldqqOjFvewT8Xm6Qp/77g6/j0zBiuAQABj/6vS1qATjd8KSIFDg9G/tCtzwmV/T/egmzswWd5CBiAhW6lgJgEDRr+gRUrFSOB7o3hypW8FUnUrr1JtzzwMYAQ==" + }, + { + "citations": null, + "text": "", + "type": "tool_use", + "id": "toolu_01ParallelBlockFirst0000", + "input": { + "file_path": "/tmp/blah/foo" + }, + "name": "Read", + "content": { + "OfWebSearchResultBlockArray": null, + "OfString": "", + "OfMCPToolResultBlockContent": null, + "error_code": "", + "type": "", + "content": null, + "return_code": 0, + "stderr": "", + "stdout": "" + }, + "tool_use_id": "", + "server_name": "", + "is_error": false, + "file_id": "", + "signature": "", + "thinking": "", + "data": "" + }, + { + "citations": null, + "text": "", + "type": "tool_use", + "id": "toolu_01ParallelBlockSecond000", + "input": { + "file_path": "/tmp/blah/bar" + }, + "name": "Read", + "content": { + "OfWebSearchResultBlockArray": null, + "OfString": "", + "OfMCPToolResultBlockContent": null, + "error_code": "", + "type": "", + "content": null, + "return_code": 0, + "stderr": "", + "stdout": "" + }, + "tool_use_id": "", + "server_name": "", + "is_error": false, + "file_id": "", + "signature": "", + "thinking": "", + "data": "" + } + ], + "model": "claude-sonnet-4-20250514", + "role": "assistant", + "stop_reason": "tool_use", + "stop_sequence": "", + "type": "message", + "usage": { + "cache_creation": { + "ephemeral_1h_input_tokens": 0, + "ephemeral_5m_input_tokens": 0 + }, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 23490, + "input_tokens": 5, + "output_tokens": 95, + "server_tool_use": { + "web_search_requests": 0 + }, + "service_tier": "standard" + } +} diff --git a/aibridge/fixtures/anthropic/single_injected_tool.txtar b/aibridge/fixtures/anthropic/single_injected_tool.txtar new file mode 100644 index 0000000000..a37038db61 --- /dev/null +++ b/aibridge/fixtures/anthropic/single_injected_tool.txtar @@ -0,0 +1,163 @@ +Coder MCP tools automatically injected. + +-- request -- +{ + "model": "claude-sonnet-4-20250514", + "max_tokens": 1024, + "messages": [ + { + "role": "user", + "content": "list coder workspace IDs for admin" + } + ] +} + +-- streaming -- +event: message_start +data: {"type":"message_start","message":{"id":"msg_01JWGa2JHsKBHL28Cjr2dvPK","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":7545,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}} } + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} } + +event: ping +data: {"type": "ping"} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"I'll list the work"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"spaces for the admin user to get their"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" workspace IDs."} } + +event: content_block_stop +data: {"type":"content_block_stop","index":0 } + +event: content_block_start +data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01TSQLR6R6wBUqoxGPjQKDAj","name":"bmcp_coder_coder_list_workspaces","input":{}} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":""} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"owner\""} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":": \"ad"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"min\"}"} } + +event: content_block_stop +data: {"type":"content_block_stop","index":1 } + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"output_tokens":74}} + +event: message_stop +data: {"type":"message_stop" } + + +-- streaming/tool-call -- +event: message_start +data: {"type":"message_start","message":{"id":"msg_01LZSVzMCLivzXrp6ZnTcmeG","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":7763,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}} } + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} } + +event: ping +data: {"type": "ping"} + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Here"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" are the workspace IDs for the admin user:\n\n**"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Admin's Workspaces:**\n- Workspace ID: `dd711d5c-83c"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"6-4c08-a0af-b73055906e8"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"c`\n - Name: `bob`\n - Template: `docker"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"`\n - Template ID: `b3a9d9b4-486a-4"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"f21-8884-d81d5dbdd837`"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"\n\nThe admin user currently has 1 workspace named \"bob\" created from"} } + +event: content_block_delta +data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":" the \"docker\" template."} } + +event: content_block_stop +data: {"type":"content_block_stop","index":0 } + +event: message_delta +data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":128} } + +event: message_stop +data: {"type":"message_stop" } + + +-- non-streaming -- +{ + "id": "msg_01FwkWU26guw9EwkL8zeacPL", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [ + { + "type": "text", + "text": "I'll list the workspaces for the admin user to get their workspace IDs." + }, + { + "type": "tool_use", + "id": "toolu_01QjNz5b3HxAqAccTVnSMsKP", + "name": "bmcp_coder_coder_list_workspaces", + "input": { + "owner": "admin" + } + } + ], + "stop_reason": "tool_use", + "stop_sequence": null, + "usage": { + "input_tokens": 7545, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 75, + "service_tier": "standard" + } +} + + +-- non-streaming/tool-call -- +{ + "id": "msg_01Sr5BnPSwodTo8Df4XvUBg5", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-20250514", + "content": [ + { + "type": "text", + "text": "Here are the Coder workspace IDs for the admin user:\n\n**Workspace ID:** `dd711d5c-83c6-4c08-a0af-b73055906e8c`\n- **Name:** bob\n- **Template:** docker\n- **Template ID:** b3a9d9b4-486a-4f21-8884-d81d5dbdd837\n- **Status:** Up to date (not outdated)\n\nThe admin user currently has 1 workspace named \"bob\" running on the \"docker\" template." + } + ], + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 7763, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 129, + "service_tier": "standard" + } +} + diff --git a/aibridge/fixtures/anthropic/stream_error.txtar b/aibridge/fixtures/anthropic/stream_error.txtar new file mode 100644 index 0000000000..8b63444972 --- /dev/null +++ b/aibridge/fixtures/anthropic/stream_error.txtar @@ -0,0 +1,34 @@ +Simple request + error. + +-- request -- +{ + "max_tokens": 8192, + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "yo" + } + ] + } + ], + "model": "claude-sonnet-4-0", + "temperature": 1, + "stream": true +} + +-- streaming -- +event: message_start +data: {"type":"message_start","message":{"id":"msg_01Pvyf26bY17RcjmWfJsXGBn","type":"message","role":"assistant","model":"claude-sonnet-4-20250514","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":18,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":1,"service_tier":"standard"}} } + +event: content_block_start +data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""} } + +event: ping +data: {"type": "ping"} + +event: error +data: {"type": "error", "error": {"type": "api_error", "message": "Overloaded"}} + diff --git a/aibridge/fixtures/fixtures.go b/aibridge/fixtures/fixtures.go new file mode 100644 index 0000000000..c731e0fb9c --- /dev/null +++ b/aibridge/fixtures/fixtures.go @@ -0,0 +1,247 @@ +package fixtures + +import ( + _ "embed" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/tools/txtar" +) + +var ( + //go:embed anthropic/simple.txtar + AntSimple []byte + + //go:embed anthropic/single_builtin_tool.txtar + AntSingleBuiltinTool []byte + + //go:embed anthropic/multi_thinking_builtin_tool.txtar + AntMultiThinkingBuiltinTool []byte + + //go:embed anthropic/single_builtin_tool_parallel.txtar + AntSingleBuiltinToolParallel []byte + + //go:embed anthropic/single_injected_tool.txtar + AntSingleInjectedTool []byte + + //go:embed anthropic/fallthrough.txtar + AntFallthrough []byte + + //go:embed anthropic/stream_error.txtar + AntMidStreamError []byte + + //go:embed anthropic/non_stream_error.txtar + AntNonStreamError []byte + + //go:embed anthropic/simple_bedrock.txtar + AntSimpleBedrock []byte + + //go:embed anthropic/haiku_simple.txtar + AntHaikuSimple []byte +) + +var ( + //go:embed openai/chatcompletions/simple.txtar + OaiChatSimple []byte + + //go:embed openai/chatcompletions/single_builtin_tool.txtar + OaiChatSingleBuiltinTool []byte + + //go:embed openai/chatcompletions/single_injected_tool.txtar + OaiChatSingleInjectedTool []byte + + //go:embed openai/chatcompletions/fallthrough.txtar + OaiChatFallthrough []byte + + //go:embed openai/chatcompletions/stream_error.txtar + OaiChatMidStreamError []byte + + //go:embed openai/chatcompletions/non_stream_error.txtar + OaiChatNonStreamError []byte + + //go:embed openai/chatcompletions/streaming_injected_tool_no_preamble.txtar + OaiChatStreamingInjectedToolNoPreamble []byte + + //go:embed openai/chatcompletions/streaming_injected_tool_nonzero_index.txtar + OaiChatStreamingInjectedToolNonzeroIndex []byte +) + +var ( + //go:embed openai/responses/blocking/simple.txtar + OaiResponsesBlockingSimple []byte + + //go:embed openai/responses/blocking/single_builtin_tool.txtar + OaiResponsesBlockingSingleBuiltinTool []byte + + //go:embed openai/responses/blocking/multi_reasoning_builtin_tool.txtar + OaiResponsesBlockingMultiReasoningBuiltinTool []byte + + //go:embed openai/responses/blocking/commentary_builtin_tool.txtar + OaiResponsesBlockingCommentaryBuiltinTool []byte + + //go:embed openai/responses/blocking/summary_and_commentary_builtin_tool.txtar + OaiResponsesBlockingSummaryAndCommentaryBuiltinTool []byte + + //go:embed openai/responses/blocking/cached_input_tokens.txtar + OaiResponsesBlockingCachedInputTokens []byte + + //go:embed openai/responses/blocking/custom_tool.txtar + OaiResponsesBlockingCustomTool []byte + + //go:embed openai/responses/blocking/conversation.txtar + OaiResponsesBlockingConversation []byte + + //go:embed openai/responses/blocking/http_error.txtar + OaiResponsesBlockingHTTPErr []byte + + //go:embed openai/responses/blocking/prev_response_id.txtar + OaiResponsesBlockingPrevResponseID []byte + + //go:embed openai/responses/blocking/single_builtin_tool_parallel.txtar + OaiResponsesBlockingSingleBuiltinToolParallel []byte + + //go:embed openai/responses/blocking/single_injected_tool.txtar + OaiResponsesBlockingSingleInjectedTool []byte + + //go:embed openai/responses/blocking/single_injected_tool_error.txtar + OaiResponsesBlockingSingleInjectedToolError []byte + + //go:embed openai/responses/blocking/wrong_response_format.txtar + OaiResponsesBlockingWrongResponseFormat []byte +) + +var ( + //go:embed openai/responses/streaming/simple.txtar + OaiResponsesStreamingSimple []byte + + //go:embed openai/responses/streaming/codex_example.txtar + OaiResponsesStreamingCodex []byte + + //go:embed openai/responses/streaming/builtin_tool.txtar + OaiResponsesStreamingBuiltinTool []byte + + //go:embed openai/responses/streaming/multi_reasoning_builtin_tool.txtar + OaiResponsesStreamingMultiReasoningBuiltinTool []byte + + //go:embed openai/responses/streaming/commentary_builtin_tool.txtar + OaiResponsesStreamingCommentaryBuiltinTool []byte + + //go:embed openai/responses/streaming/summary_and_commentary_builtin_tool.txtar + OaiResponsesStreamingSummaryAndCommentaryBuiltinTool []byte + + //go:embed openai/responses/streaming/cached_input_tokens.txtar + OaiResponsesStreamingCachedInputTokens []byte + + //go:embed openai/responses/streaming/custom_tool.txtar + OaiResponsesStreamingCustomTool []byte + + //go:embed openai/responses/streaming/conversation.txtar + OaiResponsesStreamingConversation []byte + + //go:embed openai/responses/streaming/http_error.txtar + OaiResponsesStreamingHTTPErr []byte + + //go:embed openai/responses/streaming/prev_response_id.txtar + OaiResponsesStreamingPrevResponseID []byte + + //go:embed openai/responses/streaming/single_builtin_tool_parallel.txtar + OaiResponsesStreamingSingleBuiltinToolParallel []byte + + //go:embed openai/responses/streaming/single_injected_tool.txtar + OaiResponsesStreamingSingleInjectedTool []byte + + //go:embed openai/responses/streaming/single_injected_tool_error.txtar + OaiResponsesStreamingSingleInjectedToolError []byte + + //go:embed openai/responses/streaming/stream_error.txtar + OaiResponsesStreamingStreamError []byte + + //go:embed openai/responses/streaming/stream_failure.txtar + OaiResponsesStreamingStreamFailure []byte + + //go:embed openai/responses/streaming/wrong_response_format.txtar + OaiResponsesStreamingWrongResponseFormat []byte +) + +// Section name constants matching the file names used in txtar fixtures. +const ( + fileRequest = "request" + fileStreamingResponse = "streaming" + fileNonStreamingResponse = "non-streaming" + fileStreamingToolCall = "streaming/tool-call" + fileNonStreamingToolCall = "non-streaming/tool-call" + + // Exported aliases so callers can check [Fixture.Has] before calling a + // getter that would otherwise fail the test. + SectionStreaming = fileStreamingResponse + SectionNonStreaming = fileNonStreamingResponse + SectionStreamingToolCall = fileStreamingToolCall + SectionNonStreamToolCall = fileNonStreamingToolCall +) + +// Fixture holds the named sections of a parsed txtar test fixture. +type Fixture struct { + sections map[string][]byte + t *testing.T +} + +// Has reports whether the fixture contains the named section. +func (f Fixture) Has(name string) bool { + _, ok := f.sections[name] + return ok +} + +func (f Fixture) Request() []byte { + f.t.Helper() + v, ok := f.sections[fileRequest] + require.True(f.t, ok, "fixture archive missing %q section", fileRequest) + return v +} + +func (f Fixture) Streaming() []byte { + f.t.Helper() + v, ok := f.sections[fileStreamingResponse] + require.True(f.t, ok, "fixture archive missing %q section", fileStreamingResponse) + return v +} + +func (f Fixture) NonStreaming() []byte { + f.t.Helper() + v, ok := f.sections[fileNonStreamingResponse] + require.True(f.t, ok, "fixture archive missing %q section", fileNonStreamingResponse) + return v +} + +func (f Fixture) StreamingToolCall() []byte { + f.t.Helper() + v, ok := f.sections[fileStreamingToolCall] + require.True(f.t, ok, "fixture archive missing %q section", fileStreamingToolCall) + return v +} + +func (f Fixture) NonStreamingToolCall() []byte { + f.t.Helper() + v, ok := f.sections[fileNonStreamingToolCall] + require.True(f.t, ok, "fixture archive missing %q section", fileNonStreamingToolCall) + return v +} + +// Parse parses raw txtar data into a [Fixture]. +func Parse(t *testing.T, data []byte) Fixture { + t.Helper() + + archive := txtar.Parse(data) + require.NotEmpty(t, archive.Files, "fixture archive has no files") + + sections := make(map[string][]byte, len(archive.Files)) + for _, f := range archive.Files { + sections[f.Name] = f.Data + } + return Fixture{sections: sections, t: t} +} + +// Request extracts the "request" fixture from raw txtar data. +func Request(t *testing.T, fixture []byte) []byte { + t.Helper() + return Parse(t, fixture).Request() +} diff --git a/aibridge/fixtures/openai/chatcompletions/fallthrough.txtar b/aibridge/fixtures/openai/chatcompletions/fallthrough.txtar new file mode 100644 index 0000000000..41bcf349d3 --- /dev/null +++ b/aibridge/fixtures/openai/chatcompletions/fallthrough.txtar @@ -0,0 +1,524 @@ +API endpoints not explicitly handled will fallthrough to upstream via reverse-proxy. + +-- non-streaming -- +{ + "object": "list", + "data": [ + { + "id": "gpt-4-0613", + "object": "model", + "created": 1686588896, + "owned_by": "openai" + }, + { + "id": "gpt-4", + "object": "model", + "created": 1687882411, + "owned_by": "openai" + }, + { + "id": "gpt-3.5-turbo", + "object": "model", + "created": 1677610602, + "owned_by": "openai" + }, + { + "id": "gpt-5-nano", + "object": "model", + "created": 1754426384, + "owned_by": "system" + }, + { + "id": "gpt-5", + "object": "model", + "created": 1754425777, + "owned_by": "system" + }, + { + "id": "gpt-5-mini-2025-08-07", + "object": "model", + "created": 1754425867, + "owned_by": "system" + }, + { + "id": "gpt-5-mini", + "object": "model", + "created": 1754425928, + "owned_by": "system" + }, + { + "id": "gpt-5-nano-2025-08-07", + "object": "model", + "created": 1754426303, + "owned_by": "system" + }, + { + "id": "davinci-002", + "object": "model", + "created": 1692634301, + "owned_by": "system" + }, + { + "id": "babbage-002", + "object": "model", + "created": 1692634615, + "owned_by": "system" + }, + { + "id": "gpt-3.5-turbo-instruct", + "object": "model", + "created": 1692901427, + "owned_by": "system" + }, + { + "id": "gpt-3.5-turbo-instruct-0914", + "object": "model", + "created": 1694122472, + "owned_by": "system" + }, + { + "id": "dall-e-3", + "object": "model", + "created": 1698785189, + "owned_by": "system" + }, + { + "id": "dall-e-2", + "object": "model", + "created": 1698798177, + "owned_by": "system" + }, + { + "id": "gpt-4-1106-preview", + "object": "model", + "created": 1698957206, + "owned_by": "system" + }, + { + "id": "gpt-3.5-turbo-1106", + "object": "model", + "created": 1698959748, + "owned_by": "system" + }, + { + "id": "tts-1-hd", + "object": "model", + "created": 1699046015, + "owned_by": "system" + }, + { + "id": "tts-1-1106", + "object": "model", + "created": 1699053241, + "owned_by": "system" + }, + { + "id": "tts-1-hd-1106", + "object": "model", + "created": 1699053533, + "owned_by": "system" + }, + { + "id": "text-embedding-3-small", + "object": "model", + "created": 1705948997, + "owned_by": "system" + }, + { + "id": "text-embedding-3-large", + "object": "model", + "created": 1705953180, + "owned_by": "system" + }, + { + "id": "gpt-4-0125-preview", + "object": "model", + "created": 1706037612, + "owned_by": "system" + }, + { + "id": "gpt-4-turbo-preview", + "object": "model", + "created": 1706037777, + "owned_by": "system" + }, + { + "id": "gpt-3.5-turbo-0125", + "object": "model", + "created": 1706048358, + "owned_by": "system" + }, + { + "id": "gpt-4-turbo", + "object": "model", + "created": 1712361441, + "owned_by": "system" + }, + { + "id": "gpt-4-turbo-2024-04-09", + "object": "model", + "created": 1712601677, + "owned_by": "system" + }, + { + "id": "gpt-4o", + "object": "model", + "created": 1715367049, + "owned_by": "system" + }, + { + "id": "gpt-4o-2024-05-13", + "object": "model", + "created": 1715368132, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-2024-07-18", + "object": "model", + "created": 1721172717, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini", + "object": "model", + "created": 1721172741, + "owned_by": "system" + }, + { + "id": "gpt-4o-2024-08-06", + "object": "model", + "created": 1722814719, + "owned_by": "system" + }, + { + "id": "chatgpt-4o-latest", + "object": "model", + "created": 1723515131, + "owned_by": "system" + }, + { + "id": "o1-mini-2024-09-12", + "object": "model", + "created": 1725648979, + "owned_by": "system" + }, + { + "id": "o1-mini", + "object": "model", + "created": 1725649008, + "owned_by": "system" + }, + { + "id": "gpt-4o-realtime-preview-2024-10-01", + "object": "model", + "created": 1727131766, + "owned_by": "system" + }, + { + "id": "gpt-4o-audio-preview-2024-10-01", + "object": "model", + "created": 1727389042, + "owned_by": "system" + }, + { + "id": "gpt-4o-audio-preview", + "object": "model", + "created": 1727460443, + "owned_by": "system" + }, + { + "id": "gpt-4o-realtime-preview", + "object": "model", + "created": 1727659998, + "owned_by": "system" + }, + { + "id": "omni-moderation-latest", + "object": "model", + "created": 1731689265, + "owned_by": "system" + }, + { + "id": "omni-moderation-2024-09-26", + "object": "model", + "created": 1732734466, + "owned_by": "system" + }, + { + "id": "gpt-4o-realtime-preview-2024-12-17", + "object": "model", + "created": 1733945430, + "owned_by": "system" + }, + { + "id": "gpt-4o-audio-preview-2024-12-17", + "object": "model", + "created": 1734034239, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-realtime-preview-2024-12-17", + "object": "model", + "created": 1734112601, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-audio-preview-2024-12-17", + "object": "model", + "created": 1734115920, + "owned_by": "system" + }, + { + "id": "o1-2024-12-17", + "object": "model", + "created": 1734326976, + "owned_by": "system" + }, + { + "id": "o1", + "object": "model", + "created": 1734375816, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-realtime-preview", + "object": "model", + "created": 1734387380, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-audio-preview", + "object": "model", + "created": 1734387424, + "owned_by": "system" + }, + { + "id": "o3-mini", + "object": "model", + "created": 1737146383, + "owned_by": "system" + }, + { + "id": "o3-mini-2025-01-31", + "object": "model", + "created": 1738010200, + "owned_by": "system" + }, + { + "id": "gpt-4o-2024-11-20", + "object": "model", + "created": 1739331543, + "owned_by": "system" + }, + { + "id": "gpt-4o-search-preview-2025-03-11", + "object": "model", + "created": 1741388170, + "owned_by": "system" + }, + { + "id": "gpt-4o-search-preview", + "object": "model", + "created": 1741388720, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-search-preview-2025-03-11", + "object": "model", + "created": 1741390858, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-search-preview", + "object": "model", + "created": 1741391161, + "owned_by": "system" + }, + { + "id": "gpt-4o-transcribe", + "object": "model", + "created": 1742068463, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-transcribe", + "object": "model", + "created": 1742068596, + "owned_by": "system" + }, + { + "id": "o1-pro-2025-03-19", + "object": "model", + "created": 1742251504, + "owned_by": "system" + }, + { + "id": "o1-pro", + "object": "model", + "created": 1742251791, + "owned_by": "system" + }, + { + "id": "gpt-4o-mini-tts", + "object": "model", + "created": 1742403959, + "owned_by": "system" + }, + { + "id": "o3-2025-04-16", + "object": "model", + "created": 1744133301, + "owned_by": "system" + }, + { + "id": "o4-mini-2025-04-16", + "object": "model", + "created": 1744133506, + "owned_by": "system" + }, + { + "id": "o3", + "object": "model", + "created": 1744225308, + "owned_by": "system" + }, + { + "id": "o4-mini", + "object": "model", + "created": 1744225351, + "owned_by": "system" + }, + { + "id": "gpt-4.1-2025-04-14", + "object": "model", + "created": 1744315746, + "owned_by": "system" + }, + { + "id": "gpt-4.1", + "object": "model", + "created": 1744316542, + "owned_by": "system" + }, + { + "id": "gpt-4.1-mini-2025-04-14", + "object": "model", + "created": 1744317547, + "owned_by": "system" + }, + { + "id": "gpt-4.1-mini", + "object": "model", + "created": 1744318173, + "owned_by": "system" + }, + { + "id": "gpt-4.1-nano-2025-04-14", + "object": "model", + "created": 1744321025, + "owned_by": "system" + }, + { + "id": "gpt-4.1-nano", + "object": "model", + "created": 1744321707, + "owned_by": "system" + }, + { + "id": "gpt-image-1", + "object": "model", + "created": 1745517030, + "owned_by": "system" + }, + { + "id": "codex-mini-latest", + "object": "model", + "created": 1746673257, + "owned_by": "system" + }, + { + "id": "o3-pro", + "object": "model", + "created": 1748475349, + "owned_by": "system" + }, + { + "id": "gpt-4o-realtime-preview-2025-06-03", + "object": "model", + "created": 1748907838, + "owned_by": "system" + }, + { + "id": "gpt-4o-audio-preview-2025-06-03", + "object": "model", + "created": 1748908498, + "owned_by": "system" + }, + { + "id": "o3-pro-2025-06-10", + "object": "model", + "created": 1749166761, + "owned_by": "system" + }, + { + "id": "o4-mini-deep-research", + "object": "model", + "created": 1749685485, + "owned_by": "system" + }, + { + "id": "o3-deep-research", + "object": "model", + "created": 1749840121, + "owned_by": "system" + }, + { + "id": "o3-deep-research-2025-06-26", + "object": "model", + "created": 1750865219, + "owned_by": "system" + }, + { + "id": "o4-mini-deep-research-2025-06-26", + "object": "model", + "created": 1750866121, + "owned_by": "system" + }, + { + "id": "gpt-5-chat-latest", + "object": "model", + "created": 1754073306, + "owned_by": "system" + }, + { + "id": "gpt-5-2025-08-07", + "object": "model", + "created": 1754075360, + "owned_by": "system" + }, + { + "id": "gpt-3.5-turbo-16k", + "object": "model", + "created": 1683758102, + "owned_by": "openai-internal" + }, + { + "id": "tts-1", + "object": "model", + "created": 1681940951, + "owned_by": "openai-internal" + }, + { + "id": "whisper-1", + "object": "model", + "created": 1677532384, + "owned_by": "openai-internal" + }, + { + "id": "text-embedding-ada-002", + "object": "model", + "created": 1671217299, + "owned_by": "openai-internal" + } + ] +} diff --git a/aibridge/fixtures/openai/chatcompletions/non_stream_error.txtar b/aibridge/fixtures/openai/chatcompletions/non_stream_error.txtar new file mode 100644 index 0000000000..e84ce09201 --- /dev/null +++ b/aibridge/fixtures/openai/chatcompletions/non_stream_error.txtar @@ -0,0 +1,43 @@ +Simple request + error which occurs before streaming begins (where applicable). + +-- request -- +{ + "messages": [ + { + "role": "user", + "content": "how many angels can dance on the head of a pin\n" + } + ], + "model": "gpt-4.1", + "stream": true +} + +-- streaming -- +HTTP/2.0 400 Bad Request +Content-Length: 281 +Content-Type: application/json + +{ + "error": { + "message": "Input tokens exceed the configured limit of 272000 tokens. Your messages resulted in 3148588 tokens. Please reduce the length of the messages.", + "type": "invalid_request_error", + "param": "messages", + "code": "context_length_exceeded" + } +} + + +-- non-streaming -- +HTTP/2.0 400 Bad Request +Content-Length: 281 +Content-Type: application/json + +{ + "error": { + "message": "Input tokens exceed the configured limit of 272000 tokens. Your messages resulted in 3148588 tokens. Please reduce the length of the messages.", + "type": "invalid_request_error", + "param": "messages", + "code": "context_length_exceeded" + } +} + diff --git a/aibridge/fixtures/openai/chatcompletions/simple.txtar b/aibridge/fixtures/openai/chatcompletions/simple.txtar new file mode 100644 index 0000000000..8f07d0c8ff --- /dev/null +++ b/aibridge/fixtures/openai/chatcompletions/simple.txtar @@ -0,0 +1,536 @@ +Simple request. + +-- request -- +{ + "messages": [ + { + "role": "user", + "content": "how many angels can dance on the head of a pin\n" + } + ], + "model": "gpt-4.1" +} + +-- streaming -- +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"The"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" question"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" \""},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"How"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" many"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" angels"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" can"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" dance"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" on"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" the"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" head"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" of"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" a"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" pin"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"?\""},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" is"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" a"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" classic"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" example"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" of"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" a"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" **"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"ph"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"ilos"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"oph"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"ical"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" or"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" theological"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" r"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"iddle"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"**,"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" not"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" a"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" genuine"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" inquiry"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" about"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" metaph"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"ysical"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" realities"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"."},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" The"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" phrase"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" most"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" likely"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" originated"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" during"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" **"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"med"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"ieval"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" schol"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"astic"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" debates"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"**,"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" where"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" scholars"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" engaged"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" in"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" complex"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" discussions"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" about"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" the"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" nature"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" of"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" spiritual"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" beings"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" and"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" the"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" limits"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" of"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" human"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" knowledge"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":".\n\n"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"###"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" Meaning"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" and"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" Context"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"\n"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"-"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" **"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"Not"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" meant"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" to"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" have"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" a"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" literal"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" answer"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":":**"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" Angels"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" in"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" Christian"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" theology"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" are"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" spiritual"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" ("},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"not"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" physical"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":")"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" beings"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" so"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" they"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" don"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"’t"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" occupy"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" space"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" in"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" the"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" physical"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" sense"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":".\n"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"-"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" **"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"Symbol"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"ic"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" purpose"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":":**"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" The"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" question"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" is"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" often"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" used"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" to"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" mock"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" or"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" illustrate"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" arguments"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" perceived"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" as"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" overly"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" speculative"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" or"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" irrelevant"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":".\n\n"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"###"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" \""},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"Answers"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"\""},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" through"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" History"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"\n"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"-"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" **"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"Sch"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"ol"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"ast"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"ics"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":":**"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" There's"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" little"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" evidence"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" medieval"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" scholars"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" literally"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" debated"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" this"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":";"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" it's"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" more"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" a"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" later"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" **"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"car"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"ic"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"ature"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"**"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" of"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" their"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" intricate"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" theological"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" arguments"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":".\n"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"-"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" **"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"Modern"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" usage"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":":**"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" It's"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" cited"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" as"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" an"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" example"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" of"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" a"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" pointless"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" or"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" un"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"answer"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"able"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" question"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":".\n\n"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"###"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" Summary"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"\n"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"**"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"There"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" is"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" no"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" specific"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" number"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":";"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"**"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" the"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" question"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" is"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" rhetorical"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":","},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" highlighting"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" the"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" limits"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" of"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" theoretical"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" or"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" speculative"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" reasoning"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":".\n\n"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"Would"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" like"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" to"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" know"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" more"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" about"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" medieval"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" schol"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"astic"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" debates"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" or"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" how"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" this"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" question"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" is"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" used"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" in"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" modern"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" discourse"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"?"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[],"usage":{"prompt_tokens":19,"completion_tokens":238,"total_tokens":257,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}}} + +data: [DONE] + +-- non-streaming -- +{ + "id": "chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N", + "object": "chat.completion", + "created": 1753357765, + "model": "gpt-4.1-2025-04-14", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "The question \"How many angels can dance on the head of a pin?\" is a classic example of a rhetorical or philosophical question—*not* a real theological inquiry.\n\n**Origin and Meaning:**\n- The phrase is used to lampoon or satirize overly subtle, speculative, or irrelevant philosophical debates, especially those attributed to medieval scholasticism.\n- There is **no actual historical record** of medieval theologians debating this specific question.\n- It **illustrates debates about the nature of angels**—whether they occupy physical space, for example—but not in such literal terms.\n\n**If answered literally:**\n- If angels are considered non-corporeal and not limited by physical space, **an infinite number** could \"dance\" on the head of a pin.\n- If taken as a joke, the answer is up to the storyteller!\n\n**In summary:** \nIt's a facetious question highlighting the limits or absurdities of some philosophical or theological arguments. There is no fixed answer.", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 19, + "completion_tokens": 200, + "total_tokens": 219, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_b3f1157249" +} + diff --git a/aibridge/fixtures/openai/chatcompletions/single_builtin_tool.txtar b/aibridge/fixtures/openai/chatcompletions/single_builtin_tool.txtar new file mode 100644 index 0000000000..0eae82126a --- /dev/null +++ b/aibridge/fixtures/openai/chatcompletions/single_builtin_tool.txtar @@ -0,0 +1,102 @@ +LLM (https://llm.datasette.io/) configured with a simple "read_file" tool. + +-- request -- +{ + "messages": [ + { + "role": "user", + "content": "how large is the README.md file in my current path" + } + ], + "model": "gpt-4.1", + "tools": [ + { + "type": "function", + "function": { + "name": "read_file", + "description": "Read the contents of a file at the given path.", + "parameters": { + "properties": { + "path": { + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } + } + } + ] +} + +-- streaming -- +data: {"id":"chatcmpl-BwkwXxA0yAyLKZelloERJWtxKor9z","object":"chat.completion.chunk","created":1753343173,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_b3f1157249","choices":[{"index":0,"delta":{"role":"assistant","content":null,"tool_calls":[{"index":0,"id":"call_HjeqP7YeRkoNj0de9e3U4X4B","type":"function","function":{"name":"read_file","arguments":""}}],"refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwkwXxA0yAyLKZelloERJWtxKor9z","object":"chat.completion.chunk","created":1753343173,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_b3f1157249","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwkwXxA0yAyLKZelloERJWtxKor9z","object":"chat.completion.chunk","created":1753343173,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_b3f1157249","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"path"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwkwXxA0yAyLKZelloERJWtxKor9z","object":"chat.completion.chunk","created":1753343173,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_b3f1157249","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwkwXxA0yAyLKZelloERJWtxKor9z","object":"chat.completion.chunk","created":1753343173,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_b3f1157249","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"README"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwkwXxA0yAyLKZelloERJWtxKor9z","object":"chat.completion.chunk","created":1753343173,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_b3f1157249","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":".md"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwkwXxA0yAyLKZelloERJWtxKor9z","object":"chat.completion.chunk","created":1753343173,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_b3f1157249","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwkwXxA0yAyLKZelloERJWtxKor9z","object":"chat.completion.chunk","created":1753343173,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_b3f1157249","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}],"usage":null} + +data: {"id":"chatcmpl-BwkwXxA0yAyLKZelloERJWtxKor9z","object":"chat.completion.chunk","created":1753343173,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_b3f1157249","choices":[],"usage":{"prompt_tokens":60,"completion_tokens":15,"total_tokens":75,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}}} + +data: [DONE] + +-- non-streaming -- +{ + "id": "chatcmpl-BwkyFElDIr1egmFyfQ9z4vPBto7m2", + "object": "chat.completion", + "created": 1753343279, + "model": "gpt-4.1-2025-04-14", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_KjzAbhiZC6nk81tQzL7pwlpc", + "type": "function", + "function": { + "name": "read_file", + "arguments": "{\"path\":\"README.md\"}" + } + } + ], + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 60, + "completion_tokens": 15, + "total_tokens": 75, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_b3f1157249" +} + diff --git a/aibridge/fixtures/openai/chatcompletions/single_injected_tool.txtar b/aibridge/fixtures/openai/chatcompletions/single_injected_tool.txtar new file mode 100644 index 0000000000..b89aac648a --- /dev/null +++ b/aibridge/fixtures/openai/chatcompletions/single_injected_tool.txtar @@ -0,0 +1,294 @@ +Coder MCP tools automatically injected. + +-- request -- +{ + "model": "gpt-4.1", + "messages": [ + { + "role": "user", + "content": "list coder workspace IDs for admin" + } + ] +} + +-- streaming -- +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ha7QSWuIrCLSg"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"I"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"TxlRNztDyni152"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" am"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"d8rQaibDQpyL"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" about"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Qlbfp6UEp"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" to"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"68rb1Vo3ymBh"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" call"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"i7c6mc6zJY"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" the"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Z9syl1x73E7"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" appropriate"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"5wK"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" tool"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"qxf0biXh4i"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" to"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"UMXRLeWr9r7g"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" list"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"PkO0yHjNu3"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" all"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ktUBR7vT2FC"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" work"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"xdNr1gCRJW"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"spaces"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"5z5luvhUz"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" for"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"G6D7Ze3OlLR"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" the"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"6BZ54FOiuA7"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" user"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"6b0xOBQj2J"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" admin"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"X5gzNDQyO"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" and"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"oSONGErPa7g"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" display"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"EK9oGdN"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" their"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"TPtBmjMIt"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" IDs"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"FONB73iSePd"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":".\n\n"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"VMpWnam5jp"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_0TxntkwDB66KH8z4RwNqeWrZ","type":"function","function":{"name":"bmcp_coder_coder_list_workspaces","arguments":""}}]},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"kY"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"n5"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"owner"}}]},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":""} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":""} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"admin"}}]},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":""} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"1t"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}],"usage":null,"obfuscation":"sDj"} + +data: {"id":"chatcmpl-C1WTooFaxeQgtyLB1kg53t41aB0NV","object":"chat.completion.chunk","created":1754479216,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[],"usage":{"prompt_tokens":4862,"completion_tokens":45,"total_tokens":4907,"prompt_tokens_details":{"cached_tokens":0,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"8sIWE1chOW"} + +data: [DONE] + + +-- streaming/tool-call -- +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"DBu9uyty0Uhux"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"Here"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"Pk0tDwr0wkd"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" are"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ACu9WW1Lsz4"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" the"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"xrXWRUKKAZl"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" workspace"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"LowCw"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" IDs"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"RXNpYewll1k"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" for"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"WnyxJrani1M"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" the"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"JrnDAJOLap4"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" user"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"RNZIdDo4vj"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" admin"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"nJ7O0qcsG"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":":\n\n"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"0k0UVPjnE2"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"-"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"dtGIleZ8Nl9lU7"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" Workspace"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"wKNWu"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" Name"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"cmzvcWMEIp"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":":"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"GsImQO12UCnPHY"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" bob"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"AR4Jvn87StW"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"\n"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"WoNeyT7BKKjIS"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"-"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"2Ou4DytumVPlyW"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" Workspace"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"PRWw3"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" ID"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"rrKKjluNdVET"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":":"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"v6NUOTV1Pd6piU"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" dd"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"UuYGjaLT7OXO"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"711"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"vLHjJVhbJgec"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"d"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"2yDtuCir4L9eyS"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"5"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"kyJOHcdfo1NMrP"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"c"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"nuKRieC0bpf6O3"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"-"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"q29JHHRnNg1GYt"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"83"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"e0o7Zu6eKnter"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"c"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"NCASF3SYR9GDQl"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"6"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"eG48V9XgxodtbB"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"-"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"CpP8ALTDfT0yBv"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"4"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"uQY85IhRAfuFl9"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"c"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"wsdJSv3bN65S5a"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"08"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"dq2JARx8gsgIm"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"-a"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"4booyOM91IZdC"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"0"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"wVJJDjNFBXO3OC"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"af"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"XFtDbXdnHdnF3"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"-b"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"juymtEmZxo1Ez"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"730"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"8pIOLoJZJAfe"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"559"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"NPfQJmrtGPlY"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"06"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"jsqxOojcWTY3A"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"e"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"cWYFwWie0ciIju"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"8"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ilVWzWQLUWQOMw"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"c"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"ea99MtCCypPar2"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"\n\n"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"SDq7UD3LcH7"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"Let"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"S343Ji05lUgD"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" me"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"TTCD9vPg98sO"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" know"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"xcsP3lRI6f"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" if"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"bS0qh0vq73n3"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" you"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"pxUYdxCHoy8"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" need"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"wjLDXO4uD8"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" more"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"B6ckyharjv"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" information"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"xrN"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" about"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"aqv4RrWxJ"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" any"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"hqdG5QSND4E"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" of"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"HvfgjMOXU6aG"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" these"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"yE0jSPMkD"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":" work"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"wWfGxJR2wt"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"spaces"},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"hOXndth8X"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{"content":"."},"logprobs":null,"finish_reason":null}],"usage":null,"obfuscation":"MReMwESHIpaDyo"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"stop"}],"usage":null,"obfuscation":"EFeFvdS8m"} + +data: {"id":"chatcmpl-C1WTqhYgK7bV01bW98Lww3zqaf8ZF","object":"chat.completion.chunk","created":1754479218,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_799e4ca3f1","choices":[],"usage":{"prompt_tokens":5049,"completion_tokens":60,"total_tokens":5109,"prompt_tokens_details":{"cached_tokens":4864,"audio_tokens":0},"completion_tokens_details":{"reasoning_tokens":0,"audio_tokens":0,"accepted_prediction_tokens":0,"rejected_prediction_tokens":0}},"obfuscation":"0JQt7Fw"} + +data: [DONE] + + +-- non-streaming -- +{ + "id": "chatcmpl-C1XAKDTVYnmWS7tgvg7vPje00PIiy", + "object": "chat.completion", + "created": 1754481852, + "model": "gpt-4.1-2025-04-14", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "I am about to call the relevant function to list all workspaces for the user admin and provide their workspace IDs.\n\nExecuting the function call now.", + "tool_calls": [ + { + "id": "call_aEuQAWKQYInC6fQ4z0iatdVP", + "type": "function", + "function": { + "name": "bmcp_coder_coder_list_workspaces", + "arguments": "{\"owner\":\"admin\"}" + } + } + ], + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "tool_calls" + } + ], + "usage": { + "prompt_tokens": 4862, + "completion_tokens": 45, + "total_tokens": 4914, + "prompt_tokens_details": { + "cached_tokens": 0, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_51e1070cf2" +} + + +-- non-streaming/tool-call -- +{ + "id": "chatcmpl-C1XANLwdflVxAjKOjbMP3LJxSlXsS", + "object": "chat.completion", + "created": 1754481855, + "model": "gpt-4.1-2025-04-14", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Here is the list of Coder workspace IDs for the user admin:\n\n- Workspace Name: bob\n- Workspace ID: dd711d5c-83c6-4c08-a0af-b73055906e8c\n\nLet me know if you need more details or actions on this workspace!", + "refusal": null, + "annotations": [] + }, + "logprobs": null, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 5049, + "completion_tokens": 60, + "total_tokens": 5119, + "prompt_tokens_details": { + "cached_tokens": 4864, + "audio_tokens": 0 + }, + "completion_tokens_details": { + "reasoning_tokens": 0, + "audio_tokens": 0, + "accepted_prediction_tokens": 0, + "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_51e1070cf2" +} + diff --git a/aibridge/fixtures/openai/chatcompletions/stream_error.txtar b/aibridge/fixtures/openai/chatcompletions/stream_error.txtar new file mode 100644 index 0000000000..678800bb44 --- /dev/null +++ b/aibridge/fixtures/openai/chatcompletions/stream_error.txtar @@ -0,0 +1,25 @@ +Simple request + error. + +-- request -- +{ + "messages": [ + { + "role": "user", + "content": "how many angels can dance on the head of a pin\n" + } + ], + "model": "gpt-4.1", + "stream": true +} + +-- streaming -- +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"role":"assistant","content":"","refusal":null},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":"The"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" question"},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"id":"chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N","object":"chat.completion.chunk","created":1753357673,"model":"gpt-4.1-2025-04-14","service_tier":"default","system_fingerprint":"fp_51e1070cf2","choices":[{"index":0,"delta":{"content":" \""},"logprobs":null,"finish_reason":null}],"usage":null} + +data: {"error": {"message": "The server had an error while processing your request. Sorry about that!", "type": "server_error"}} + diff --git a/aibridge/fixtures/openai/chatcompletions/streaming_injected_tool_no_preamble.txtar b/aibridge/fixtures/openai/chatcompletions/streaming_injected_tool_no_preamble.txtar new file mode 100644 index 0000000000..f39097c7d8 --- /dev/null +++ b/aibridge/fixtures/openai/chatcompletions/streaming_injected_tool_no_preamble.txtar @@ -0,0 +1,73 @@ +Streaming response where the provider returns an injected tool call as the first chunk with no text preamble. +This test ensures tool invocation continues even when no chunks are relayed to the client. + +-- request -- +{ + "messages": [ + { + "content": "2026-01-22T18:35:17.612Z\n\nlist all my coder workspaces", + "role": "user" + } + ], + "model": "claude-haiku-4.5", + "n": 1, + "temperature": 1, + "parallel_tool_calls": false, + "stream_options": { + "include_usage": true + }, + "stream": true +} + +-- streaming -- +data: {"choices":[{"index":0,"delta":{"content":null,"tool_calls":[{"function":{"name":"bmcp_coder_coder_list_workspaces"},"id":"toolu_vrtx_01CvBi1d4qpKTG2PCuc9wDbZ","index":0,"type":"function"}]}}],"created":1769106921,"id":"msg_vrtx_01UoiRJwj3JXcwNYAh3z7ARs","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":null,"tool_calls":[{"function":{"arguments":""},"index":0}]}}],"created":1769106921,"id":"msg_vrtx_01UoiRJwj3JXcwNYAh3z7ARs","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":null,"tool_calls":[{"function":{"arguments":"{\"own"},"index":0}]}}],"created":1769106921,"id":"msg_vrtx_01UoiRJwj3JXcwNYAh3z7ARs","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":null,"tool_calls":[{"function":{"arguments":"er\": \"me\"}"},"index":0}]}}],"created":1769106921,"id":"msg_vrtx_01UoiRJwj3JXcwNYAh3z7ARs","model":"claude-haiku-4.5"} + +data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null}}],"created":1769106921,"id":"msg_vrtx_01UoiRJwj3JXcwNYAh3z7ARs","usage":{"completion_tokens":65,"prompt_tokens":25716,"prompt_tokens_details":{"cached_tokens":20470},"total_tokens":25781},"model":"claude-haiku-4.5"} + +data: [DONE] + + +-- streaming/tool-call -- +data: {"choices":[{"index":0,"delta":{"content":"You","role":"assistant"}}],"created":1769198061,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":" have one","role":"assistant"}}],"created":1769198061,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":" Coder workspace:","role":"assistant"}}],"created":1769198062,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":"\n\n**test-scf** (","role":"assistant"}}],"created":1769198062,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":"ID: a174a2e5","role":"assistant"}}],"created":1769198062,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":"-5050-445d-89","role":"assistant"}}],"created":1769198062,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":"ff-dd720e5b442","role":"assistant"}}],"created":1769198062,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":"e)\n- Template: docker","role":"assistant"}}],"created":1769198062,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":"\n- Template Version","role":"assistant"}}],"created":1769198062,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":" ID","role":"assistant"}}],"created":1769198062,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":": ad1b5ab1-","role":"assistant"}}],"created":1769198062,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":"fc18-4792-84f","role":"assistant"}}],"created":1769198062,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":"7-797787607d30","role":"assistant"}}],"created":1769198062,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":"\n- Status","role":"assistant"}}],"created":1769198062,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":": Up","role":"assistant"}}],"created":1769198062,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":" to date","role":"assistant"}}],"created":1769198062,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","model":"claude-haiku-4.5"} + +data: {"choices":[{"finish_reason":"stop","index":0,"delta":{"content":null}}],"created":1769198062,"id":"msg_vrtx_015B1npskreQgEjMrfsdjH1m","usage":{"completion_tokens":85,"prompt_tokens":25989,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":26074},"model":"claude-haiku-4.5"} + +data: [DONE] + + diff --git a/aibridge/fixtures/openai/chatcompletions/streaming_injected_tool_nonzero_index.txtar b/aibridge/fixtures/openai/chatcompletions/streaming_injected_tool_nonzero_index.txtar new file mode 100644 index 0000000000..384d1ee59d --- /dev/null +++ b/aibridge/fixtures/openai/chatcompletions/streaming_injected_tool_nonzero_index.txtar @@ -0,0 +1,72 @@ +Streaming response where the provider returns text content followed by an injected tool call at index 1 (instead of index 0). +This can happen when the provider incorrectly continues indexing from a previous response. +This tests that nil entries are removed from the tool calls array caused by non-zero starting indices. + +-- request -- +{ + "messages": [ + { + "content": "2026-01-23T20:22:43.781Z\n\nI want you to do to this in order:\n1) create a file in my current directory with name \"test.txt\"\n2) list all my coder workspaces", + "role": "user" + } + ], + "model": "claude-haiku-4.5", + "n": 1, + "temperature": 1, + "parallel_tool_calls": false, + "stream_options": { + "include_usage": true + }, + "stream": true +} + +-- streaming -- +data: {"choices":[{"index":0,"delta":{"content":"Now","role":"assistant"}}],"created":1769199774,"id":"msg_vrtx_01Fiieb5Z3kqJf9a3FwvLkky","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":" listing","role":"assistant"}}],"created":1769199774,"id":"msg_vrtx_01Fiieb5Z3kqJf9a3FwvLkky","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":" your","role":"assistant"}}],"created":1769199774,"id":"msg_vrtx_01Fiieb5Z3kqJf9a3FwvLkky","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":" C","role":"assistant"}}],"created":1769199774,"id":"msg_vrtx_01Fiieb5Z3kqJf9a3FwvLkky","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":"oder workspaces:","role":"assistant"}}],"created":1769199774,"id":"msg_vrtx_01Fiieb5Z3kqJf9a3FwvLkky","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":null,"tool_calls":[{"function":{"name":"bmcp_coder_coder_list_workspaces"},"id":"toolu_vrtx_01DbFqUgk6aAtJ4nDBqzFWDF","index":1,"type":"function"}]}}],"created":1769199774,"id":"msg_vrtx_01Fiieb5Z3kqJf9a3FwvLkky","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":null,"tool_calls":[{"function":{"arguments":""},"index":1}]}}],"created":1769199774,"id":"msg_vrtx_01Fiieb5Z3kqJf9a3FwvLkky","model":"claude-haiku-4.5"} + +data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null}}],"created":1769199774,"id":"msg_vrtx_01Fiieb5Z3kqJf9a3FwvLkky","usage":{"completion_tokens":58,"prompt_tokens":25939,"prompt_tokens_details":{"cached_tokens":25429},"total_tokens":25997},"model":"claude-haiku-4.5"} + +data: [DONE] + + +-- streaming/tool-call -- +data: {"choices":[{"index":0,"delta":{"content":"Done","role":"assistant"}}],"created":1769199776,"id":"msg_vrtx_01RVxamMyw1DBtpoENDpmnQK","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":"! I create","role":"assistant"}}],"created":1769199776,"id":"msg_vrtx_01RVxamMyw1DBtpoENDpmnQK","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":"d `","role":"assistant"}}],"created":1769199776,"id":"msg_vrtx_01RVxamMyw1DBtpoENDpmnQK","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":"test.txt` in","role":"assistant"}}],"created":1769199776,"id":"msg_vrtx_01RVxamMyw1DBtpoENDpmnQK","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":" your current directory.","role":"assistant"}}],"created":1769199776,"id":"msg_vrtx_01RVxamMyw1DBtpoENDpmnQK","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":" You","role":"assistant"}}],"created":1769199776,"id":"msg_vrtx_01RVxamMyw1DBtpoENDpmnQK","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":" have","role":"assistant"}}],"created":1769199776,"id":"msg_vrtx_01RVxamMyw1DBtpoENDpmnQK","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":" 1","role":"assistant"}}],"created":1769199776,"id":"msg_vrtx_01RVxamMyw1DBtpoENDpmnQK","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":" ","role":"assistant"}}],"created":1769199776,"id":"msg_vrtx_01RVxamMyw1DBtpoENDpmnQK","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":"Coder workspace:\n\n-","role":"assistant"}}],"created":1769199776,"id":"msg_vrtx_01RVxamMyw1DBtpoENDpmnQK","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":" **test-scf** (docker","role":"assistant"}}],"created":1769199776,"id":"msg_vrtx_01RVxamMyw1DBtpoENDpmnQK","model":"claude-haiku-4.5"} + +data: {"choices":[{"index":0,"delta":{"content":" template)","role":"assistant"}}],"created":1769199776,"id":"msg_vrtx_01RVxamMyw1DBtpoENDpmnQK","model":"claude-haiku-4.5"} + +data: {"choices":[{"finish_reason":"stop","index":0,"delta":{"content":null}}],"created":1769199776,"id":"msg_vrtx_01RVxamMyw1DBtpoENDpmnQK","usage":{"completion_tokens":39,"prompt_tokens":26166,"prompt_tokens_details":{"cached_tokens":25934},"total_tokens":26205},"model":"claude-haiku-4.5"} + +data: [DONE] + + diff --git a/aibridge/fixtures/openai/responses/blocking/cached_input_tokens.txtar b/aibridge/fixtures/openai/responses/blocking/cached_input_tokens.txtar new file mode 100644 index 0000000000..41a6d7ca7e --- /dev/null +++ b/aibridge/fixtures/openai/responses/blocking/cached_input_tokens.txtar @@ -0,0 +1,81 @@ +-- request -- +{ + "input": "This was a large input...", + "model": "gpt-4.1", + "prompt_cache_key": "key-123", + "prompt_cache_retention": "24h", + "stream": false +} + +-- non-streaming -- +{ + "id": "resp_0cd5d6b8310055d600696a1776b42c81a199fbb02248a8bfa0", + "object": "response", + "created_at": 1768560502, + "status": "completed", + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1768560504, + "error": null, + "frequency_penalty": 0.0, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4.1-2025-04-14", + "output": [ + { + "id": "msg_0cd5d6b8310055d600696a177708b881a1bb53034def764104", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "- I provide clear, accurate, and concise answers tailored to your requests.\n- I can process and summarize large volumes of information quickly.\n- I adapt my responses based on your needs and instructions for precision and relevance." + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "presence_penalty": 0.0, + "previous_response_id": null, + "prompt_cache_key": "key-123", + "prompt_cache_retention": "24h", + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 12033, + "input_tokens_details": { + "cached_tokens": 11904 + }, + "output_tokens": 44, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 12077 + }, + "user": null, + "metadata": {} +} diff --git a/aibridge/fixtures/openai/responses/blocking/commentary_builtin_tool.txtar b/aibridge/fixtures/openai/responses/blocking/commentary_builtin_tool.txtar new file mode 100644 index 0000000000..d0e83dd7f4 --- /dev/null +++ b/aibridge/fixtures/openai/responses/blocking/commentary_builtin_tool.txtar @@ -0,0 +1,139 @@ +-- request -- +{ + "input": [ + { + "role": "user", + "content": "Is 3 + 5 a prime number? Use the add function to calculate the sum." + } + ], + "model": "gpt-5.4", + "stream": false, + "tools": [ + { + "type": "function", + "name": "add", + "description": "Add two numbers together.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ] + } + } + ] +} + +-- non-streaming -- +{ + "id": "resp_0aba2ac43dc240b30169b15720243c819ebb64977365d42cf5", + "object": "response", + "created_at": 1773229856, + "status": "completed", + "background": false, + "completed_at": 1773229861, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-5.4-2026-03-05", + "output": [ + { + "id": "rs_0aba2ac43dc240b30169b157208c88819e8238a91b5f7a919b", + "type": "reasoning", + "status": "completed", + "encrypted_content": "gAAAAA==", + "summary": [] + }, + { + "id": "msg_0aba2ac43dc240b30169b1572286d0819eb24b1d0f84c8fb3f", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "text": "Checking whether 3 + 5 is prime by calling the add function first." + } + ], + "phase": "commentary", + "role": "assistant" + }, + { + "id": "fc_0aba2ac43dc240b30169b157255604819e8a108124efc1635c", + "type": "function_call", + "status": "completed", + "arguments": "{\"a\":3,\"b\":5}", + "call_id": "call_A8TkZmIcKtw2Zw952Wc5QVe7", + "name": "add" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": "xhigh", + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": false, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "low" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "function", + "description": "Add two numbers together.", + "name": "add", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ], + "additionalProperties": false + }, + "strict": true + } + ], + "top_logprobs": 0, + "top_p": 0.98, + "truncation": "disabled", + "usage": { + "input_tokens": 58, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 30, + "output_tokens_details": { + "reasoning_tokens": 10 + }, + "total_tokens": 88 + }, + "user": null, + "metadata": {} +} diff --git a/aibridge/fixtures/openai/responses/blocking/conversation.txtar b/aibridge/fixtures/openai/responses/blocking/conversation.txtar new file mode 100644 index 0000000000..2474b05613 --- /dev/null +++ b/aibridge/fixtures/openai/responses/blocking/conversation.txtar @@ -0,0 +1,82 @@ +-- request -- +{ + "conversation": "conv_695fa15ecbb881958e89ac2d35d918ed0c9f1f0524a858fa", + "input": "explain why this is funny.", + "model": "gpt-4o-mini", + "stream": false +} + + +-- non-streaming -- +{ + "id": "resp_0c9f1f0524a858fa00695fa15fc5a081958f4304aafd3bdec2", + "object": "response", + "created_at": 1767874911, + "status": "completed", + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1767874914, + "conversation": { + "id": "conv_695fa15ecbb881958e89ac2d35d918ed0c9f1f0524a858fa" + }, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "msg_0c9f1f0524a858fa00695fa1605bd48195b65b4dfd732941bc", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "This joke plays on a double meaning of the phrase \u201cmake up.\u201d \n\n1. **Literal Meaning**: Atoms are the basic building blocks of matter and literally \"make up\" all substances in the universe.\n\n2. **Figurative Meaning**: The phrase \"make up\" can also mean to fabricate or lie about something. \n\nThe humor comes from the unexpected twist; it starts off sounding like a serious statement about atoms, then surprises us with a clever play on words that suggests atoms are dishonest. This blend of scientific fact and pun creates the comedic effect!" + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 48, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 116, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 164 + }, + "user": null, + "metadata": {} +} diff --git a/aibridge/fixtures/openai/responses/blocking/custom_tool.txtar b/aibridge/fixtures/openai/responses/blocking/custom_tool.txtar new file mode 100644 index 0000000000..a1965930d8 --- /dev/null +++ b/aibridge/fixtures/openai/responses/blocking/custom_tool.txtar @@ -0,0 +1,93 @@ +-- request -- +{ + "input": "Use the code_exec tool to print hello world to the console.", + "model": "gpt-5", + "tools": [ + { + "type": "custom", + "name": "code_exec", + "description": "Executes arbitrary Python code." + } + ] +} + +-- non-streaming -- +{ + "id": "resp_09c614364030cdf000696942589da081a0af07f5859acb7308", + "object": "response", + "created_at": 1768505944, + "status": "completed", + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1768505948, + "error": null, + "frequency_penalty": 0.0, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-5-2025-08-07", + "output": [ + { + "id": "rs_09c614364030cdf00069694258e45881a0b8d5f198cde47d58", + "type": "reasoning", + "summary": [] + }, + { + "id": "ctc_09c614364030cdf0006969425bf33481a09cc0f9522af2d980", + "type": "custom_tool_call", + "status": "completed", + "call_id": "call_haf8njtwrVZ1754Gm6fjAtuA", + "input": "print(\"hello world\")", + "name": "code_exec" + } + ], + "parallel_tool_calls": true, + "presence_penalty": 0.0, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": "medium", + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "custom", + "description": "Executes arbitrary Python code.", + "format": { + "type": "text" + }, + "name": "code_exec" + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 64, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 148, + "output_tokens_details": { + "reasoning_tokens": 128 + }, + "total_tokens": 212 + }, + "user": null, + "metadata": {} +} \ No newline at end of file diff --git a/aibridge/fixtures/openai/responses/blocking/http_error.txtar b/aibridge/fixtures/openai/responses/blocking/http_error.txtar new file mode 100644 index 0000000000..24986a2cea --- /dev/null +++ b/aibridge/fixtures/openai/responses/blocking/http_error.txtar @@ -0,0 +1,21 @@ +-- request -- +{ + "input": "tell me a joke", + "model": "gpt-4o-mini", + "stream": false +} + +-- non-streaming -- +HTTP/2.0 401 Unauthorized +Content-Length: 234 +Content-Type: application/json + +{ + "error": { + "message": "Incorrect API key provided: sk-***. You can find your API key at https://platform.openai.com/account/api-keys.", + "type": "authentication_error", + "param": null, + "code": "invalid_api_key" + } +} + diff --git a/aibridge/fixtures/openai/responses/blocking/multi_reasoning_builtin_tool.txtar b/aibridge/fixtures/openai/responses/blocking/multi_reasoning_builtin_tool.txtar new file mode 100644 index 0000000000..022b433ec8 --- /dev/null +++ b/aibridge/fixtures/openai/responses/blocking/multi_reasoning_builtin_tool.txtar @@ -0,0 +1,142 @@ +Two reasoning output items before a function_call. + +-- request -- +{ + "input": [ + { + "role": "user", + "content": "Is 3 + 5 a prime number? Use the add function to calculate the sum." + } + ], + "model": "gpt-4.1", + "stream": false, + "tools": [ + { + "type": "function", + "name": "add", + "description": "Add two numbers together.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ] + } + } + ] +} + +-- non-streaming -- +{ + "id": "resp_0da6045a8b68fa5200695fa23dcc2c81a19c849f627abf8a31", + "object": "response", + "created_at": 1767875133, + "status": "completed", + "background": false, + "completed_at": 1767875134, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4.1-2025-04-14", + "output": [ + { + "id": "rs_0da6045a8b68fa5200695fa23e100081a19bf68887d47ae93d", + "type": "reasoning", + "status": "completed", + "summary": [ + { + "type": "summary_text", + "text": "The user wants to add 3 and 5. Let me call the add function." + } + ] + }, + { + "id": "rs_1aa7045a8b68fa5200695fa23e200082b29cf79998e58bf94e", + "type": "reasoning", + "status": "completed", + "summary": [ + { + "type": "summary_text", + "text": "After adding, I will check if the result is prime." + } + ] + }, + { + "id": "fc_0da6045a8b68fa5200695fa23e198081a19bf68887d47ae93d", + "type": "function_call", + "status": "completed", + "arguments": "{\"a\":3,\"b\":5}", + "call_id": "call_CJSaa2u51JG996575oVljuNq", + "name": "add" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "function", + "description": "Add two numbers together.", + "name": "add", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ], + "additionalProperties": false + }, + "strict": true + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 58, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 18, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 76 + }, + "user": null, + "metadata": {} +} diff --git a/aibridge/fixtures/openai/responses/blocking/prev_response_id.txtar b/aibridge/fixtures/openai/responses/blocking/prev_response_id.txtar new file mode 100644 index 0000000000..4648abb665 --- /dev/null +++ b/aibridge/fixtures/openai/responses/blocking/prev_response_id.txtar @@ -0,0 +1,78 @@ +-- request -- +{ + "input": "explain why this is funny.", + "model": "gpt-4o-mini", + "previous_response_id": "resp_0388c79043df3e3400695f9f83cd6481959062cec6830d8d51", + "stream": false +} + +-- non-streaming -- +{ + "id": "resp_0388c79043df3e3400695f9f86cfa08195af1f015c60117a83", + "object": "response", + "created_at": 1767874438, + "status": "completed", + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1767874441, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "msg_0388c79043df3e3400695f9f87369c8195a0d1a82a06f96d56", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "The joke plays on a clever wordplay and a double meaning. \n\n1. **Outstanding in his field**: The phrase can mean that someone is exceptionally good at what they do (outstanding performance) and also literally refers to the scarecrow being in a field (like a farm field). \n\n2. **Scarecrow context**: Scarecrows are placed in fields to scare away birds, so the idea of a scarecrow being \"outstanding\" can lead to a funny mental image.\n\nThe humor comes from the unexpected twist of a literal phrase being interpreted in a figurative way, creating a light and playful pun." + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": "resp_0388c79043df3e3400695f9f83cd6481959062cec6830d8d51", + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 43, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 129, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 172 + }, + "user": null, + "metadata": {} +} diff --git a/aibridge/fixtures/openai/responses/blocking/simple.txtar b/aibridge/fixtures/openai/responses/blocking/simple.txtar new file mode 100644 index 0000000000..e9f188eef9 --- /dev/null +++ b/aibridge/fixtures/openai/responses/blocking/simple.txtar @@ -0,0 +1,77 @@ +-- request -- +{ + "input": "tell me a joke", + "model": "gpt-4o-mini", + "stream": false +} + +-- non-streaming -- +{ + "id": "resp_0388c79043df3e3400695f9f83cd6481959062cec6830d8d51", + "object": "response", + "created_at": 1767874435, + "status": "completed", + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1767874436, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "msg_0388c79043df3e3400695f9f8447a08195af2ef951966823c4", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "Why did the scarecrow win an award?\n\nBecause he was outstanding in his field!" + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 11, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 18, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 29 + }, + "user": null, + "metadata": {} +} diff --git a/aibridge/fixtures/openai/responses/blocking/single_builtin_tool.txtar b/aibridge/fixtures/openai/responses/blocking/single_builtin_tool.txtar new file mode 100644 index 0000000000..14299ff3f8 --- /dev/null +++ b/aibridge/fixtures/openai/responses/blocking/single_builtin_tool.txtar @@ -0,0 +1,132 @@ +-- request -- +{ + "input": [ + { + "role": "user", + "content": "Is 3 + 5 a prime number? Use the add function to calculate the sum." + } + ], + "model": "gpt-4.1", + "stream": false, + "tools": [ + { + "type": "function", + "name": "add", + "description": "Add two numbers together.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ] + } + } + ] +} + +-- non-streaming -- +{ + "id": "resp_0da6045a8b68fa5200695fa23dcc2c81a19c849f627abf8a31", + "object": "response", + "created_at": 1767875133, + "status": "completed", + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1767875134, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4.1-2025-04-14", + "output": [ + { + "id": "rs_0da6045a8b68fa5200695fa23e100081a19bf68887d47ae93d", + "type": "reasoning", + "status": "completed", + "summary": [ + { + "type": "summary_text", + "text": "The user wants to add 3 and 5. Let me call the add function." + } + ] + }, + { + "id": "fc_0da6045a8b68fa5200695fa23e198081a19bf68887d47ae93d", + "type": "function_call", + "status": "completed", + "arguments": "{\"a\":3,\"b\":5}", + "call_id": "call_CJSaa2u51JG996575oVljuNq", + "name": "add" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "function", + "description": "Add two numbers together.", + "name": "add", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ], + "additionalProperties": false + }, + "strict": true + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 58, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 18, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 76 + }, + "user": null, + "metadata": {} +} diff --git a/aibridge/fixtures/openai/responses/blocking/single_builtin_tool_parallel.txtar b/aibridge/fixtures/openai/responses/blocking/single_builtin_tool_parallel.txtar new file mode 100644 index 0000000000..4be0d240a6 --- /dev/null +++ b/aibridge/fixtures/openai/responses/blocking/single_builtin_tool_parallel.txtar @@ -0,0 +1,140 @@ +-- request -- +{ + "input": [ + { + "role": "user", + "content": "Is 3 + 5 a prime number? Also add 10 + 20. Use the add function for both." + } + ], + "model": "gpt-4.1", + "stream": false, + "tools": [ + { + "type": "function", + "name": "add", + "description": "Add two numbers together.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ] + } + } + ] +} + +-- non-streaming -- +{ + "id": "resp_parallel_blocking_001", + "object": "response", + "created_at": 1767875133, + "status": "completed", + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1767875134, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4.1-2025-04-14", + "output": [ + { + "id": "rs_parallel_blocking_reasoning_001", + "type": "reasoning", + "status": "completed", + "summary": [ + { + "type": "summary_text", + "text": "The user wants two additions: 3+5 and 10+20. I'll call add for both." + } + ] + }, + { + "id": "fc_parallel_blocking_first_001", + "type": "function_call", + "status": "completed", + "arguments": "{\"a\":3,\"b\":5}", + "call_id": "call_ParallelBlockingFirst001", + "name": "add" + }, + { + "id": "fc_parallel_blocking_second_001", + "type": "function_call", + "status": "completed", + "arguments": "{\"a\":10,\"b\":20}", + "call_id": "call_ParallelBlockingSecond01", + "name": "add" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "function", + "description": "Add two numbers together.", + "name": "add", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ], + "additionalProperties": false + }, + "strict": true + } + ], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 65, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 30, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 95 + }, + "user": null, + "metadata": {} +} diff --git a/aibridge/fixtures/openai/responses/blocking/single_injected_tool.txtar b/aibridge/fixtures/openai/responses/blocking/single_injected_tool.txtar new file mode 100644 index 0000000000..028377dcaa --- /dev/null +++ b/aibridge/fixtures/openai/responses/blocking/single_injected_tool.txtar @@ -0,0 +1,1522 @@ +Coder MCP tools automatically injected. + +-- request -- +{ + "input": "list the template params for version aa4e30e4-a086-4df6-a364-1343f1458104", + "model": "gpt-5.2" +} + + +-- non-streaming -- +{ + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1768644075, + "created_at": 1768644072, + "error": null, + "frequency_penalty": 0, + "id": "resp_012db006225b0ec700696b5de8a01481a28182ea6885448f93", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "metadata": {}, + "model": "gpt-5.2-2025-12-11", + "object": "response", + "output": [ + { + "id": "rs_012db006225b0ec700696b5dea84e081a2b7777aeb4925d8f9", + "summary": [], + "type": "reasoning" + }, + { + "arguments": "{\"template_version_id\":\"aa4e30e4-a086-4df6-a364-1343f1458104\"}", + "call_id": "call_5AroFIQIK3cm3suliZdux0TB", + "id": "fc_012db006225b0ec700696b5deb0a5081a28a495f192f19e75f", + "name": "bmcp_coder_coder_template_version_parameters", + "status": "completed", + "type": "function_call" + } + ], + "parallel_tool_calls": false, + "presence_penalty": 0, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": "high", + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "status": "completed", + "store": true, + "temperature": 1, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "description": "Create a task.", + "name": "bmcp_coder_coder_create_task", + "parameters": { + "properties": { + "input": { + "description": "Input/prompt for the task.", + "type": "string" + }, + "template_version_id": { + "description": "ID of the template version to create the task from.", + "type": "string" + }, + "template_version_preset_id": { + "description": "Optional ID of the template version preset to create the task from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.", + "type": "string" + } + }, + "required": [ + "input", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template in Coder. First, you must create a template version.", + "name": "bmcp_coder_coder_create_template", + "parameters": { + "properties": { + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon": { + "description": "A URL to an icon to use.", + "type": "string" + }, + "name": { + "type": "string" + }, + "version_id": { + "description": "The ID of the version to use.", + "type": "string" + } + }, + "required": [ + "name", + "display_name", + "description", + "version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\u003cterraform-spec\u003e\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"\u0026\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\u003c/terraform-spec\u003e\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\u003caws-ec2-instance\u003e\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\u003c/aws-ec2-instance\u003e\n\n\u003cgcp-vm-instance\u003e\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = \u003c\u003cEOMETA\n#!/usr/bin/env sh\nset -eux\n\n# If user does not exist, create it and set up passwordless sudo\nif ! id -u \"${local.linux_user}\" \u003e/dev/null 2\u003e\u00261; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" \u003e /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\u003c/gcp-vm-instance\u003e\n\n\u003cazure-vm-instance\u003e\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\u003c/azure-vm-instance\u003e\n\n\u003cdocker-container\u003e\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\u003c/docker-container\u003e\n\n\u003ckubernetes-pod\u003e\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\u003c/kubernetes-pod\u003e\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n", + "name": "bmcp_coder_coder_create_template_version", + "parameters": { + "properties": { + "file_id": { + "type": "string" + }, + "template_id": { + "type": "string" + } + }, + "required": [ + "file_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace", + "parameters": { + "properties": { + "name": { + "description": "Name of the workspace to create.", + "type": "string" + }, + "rich_parameters": { + "description": "Key/value pairs of rich parameters to pass to the template version to create the workspace.", + "type": "object" + }, + "template_version_id": { + "description": "ID of the template version to create the workspace from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.", + "type": "string" + } + }, + "required": [ + "user", + "template_version_id", + "name", + "rich_parameters" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace_build", + "parameters": { + "properties": { + "template_version_id": { + "description": "(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.", + "type": "string" + }, + "transition": { + "description": "The transition to perform. Must be one of: start, stop, delete", + "enum": [ + "start", + "stop", + "delete" + ], + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "transition" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a task.", + "name": "bmcp_coder_coder_delete_task", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a template. This is irreversible.", + "name": "bmcp_coder_coder_delete_template", + "parameters": { + "properties": { + "template_id": { + "type": "string" + } + }, + "required": [ + "template_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the currently authenticated user, similar to the `whoami` command.", + "name": "bmcp_coder_coder_get_authenticated_user", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a task.", + "name": "bmcp_coder_coder_get_task_logs", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the status of a task.", + "name": "bmcp_coder_coder_get_task_status", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a template version. This is useful to check whether a template version successfully imports or not.", + "name": "bmcp_coder_coder_get_template_version_logs", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.", + "name": "bmcp_coder_coder_get_workspace", + "parameters": { + "properties": { + "workspace_id": { + "description": "The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.", + "name": "bmcp_coder_coder_get_workspace_agent_logs", + "parameters": { + "properties": { + "workspace_agent_id": { + "type": "string" + } + }, + "required": [ + "workspace_agent_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.", + "name": "bmcp_coder_coder_get_workspace_build_logs", + "parameters": { + "properties": { + "workspace_build_id": { + "type": "string" + } + }, + "required": [ + "workspace_build_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List tasks.", + "name": "bmcp_coder_coder_list_tasks", + "parameters": { + "properties": { + "status": { + "description": "Optional filter by task status.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists templates for the authenticated user.", + "name": "bmcp_coder_coder_list_templates", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists workspaces for the authenticated user.", + "name": "bmcp_coder_coder_list_workspaces", + "parameters": { + "properties": { + "owner": { + "description": "The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Send input to a running task.", + "name": "bmcp_coder_coder_send_task_input", + "parameters": { + "properties": { + "input": { + "description": "The input to send to the task.", + "type": "string" + }, + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id", + "input" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.", + "name": "bmcp_coder_coder_template_version_parameters", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Update the active version of a template. This is helpful when iterating on templates.", + "name": "bmcp_coder_coder_update_template_active_version", + "parameters": { + "properties": { + "template_id": { + "type": "string" + }, + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_id", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.", + "name": "bmcp_coder_coder_upload_tar_file", + "parameters": { + "properties": { + "files": { + "description": "A map of file names to file contents.", + "type": "object" + } + }, + "required": [ + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh \u003cworkspace\u003e \u003ccommand\u003e' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"", + "name": "bmcp_coder_coder_workspace_bash", + "parameters": { + "properties": { + "background": { + "description": "Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.", + "type": "boolean" + }, + "command": { + "description": "The bash command to execute in the workspace.", + "type": "string" + }, + "timeout_ms": { + "default": 60000, + "description": "Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.", + "minimum": 1, + "type": "integer" + }, + "workspace": { + "description": "The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "command" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit a file in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_file", + "parameters": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "edits" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit one or more files in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_files", + "parameters": { + "properties": { + "files": { + "description": "An array of files to edit.", + "items": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + } + }, + "required": [ + "path", + "edits" + ], + "type": "object" + }, + "type": "array" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List the URLs of Coder apps running in a workspace for a single agent.", + "name": "bmcp_coder_coder_workspace_list_apps", + "parameters": { + "properties": { + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List directories in a workspace.", + "name": "bmcp_coder_coder_workspace_ls", + "parameters": { + "properties": { + "path": { + "description": "The absolute path of the directory in the workspace to list.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Fetch URLs that forward to the specified port.", + "name": "bmcp_coder_coder_workspace_port_forward", + "parameters": { + "properties": { + "port": { + "description": "The port to forward.", + "type": "number" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "port" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Read from a file in a workspace.", + "name": "bmcp_coder_coder_workspace_read_file", + "parameters": { + "properties": { + "limit": { + "description": "The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.", + "type": "integer" + }, + "offset": { + "description": "A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.", + "type": "integer" + }, + "path": { + "description": "The absolute path of the file to read in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n", + "name": "bmcp_coder_coder_workspace_write_file", + "parameters": { + "properties": { + "content": { + "description": "The base64-encoded bytes to write to the file.", + "type": "string" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "content" + ], + "type": "object" + }, + "strict": false, + "type": "function" + } + ], + "top_logprobs": 0, + "top_p": 0.98, + "truncation": "disabled", + "usage": { + "input_tokens": 6371, + "input_tokens_details": { + "cached_tokens": 6144 + }, + "output_tokens": 75, + "output_tokens_details": { + "reasoning_tokens": 25 + }, + "total_tokens": 6446 + }, + "user": null +} + + +-- non-streaming/tool-call -- +{ + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1768644080, + "created_at": 1768644076, + "error": null, + "frequency_penalty": 0, + "id": "resp_012db006225b0ec700696b5dec1d4c81a2a6a416e31af39b90", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "metadata": {}, + "model": "gpt-5.2-2025-12-11", + "object": "response", + "output": [ + { + "id": "rs_012db006225b0ec700696b5dec8e4c81a29eae3985d087c0b3", + "summary": [], + "type": "reasoning" + }, + { + "content": [ + { + "annotations": [], + "logprobs": [], + "text": "The template version `aa4e30e4-a086-4df6-a364-1343f1458104` defines **one** workspace parameter:\n\n### `jetbrains_ides`\n- **Display name:** JetBrains IDEs \n- **Type:** `list(string)` \n- **Form type:** `multi-select` \n- **Default:** `[]` (empty selection) \n- **Mutable after creation:** `true` \n- **Description:** Select which JetBrains IDEs to configure for use in this workspace.\n\n**Selectable options (name → value):**\n- CLion → `CL`\n- GoLand → `GO`\n- IntelliJ IDEA → `IU`\n- PhpStorm → `PS`\n- PyCharm → `PY`\n- Rider → `RD`\n- RubyMine → `RM`\n- RustRover → `RR`\n- WebStorm → `WS`", + "type": "output_text" + } + ], + "id": "msg_012db006225b0ec700696b5ded3f9881a2836e6cca7a5866e6", + "role": "assistant", + "status": "completed", + "type": "message" + } + ], + "parallel_tool_calls": false, + "presence_penalty": 0, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": "high", + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "status": "completed", + "store": true, + "temperature": 1, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "description": "Create a task.", + "name": "bmcp_coder_coder_create_task", + "parameters": { + "properties": { + "input": { + "description": "Input/prompt for the task.", + "type": "string" + }, + "template_version_id": { + "description": "ID of the template version to create the task from.", + "type": "string" + }, + "template_version_preset_id": { + "description": "Optional ID of the template version preset to create the task from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.", + "type": "string" + } + }, + "required": [ + "input", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template in Coder. First, you must create a template version.", + "name": "bmcp_coder_coder_create_template", + "parameters": { + "properties": { + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon": { + "description": "A URL to an icon to use.", + "type": "string" + }, + "name": { + "type": "string" + }, + "version_id": { + "description": "The ID of the version to use.", + "type": "string" + } + }, + "required": [ + "name", + "display_name", + "description", + "version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\u003cterraform-spec\u003e\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"\u0026\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\u003c/terraform-spec\u003e\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\u003caws-ec2-instance\u003e\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\u003c/aws-ec2-instance\u003e\n\n\u003cgcp-vm-instance\u003e\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = \u003c\u003cEOMETA\n#!/usr/bin/env sh\nset -eux\n\n# If user does not exist, create it and set up passwordless sudo\nif ! id -u \"${local.linux_user}\" \u003e/dev/null 2\u003e\u00261; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" \u003e /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\u003c/gcp-vm-instance\u003e\n\n\u003cazure-vm-instance\u003e\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\u003c/azure-vm-instance\u003e\n\n\u003cdocker-container\u003e\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\u003c/docker-container\u003e\n\n\u003ckubernetes-pod\u003e\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\u003c/kubernetes-pod\u003e\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n", + "name": "bmcp_coder_coder_create_template_version", + "parameters": { + "properties": { + "file_id": { + "type": "string" + }, + "template_id": { + "type": "string" + } + }, + "required": [ + "file_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace", + "parameters": { + "properties": { + "name": { + "description": "Name of the workspace to create.", + "type": "string" + }, + "rich_parameters": { + "description": "Key/value pairs of rich parameters to pass to the template version to create the workspace.", + "type": "object" + }, + "template_version_id": { + "description": "ID of the template version to create the workspace from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.", + "type": "string" + } + }, + "required": [ + "user", + "template_version_id", + "name", + "rich_parameters" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace_build", + "parameters": { + "properties": { + "template_version_id": { + "description": "(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.", + "type": "string" + }, + "transition": { + "description": "The transition to perform. Must be one of: start, stop, delete", + "enum": [ + "start", + "stop", + "delete" + ], + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "transition" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a task.", + "name": "bmcp_coder_coder_delete_task", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a template. This is irreversible.", + "name": "bmcp_coder_coder_delete_template", + "parameters": { + "properties": { + "template_id": { + "type": "string" + } + }, + "required": [ + "template_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the currently authenticated user, similar to the `whoami` command.", + "name": "bmcp_coder_coder_get_authenticated_user", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a task.", + "name": "bmcp_coder_coder_get_task_logs", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the status of a task.", + "name": "bmcp_coder_coder_get_task_status", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a template version. This is useful to check whether a template version successfully imports or not.", + "name": "bmcp_coder_coder_get_template_version_logs", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.", + "name": "bmcp_coder_coder_get_workspace", + "parameters": { + "properties": { + "workspace_id": { + "description": "The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.", + "name": "bmcp_coder_coder_get_workspace_agent_logs", + "parameters": { + "properties": { + "workspace_agent_id": { + "type": "string" + } + }, + "required": [ + "workspace_agent_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.", + "name": "bmcp_coder_coder_get_workspace_build_logs", + "parameters": { + "properties": { + "workspace_build_id": { + "type": "string" + } + }, + "required": [ + "workspace_build_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List tasks.", + "name": "bmcp_coder_coder_list_tasks", + "parameters": { + "properties": { + "status": { + "description": "Optional filter by task status.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists templates for the authenticated user.", + "name": "bmcp_coder_coder_list_templates", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists workspaces for the authenticated user.", + "name": "bmcp_coder_coder_list_workspaces", + "parameters": { + "properties": { + "owner": { + "description": "The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Send input to a running task.", + "name": "bmcp_coder_coder_send_task_input", + "parameters": { + "properties": { + "input": { + "description": "The input to send to the task.", + "type": "string" + }, + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id", + "input" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.", + "name": "bmcp_coder_coder_template_version_parameters", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Update the active version of a template. This is helpful when iterating on templates.", + "name": "bmcp_coder_coder_update_template_active_version", + "parameters": { + "properties": { + "template_id": { + "type": "string" + }, + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_id", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.", + "name": "bmcp_coder_coder_upload_tar_file", + "parameters": { + "properties": { + "files": { + "description": "A map of file names to file contents.", + "type": "object" + } + }, + "required": [ + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh \u003cworkspace\u003e \u003ccommand\u003e' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"", + "name": "bmcp_coder_coder_workspace_bash", + "parameters": { + "properties": { + "background": { + "description": "Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.", + "type": "boolean" + }, + "command": { + "description": "The bash command to execute in the workspace.", + "type": "string" + }, + "timeout_ms": { + "default": 60000, + "description": "Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.", + "minimum": 1, + "type": "integer" + }, + "workspace": { + "description": "The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "command" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit a file in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_file", + "parameters": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "edits" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit one or more files in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_files", + "parameters": { + "properties": { + "files": { + "description": "An array of files to edit.", + "items": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + } + }, + "required": [ + "path", + "edits" + ], + "type": "object" + }, + "type": "array" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List the URLs of Coder apps running in a workspace for a single agent.", + "name": "bmcp_coder_coder_workspace_list_apps", + "parameters": { + "properties": { + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List directories in a workspace.", + "name": "bmcp_coder_coder_workspace_ls", + "parameters": { + "properties": { + "path": { + "description": "The absolute path of the directory in the workspace to list.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Fetch URLs that forward to the specified port.", + "name": "bmcp_coder_coder_workspace_port_forward", + "parameters": { + "properties": { + "port": { + "description": "The port to forward.", + "type": "number" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "port" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Read from a file in a workspace.", + "name": "bmcp_coder_coder_workspace_read_file", + "parameters": { + "properties": { + "limit": { + "description": "The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.", + "type": "integer" + }, + "offset": { + "description": "A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.", + "type": "integer" + }, + "path": { + "description": "The absolute path of the file to read in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n", + "name": "bmcp_coder_coder_workspace_write_file", + "parameters": { + "properties": { + "content": { + "description": "The base64-encoded bytes to write to the file.", + "type": "string" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "content" + ], + "type": "object" + }, + "strict": false, + "type": "function" + } + ], + "top_logprobs": 0, + "top_p": 0.98, + "truncation": "disabled", + "usage": { + "input_tokens": 6756, + "input_tokens_details": { + "cached_tokens": 6144 + }, + "output_tokens": 231, + "output_tokens_details": { + "reasoning_tokens": 43 + }, + "total_tokens": 6987 + }, + "user": null +} + diff --git a/aibridge/fixtures/openai/responses/blocking/single_injected_tool_error.txtar b/aibridge/fixtures/openai/responses/blocking/single_injected_tool_error.txtar new file mode 100644 index 0000000000..9e4c2716f2 --- /dev/null +++ b/aibridge/fixtures/openai/responses/blocking/single_injected_tool_error.txtar @@ -0,0 +1,1522 @@ +Coder MCP tools automatically injected, and errors invoking them are recorded. + +-- request -- +{ + "input": "delete the template with ID 03cb4fdd-8109-4a22-8e22-bb4975171395, don't ask for confirmation", + "model": "gpt-5.2" +} + + +-- non-streaming -- +{ + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1768650575, + "created_at": 1768650573, + "error": null, + "frequency_penalty": 0, + "id": "resp_06e2afba24b6b2ad00696b774d1df0819eaf1ec802bc8a2ca9", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "metadata": {}, + "model": "gpt-5.2-2025-12-11", + "object": "response", + "output": [ + { + "id": "rs_06e2afba24b6b2ad00696b774d6894819eb9ec114d25c713e4", + "summary": [], + "type": "reasoning" + }, + { + "arguments": "{\"template_id\":\"03cb4fdd-8109-4a22-8e22-bb4975171395\"}", + "call_id": "call_ITNAVLCwsZSEAlQHq8C8bS5L", + "id": "fc_06e2afba24b6b2ad00696b774f22f8819ead7d3f3eb4e080ea", + "name": "bmcp_coder_coder_delete_template", + "status": "completed", + "type": "function_call" + } + ], + "parallel_tool_calls": false, + "presence_penalty": 0, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": "high", + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "status": "completed", + "store": true, + "temperature": 1, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "description": "Create a task.", + "name": "bmcp_coder_coder_create_task", + "parameters": { + "properties": { + "input": { + "description": "Input/prompt for the task.", + "type": "string" + }, + "template_version_id": { + "description": "ID of the template version to create the task from.", + "type": "string" + }, + "template_version_preset_id": { + "description": "Optional ID of the template version preset to create the task from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.", + "type": "string" + } + }, + "required": [ + "input", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template in Coder. First, you must create a template version.", + "name": "bmcp_coder_coder_create_template", + "parameters": { + "properties": { + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon": { + "description": "A URL to an icon to use.", + "type": "string" + }, + "name": { + "type": "string" + }, + "version_id": { + "description": "The ID of the version to use.", + "type": "string" + } + }, + "required": [ + "name", + "display_name", + "description", + "version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\u003cterraform-spec\u003e\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"\u0026\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\u003c/terraform-spec\u003e\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\u003caws-ec2-instance\u003e\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\u003c/aws-ec2-instance\u003e\n\n\u003cgcp-vm-instance\u003e\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = \u003c\u003cEOMETA\n#!/usr/bin/env sh\nset -eux\n\n# If user does not exist, create it and set up passwordless sudo\nif ! id -u \"${local.linux_user}\" \u003e/dev/null 2\u003e\u00261; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" \u003e /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\u003c/gcp-vm-instance\u003e\n\n\u003cazure-vm-instance\u003e\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\u003c/azure-vm-instance\u003e\n\n\u003cdocker-container\u003e\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\u003c/docker-container\u003e\n\n\u003ckubernetes-pod\u003e\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\u003c/kubernetes-pod\u003e\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n", + "name": "bmcp_coder_coder_create_template_version", + "parameters": { + "properties": { + "file_id": { + "type": "string" + }, + "template_id": { + "type": "string" + } + }, + "required": [ + "file_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace", + "parameters": { + "properties": { + "name": { + "description": "Name of the workspace to create.", + "type": "string" + }, + "rich_parameters": { + "description": "Key/value pairs of rich parameters to pass to the template version to create the workspace.", + "type": "object" + }, + "template_version_id": { + "description": "ID of the template version to create the workspace from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.", + "type": "string" + } + }, + "required": [ + "user", + "template_version_id", + "name", + "rich_parameters" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace_build", + "parameters": { + "properties": { + "template_version_id": { + "description": "(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.", + "type": "string" + }, + "transition": { + "description": "The transition to perform. Must be one of: start, stop, delete", + "enum": [ + "start", + "stop", + "delete" + ], + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "transition" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a task.", + "name": "bmcp_coder_coder_delete_task", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a template. This is irreversible.", + "name": "bmcp_coder_coder_delete_template", + "parameters": { + "properties": { + "template_id": { + "type": "string" + } + }, + "required": [ + "template_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the currently authenticated user, similar to the `whoami` command.", + "name": "bmcp_coder_coder_get_authenticated_user", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a task.", + "name": "bmcp_coder_coder_get_task_logs", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the status of a task.", + "name": "bmcp_coder_coder_get_task_status", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a template version. This is useful to check whether a template version successfully imports or not.", + "name": "bmcp_coder_coder_get_template_version_logs", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.", + "name": "bmcp_coder_coder_get_workspace", + "parameters": { + "properties": { + "workspace_id": { + "description": "The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.", + "name": "bmcp_coder_coder_get_workspace_agent_logs", + "parameters": { + "properties": { + "workspace_agent_id": { + "type": "string" + } + }, + "required": [ + "workspace_agent_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.", + "name": "bmcp_coder_coder_get_workspace_build_logs", + "parameters": { + "properties": { + "workspace_build_id": { + "type": "string" + } + }, + "required": [ + "workspace_build_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List tasks.", + "name": "bmcp_coder_coder_list_tasks", + "parameters": { + "properties": { + "status": { + "description": "Optional filter by task status.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists templates for the authenticated user.", + "name": "bmcp_coder_coder_list_templates", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists workspaces for the authenticated user.", + "name": "bmcp_coder_coder_list_workspaces", + "parameters": { + "properties": { + "owner": { + "description": "The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Send input to a running task.", + "name": "bmcp_coder_coder_send_task_input", + "parameters": { + "properties": { + "input": { + "description": "The input to send to the task.", + "type": "string" + }, + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id", + "input" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.", + "name": "bmcp_coder_coder_template_version_parameters", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Update the active version of a template. This is helpful when iterating on templates.", + "name": "bmcp_coder_coder_update_template_active_version", + "parameters": { + "properties": { + "template_id": { + "type": "string" + }, + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_id", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.", + "name": "bmcp_coder_coder_upload_tar_file", + "parameters": { + "properties": { + "files": { + "description": "A map of file names to file contents.", + "type": "object" + } + }, + "required": [ + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh \u003cworkspace\u003e \u003ccommand\u003e' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"", + "name": "bmcp_coder_coder_workspace_bash", + "parameters": { + "properties": { + "background": { + "description": "Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.", + "type": "boolean" + }, + "command": { + "description": "The bash command to execute in the workspace.", + "type": "string" + }, + "timeout_ms": { + "default": 60000, + "description": "Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.", + "minimum": 1, + "type": "integer" + }, + "workspace": { + "description": "The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "command" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit a file in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_file", + "parameters": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "edits" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit one or more files in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_files", + "parameters": { + "properties": { + "files": { + "description": "An array of files to edit.", + "items": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + } + }, + "required": [ + "path", + "edits" + ], + "type": "object" + }, + "type": "array" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List the URLs of Coder apps running in a workspace for a single agent.", + "name": "bmcp_coder_coder_workspace_list_apps", + "parameters": { + "properties": { + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List directories in a workspace.", + "name": "bmcp_coder_coder_workspace_ls", + "parameters": { + "properties": { + "path": { + "description": "The absolute path of the directory in the workspace to list.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Fetch URLs that forward to the specified port.", + "name": "bmcp_coder_coder_workspace_port_forward", + "parameters": { + "properties": { + "port": { + "description": "The port to forward.", + "type": "number" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "port" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Read from a file in a workspace.", + "name": "bmcp_coder_coder_workspace_read_file", + "parameters": { + "properties": { + "limit": { + "description": "The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.", + "type": "integer" + }, + "offset": { + "description": "A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.", + "type": "integer" + }, + "path": { + "description": "The absolute path of the file to read in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n", + "name": "bmcp_coder_coder_workspace_write_file", + "parameters": { + "properties": { + "content": { + "description": "The base64-encoded bytes to write to the file.", + "type": "string" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "content" + ], + "type": "object" + }, + "strict": false, + "type": "function" + } + ], + "top_logprobs": 0, + "top_p": 0.98, + "truncation": "disabled", + "usage": { + "input_tokens": 6377, + "input_tokens_details": { + "cached_tokens": 6144 + }, + "output_tokens": 119, + "output_tokens_details": { + "reasoning_tokens": 70 + }, + "total_tokens": 6496 + }, + "user": null +} + + +-- non-streaming/tool-call -- +{ + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1768650579, + "created_at": 1768650576, + "error": null, + "frequency_penalty": 0, + "id": "resp_06e2afba24b6b2ad00696b775044e8819ea14840698ef966e2", + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "metadata": {}, + "model": "gpt-5.2-2025-12-11", + "object": "response", + "output": [ + { + "id": "rs_06e2afba24b6b2ad00696b7750c35c819e860aa1438936bad6", + "summary": [], + "type": "reasoning" + }, + { + "content": [ + { + "annotations": [], + "logprobs": [], + "text": "I couldn’t delete template `03cb4fdd-8109-4a22-8e22-bb4975171395` because the API returned:\n\n- `500 Internal error deleting template`\n- underlying cause: `unauthorized: rbac: forbidden`\n\nThis means the authenticated account I’m using doesn’t have RBAC permission to delete that template.\n\nIf you want, tell me which user/account should perform the deletion (or have an admin grant delete permission for that template), and I can retry once I have the right access.", + "type": "output_text" + } + ], + "id": "msg_06e2afba24b6b2ad00696b77516d58819e9bfdec585db91bd6", + "role": "assistant", + "status": "completed", + "type": "message" + } + ], + "parallel_tool_calls": false, + "presence_penalty": 0, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": "high", + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "status": "completed", + "store": true, + "temperature": 1, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "description": "Create a task.", + "name": "bmcp_coder_coder_create_task", + "parameters": { + "properties": { + "input": { + "description": "Input/prompt for the task.", + "type": "string" + }, + "template_version_id": { + "description": "ID of the template version to create the task from.", + "type": "string" + }, + "template_version_preset_id": { + "description": "Optional ID of the template version preset to create the task from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.", + "type": "string" + } + }, + "required": [ + "input", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template in Coder. First, you must create a template version.", + "name": "bmcp_coder_coder_create_template", + "parameters": { + "properties": { + "description": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon": { + "description": "A URL to an icon to use.", + "type": "string" + }, + "name": { + "type": "string" + }, + "version_id": { + "description": "The ID of the version to use.", + "type": "string" + } + }, + "required": [ + "name", + "display_name", + "description", + "version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\u003cterraform-spec\u003e\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"\u0026\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\u003c/terraform-spec\u003e\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\u003caws-ec2-instance\u003e\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\u003c/aws-ec2-instance\u003e\n\n\u003cgcp-vm-instance\u003e\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = \u003c\u003cEOMETA\n#!/usr/bin/env sh\nset -eux\n\n# If user does not exist, create it and set up passwordless sudo\nif ! id -u \"${local.linux_user}\" \u003e/dev/null 2\u003e\u00261; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" \u003e /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\u003c/gcp-vm-instance\u003e\n\n\u003cazure-vm-instance\u003e\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\u003c/azure-vm-instance\u003e\n\n\u003cdocker-container\u003e\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\u003c/docker-container\u003e\n\n\u003ckubernetes-pod\u003e\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\u003c/kubernetes-pod\u003e\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n", + "name": "bmcp_coder_coder_create_template_version", + "parameters": { + "properties": { + "file_id": { + "type": "string" + }, + "template_id": { + "type": "string" + } + }, + "required": [ + "file_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace", + "parameters": { + "properties": { + "name": { + "description": "Name of the workspace to create.", + "type": "string" + }, + "rich_parameters": { + "description": "Key/value pairs of rich parameters to pass to the template version to create the workspace.", + "type": "object" + }, + "template_version_id": { + "description": "ID of the template version to create the workspace from.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.", + "type": "string" + } + }, + "required": [ + "user", + "template_version_id", + "name", + "rich_parameters" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n", + "name": "bmcp_coder_coder_create_workspace_build", + "parameters": { + "properties": { + "template_version_id": { + "description": "(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.", + "type": "string" + }, + "transition": { + "description": "The transition to perform. Must be one of: start, stop, delete", + "enum": [ + "start", + "stop", + "delete" + ], + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "transition" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a task.", + "name": "bmcp_coder_coder_delete_task", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Delete a template. This is irreversible.", + "name": "bmcp_coder_coder_delete_template", + "parameters": { + "properties": { + "template_id": { + "type": "string" + } + }, + "required": [ + "template_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the currently authenticated user, similar to the `whoami` command.", + "name": "bmcp_coder_coder_get_authenticated_user", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a task.", + "name": "bmcp_coder_coder_get_task_logs", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the status of a task.", + "name": "bmcp_coder_coder_get_task_status", + "parameters": { + "properties": { + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a template version. This is useful to check whether a template version successfully imports or not.", + "name": "bmcp_coder_coder_get_template_version_logs", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.", + "name": "bmcp_coder_coder_get_workspace", + "parameters": { + "properties": { + "workspace_id": { + "description": "The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.", + "name": "bmcp_coder_coder_get_workspace_agent_logs", + "parameters": { + "properties": { + "workspace_agent_id": { + "type": "string" + } + }, + "required": [ + "workspace_agent_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.", + "name": "bmcp_coder_coder_get_workspace_build_logs", + "parameters": { + "properties": { + "workspace_build_id": { + "type": "string" + } + }, + "required": [ + "workspace_build_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List tasks.", + "name": "bmcp_coder_coder_list_tasks", + "parameters": { + "properties": { + "status": { + "description": "Optional filter by task status.", + "type": "string" + }, + "user": { + "description": "Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists templates for the authenticated user.", + "name": "bmcp_coder_coder_list_templates", + "parameters": { + "properties": {}, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Lists workspaces for the authenticated user.", + "name": "bmcp_coder_coder_list_workspaces", + "parameters": { + "properties": { + "owner": { + "description": "The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.", + "type": "string" + } + }, + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Send input to a running task.", + "name": "bmcp_coder_coder_send_task_input", + "parameters": { + "properties": { + "input": { + "description": "The input to send to the task.", + "type": "string" + }, + "task_id": { + "description": "ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "task_id", + "input" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.", + "name": "bmcp_coder_coder_template_version_parameters", + "parameters": { + "properties": { + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Update the active version of a template. This is helpful when iterating on templates.", + "name": "bmcp_coder_coder_update_template_active_version", + "parameters": { + "properties": { + "template_id": { + "type": "string" + }, + "template_version_id": { + "type": "string" + } + }, + "required": [ + "template_id", + "template_version_id" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.", + "name": "bmcp_coder_coder_upload_tar_file", + "parameters": { + "properties": { + "files": { + "description": "A map of file names to file contents.", + "type": "object" + } + }, + "required": [ + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh \u003cworkspace\u003e \u003ccommand\u003e' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"", + "name": "bmcp_coder_coder_workspace_bash", + "parameters": { + "properties": { + "background": { + "description": "Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.", + "type": "boolean" + }, + "command": { + "description": "The bash command to execute in the workspace.", + "type": "string" + }, + "timeout_ms": { + "default": 60000, + "description": "Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.", + "minimum": 1, + "type": "integer" + }, + "workspace": { + "description": "The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "command" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit a file in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_file", + "parameters": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "edits" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Edit one or more files in a workspace.", + "name": "bmcp_coder_coder_workspace_edit_files", + "parameters": { + "properties": { + "files": { + "description": "An array of files to edit.", + "items": { + "properties": { + "edits": { + "description": "An array of edit operations.", + "items": { + "properties": { + "replace": { + "description": "The new string that replaces the old string.", + "type": "string" + }, + "search": { + "description": "The old string to replace.", + "type": "string" + } + }, + "required": [ + "search", + "replace" + ], + "type": "object" + }, + "type": "array" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + } + }, + "required": [ + "path", + "edits" + ], + "type": "object" + }, + "type": "array" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "files" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List the URLs of Coder apps running in a workspace for a single agent.", + "name": "bmcp_coder_coder_workspace_list_apps", + "parameters": { + "properties": { + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "List directories in a workspace.", + "name": "bmcp_coder_coder_workspace_ls", + "parameters": { + "properties": { + "path": { + "description": "The absolute path of the directory in the workspace to list.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Fetch URLs that forward to the specified port.", + "name": "bmcp_coder_coder_workspace_port_forward", + "parameters": { + "properties": { + "port": { + "description": "The port to forward.", + "type": "number" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "workspace", + "port" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Read from a file in a workspace.", + "name": "bmcp_coder_coder_workspace_read_file", + "parameters": { + "properties": { + "limit": { + "description": "The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.", + "type": "integer" + }, + "offset": { + "description": "A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.", + "type": "integer" + }, + "path": { + "description": "The absolute path of the file to read in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace" + ], + "type": "object" + }, + "strict": false, + "type": "function" + }, + { + "description": "Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n", + "name": "bmcp_coder_coder_workspace_write_file", + "parameters": { + "properties": { + "content": { + "description": "The base64-encoded bytes to write to the file.", + "type": "string" + }, + "path": { + "description": "The absolute path of the file to write in the workspace.", + "type": "string" + }, + "workspace": { + "description": "The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.", + "type": "string" + } + }, + "required": [ + "path", + "workspace", + "content" + ], + "type": "object" + }, + "strict": false, + "type": "function" + } + ], + "top_logprobs": 0, + "top_p": 0.98, + "truncation": "disabled", + "usage": { + "input_tokens": 6539, + "input_tokens_details": { + "cached_tokens": 6144 + }, + "output_tokens": 144, + "output_tokens_details": { + "reasoning_tokens": 28 + }, + "total_tokens": 6683 + }, + "user": null +} + diff --git a/aibridge/fixtures/openai/responses/blocking/summary_and_commentary_builtin_tool.txtar b/aibridge/fixtures/openai/responses/blocking/summary_and_commentary_builtin_tool.txtar new file mode 100644 index 0000000000..15082c36ed --- /dev/null +++ b/aibridge/fixtures/openai/responses/blocking/summary_and_commentary_builtin_tool.txtar @@ -0,0 +1,146 @@ +Both a reasoning summary and a commentary message before a function_call. + +-- request -- +{ + "input": [ + { + "role": "user", + "content": "Is 3 + 5 a prime number? Use the add function to calculate the sum." + } + ], + "model": "gpt-5.4", + "stream": false, + "tools": [ + { + "type": "function", + "name": "add", + "description": "Add two numbers together.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ] + } + } + ] +} + +-- non-streaming -- +{ + "id": "resp_1bba3bc54ed351c41270c26831354d920fcc75088476e53de6", + "object": "response", + "created_at": 1773229900, + "status": "completed", + "background": false, + "completed_at": 1773229905, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-5.4-2026-03-05", + "output": [ + { + "id": "rs_1bba3bc54ed351c41270c26831908d920fcc75088476e53de6", + "type": "reasoning", + "status": "completed", + "encrypted_content": "gAAAAA==", + "summary": [ + { + "type": "summary_text", + "text": "I need to add 3 and 5 to check primality." + } + ] + }, + { + "id": "msg_1bba3bc54ed351c41270c26831a09d920fdd86199587f64ef7", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "text": "Let me calculate the sum first using the add function." + } + ], + "phase": "commentary", + "role": "assistant" + }, + { + "id": "fc_1bba3bc54ed351c41270c26831b0ad920fee97200698074f08", + "type": "function_call", + "status": "completed", + "arguments": "{\"a\":3,\"b\":5}", + "call_id": "call_B9UjYX01Lvvv1XwjDsdmRW3f", + "name": "add" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": "xhigh", + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": false, + "temperature": 1.0, + "text": { + "format": { + "type": "text" + }, + "verbosity": "low" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "function", + "description": "Add two numbers together.", + "name": "add", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ], + "additionalProperties": false + }, + "strict": true + } + ], + "top_logprobs": 0, + "top_p": 0.98, + "truncation": "disabled", + "usage": { + "input_tokens": 58, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 35, + "output_tokens_details": { + "reasoning_tokens": 10 + }, + "total_tokens": 93 + }, + "user": null, + "metadata": {} +} diff --git a/aibridge/fixtures/openai/responses/blocking/wrong_response_format.txtar b/aibridge/fixtures/openai/responses/blocking/wrong_response_format.txtar new file mode 100644 index 0000000000..3c4265d33b --- /dev/null +++ b/aibridge/fixtures/openai/responses/blocking/wrong_response_format.txtar @@ -0,0 +1,39 @@ +-- request -- +{ + "input": "hello", + "model": "gpt-6.7" +} + +-- non-streaming -- +{ + "id": "resp_0388c79043df3e3400695f9f83cd6481959062cec6830d8d51", + "object": "response", + "created_at": 1767874435, + "status": "completed", + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1767874436, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "msg_0388c79043df3e3400695f9f8447a08195af2ef951966823c4", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "This json is formatted wrong" + } + ], + "role": "assistant" + } + ], diff --git a/aibridge/fixtures/openai/responses/streaming/builtin_tool.txtar b/aibridge/fixtures/openai/responses/streaming/builtin_tool.txtar new file mode 100644 index 0000000000..98793f3b79 --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/builtin_tool.txtar @@ -0,0 +1,98 @@ +-- request -- +{ + "input": [ + { + "role": "user", + "content": "Is 3 + 5 a prime number? Use the add function to calculate the sum." + } + ], + "model": "gpt-4.1", + "stream": true, + "tools": [ + { + "type": "function", + "name": "add", + "description": "Add two numbers together.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ] + } + } + ] +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_0c3fb28cfcf463a500695fa2f0239481a095ec6ce3dfe4d458","object":"response","created_at":1767875312,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Add two numbers together.","name":"add","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_0c3fb28cfcf463a500695fa2f0239481a095ec6ce3dfe4d458","object":"response","created_at":1767875312,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Add two numbers together.","name":"add","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"rs_0c3fb28cfcf463a500695fa2f0a0a881a0890103ba88b0628e","type":"reasoning","status":"in_progress","summary":[]},"output_index":0,"sequence_number":2} + +event: response.reasoning_summary_part.added +data: {"type":"response.reasoning_summary_part.added","item_id":"rs_0c3fb28cfcf463a500695fa2f0a0a881a0890103ba88b0628e","output_index":0,"part":{"type":"summary_text","text":""},"summary_index":0,"sequence_number":3} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","item_id":"rs_0c3fb28cfcf463a500695fa2f0a0a881a0890103ba88b0628e","output_index":0,"summary_index":0,"delta":"The user wants to add 3 and 5. Let me call the add function.","sequence_number":4} + +event: response.reasoning_summary_text.done +data: {"type":"response.reasoning_summary_text.done","item_id":"rs_0c3fb28cfcf463a500695fa2f0a0a881a0890103ba88b0628e","output_index":0,"summary_index":0,"text":"The user wants to add 3 and 5. Let me call the add function.","sequence_number":5} + +event: response.reasoning_summary_part.done +data: {"type":"response.reasoning_summary_part.done","item_id":"rs_0c3fb28cfcf463a500695fa2f0a0a881a0890103ba88b0628e","output_index":0,"part":{"type":"summary_text","text":"The user wants to add 3 and 5. Let me call the add function."},"summary_index":0,"sequence_number":6} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"rs_0c3fb28cfcf463a500695fa2f0a0a881a0890103ba88b0628e","type":"reasoning","status":"completed","summary":[{"type":"summary_text","text":"The user wants to add 3 and 5. Let me call the add function."}]},"output_index":0,"sequence_number":7} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","type":"function_call","status":"in_progress","arguments":"","call_id":"call_7VaiUXZYuuuwWwviCrckxq6t","name":"add"},"output_index":1,"sequence_number":8} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"{\"","item_id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","obfuscation":"gWZHP8i4lSgQYT","output_index":1,"sequence_number":9} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"a","item_id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","obfuscation":"yC1iubuqc098ZSH","output_index":1,"sequence_number":10} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"\":","item_id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","obfuscation":"G17nNbWUcJkqA2","output_index":1,"sequence_number":11} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"3","item_id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","obfuscation":"Mj71L4eeLZbIEFU","output_index":1,"sequence_number":12} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":",\"","item_id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","obfuscation":"ZchcCauvlPtVc7","output_index":1,"sequence_number":13} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"b","item_id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","obfuscation":"gWLYMrsBI3ZHKVP","output_index":1,"sequence_number":14} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"\":","item_id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","obfuscation":"n4iUzpnbPE4DnO","output_index":1,"sequence_number":15} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"5","item_id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","obfuscation":"23mO3rxkXqDOi6g","output_index":1,"sequence_number":16} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"}","item_id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","obfuscation":"AQnBsNz7GqkdylH","output_index":1,"sequence_number":17} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","arguments":"{\"a\":3,\"b\":5}","item_id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","output_index":1,"sequence_number":18} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","type":"function_call","status":"completed","arguments":"{\"a\":3,\"b\":5}","call_id":"call_7VaiUXZYuuuwWwviCrckxq6t","name":"add"},"output_index":1,"sequence_number":19} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_0c3fb28cfcf463a500695fa2f0239481a095ec6ce3dfe4d458","object":"response","created_at":1767875312,"status":"completed","background":false,"completed_at":1767875312,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[{"id":"rs_0c3fb28cfcf463a500695fa2f0a0a881a0890103ba88b0628e","type":"reasoning","status":"completed","summary":[{"type":"summary_text","text":"The user wants to add 3 and 5. Let me call the add function."}]},{"id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","type":"function_call","status":"completed","arguments":"{\"a\":3,\"b\":5}","call_id":"call_7VaiUXZYuuuwWwviCrckxq6t","name":"add"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Add two numbers together.","name":"add","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":58,"input_tokens_details":{"cached_tokens":0},"output_tokens":18,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":76},"user":null,"metadata":{}},"sequence_number":20} + diff --git a/aibridge/fixtures/openai/responses/streaming/cached_input_tokens.txtar b/aibridge/fixtures/openai/responses/streaming/cached_input_tokens.txtar new file mode 100644 index 0000000000..cc908d5abd --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/cached_input_tokens.txtar @@ -0,0 +1,47 @@ +-- request -- +{ + "model": "gpt-5.2-codex", + "input": "Test cached input tokens.", + "stream": true +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_05080461b406f3f501696a1409d34c8195a40ff4b092145c35","object":"response","created_at":1768559625,"status":"in_progress","background":false,"completed_at":null,"error":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.2-codex","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":"medium","summary":"detailed"},"service_tier":"auto","store":false,"temperature":1.0,"tool_choice":"auto","tools":[],"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_05080461b406f3f501696a1409d34c8195a40ff4b092145c35","object":"response","created_at":1768559625,"status":"in_progress","background":false,"completed_at":null,"error":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.2-codex","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":"medium","summary":"detailed"},"service_tier":"auto","store":false,"temperature":1.0,"tool_choice":"auto","tools":[],"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"msg_05080461b406f3f501696a140a70d88195a2ce4c1a4eb39696","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_05080461b406f3f501696a140a70d88195a2ce4c1a4eb39696","output_index":0,"part":{"type":"output_text","annotations":[],"text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"Test","item_id":"msg_05080461b406f3f501696a140a70d88195a2ce4c1a4eb39696","output_index":0,"sequence_number":4} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" response","item_id":"msg_05080461b406f3f501696a140a70d88195a2ce4c1a4eb39696","output_index":0,"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" with","item_id":"msg_05080461b406f3f501696a140a70d88195a2ce4c1a4eb39696","output_index":0,"sequence_number":6} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" cached","item_id":"msg_05080461b406f3f501696a140a70d88195a2ce4c1a4eb39696","output_index":0,"sequence_number":7} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" tokens.","item_id":"msg_05080461b406f3f501696a140a70d88195a2ce4c1a4eb39696","output_index":0,"sequence_number":8} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_05080461b406f3f501696a140a70d88195a2ce4c1a4eb39696","output_index":0,"text":"Test response with cached tokens.","sequence_number":9} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_05080461b406f3f501696a140a70d88195a2ce4c1a4eb39696","output_index":0,"part":{"type":"output_text","annotations":[],"text":"Test response with cached tokens."},"sequence_number":10} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"msg_05080461b406f3f501696a140a70d88195a2ce4c1a4eb39696","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Test response with cached tokens."}],"role":"assistant"},"output_index":0,"sequence_number":11} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_05080461b406f3f501696a1409d34c8195a40ff4b092145c35","object":"response","created_at":1768559625,"status":"completed","background":false,"completed_at":1768559627,"error":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.2-codex","output":[{"id":"msg_05080461b406f3f501696a140a70d88195a2ce4c1a4eb39696","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Test response with cached tokens."}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":"019bc657-f77b-7292-b5f4-2e8d6c2b0945","prompt_cache_retention":null,"reasoning":{"effort":"medium","summary":"detailed"},"service_tier":"default","store":false,"temperature":1.0,"tool_choice":"auto","tools":[],"truncation":"disabled","usage":{"input_tokens":16909,"input_tokens_details":{"cached_tokens":15744},"output_tokens":54,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":16963},"user":null,"metadata":{}},"sequence_number":12} + diff --git a/aibridge/fixtures/openai/responses/streaming/codex_example.txtar b/aibridge/fixtures/openai/responses/streaming/codex_example.txtar new file mode 100644 index 0000000000..356bfb5109 --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/codex_example.txtar @@ -0,0 +1,358 @@ +-- request -- +{ + "model": "gpt-5-codex", + "instructions": "You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.\n\n## General\n\n- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)\n\n## Editing constraints\n\n- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.\n- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like \"Assigns the value to the variable\", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.\n- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).\n- You may be in a dirty git worktree.\n * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.\n * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.\n * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.\n * If the changes are in unrelated files, just ignore them and don't revert them.\n- Do not amend a commit unless explicitly requested to do so.\n- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.\n- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.\n\n## Plan tool\n\nWhen using the planning tool:\n- Skip using the planning tool for straightforward tasks (roughly the easiest 25%).\n- Do not make single-step plans.\n- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.\n\n## Codex CLI harness, sandboxing, and approvals\n\nThe Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.\n\nFilesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:\n- **read-only**: The sandbox only permits reading files.\n- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.\n- **danger-full-access**: No filesystem sandboxing - all commands are permitted.\n\nNetwork sandboxing defines whether network can be accessed without approval. Options for `network_access` are:\n- **restricted**: Requires approval\n- **enabled**: No approval needed\n\nApprovals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are\n- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe \"read\" commands.\n- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.\n- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)\n- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.\n\nWhen you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:\n- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)\n- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.\n- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)\n- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.\n- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for\n- (for all of these, you should weigh alternative paths that do not require approval)\n\nWhen `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.\n\nYou will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.\n\nAlthough they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to \"never\", in which case never ask for approvals.\n\nWhen requesting approval to execute a command that will require escalated privileges:\n - Provide the `sandbox_permissions` parameter with the value `\"require_escalated\"`\n - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter\n\n## Special user requests\n\n- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.\n- If the user asks for a \"review\", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.\n\n## Presenting your work and final message\n\nYou are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.\n\n- Default: be very concise; friendly coding teammate tone.\n- Ask only when needed; suggest ideas; mirror the user's style.\n- For substantial work, summarize clearly; follow final‑answer formatting.\n- Skip heavy formatting for simple confirmations.\n- Don't dump large files you've written; reference paths only.\n- No \"save/copy this file\" - User is on the same machine.\n- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.\n- For code changes:\n * Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with \"summary\", just jump right in.\n * If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.\n * When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.\n- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.\n\n### Final answer structure and style guidelines\n\n- Plain text; CLI handles styling. Use structure only when it helps scanability.\n- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.\n- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.\n- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.\n- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.\n- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.\n- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no \"above/below\"; parallel wording.\n- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.\n- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.\n- File References: When referencing files in your response, make sure to include the relevant start line and always follow the below rules:\n * Use inline code to make file paths clickable.\n * Each reference should have a stand alone path. Even if it's the same file.\n * Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.\n * Line/column (1‑based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1).\n * Do not use URIs like file://, vscode://, or https://.\n * Do not provide range of lines\n * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5\n", + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "# AGENTS.md instructions for /some/directory\n\n\n## Skills\nThese skills are discovered at startup from multiple local sources. Each entry includes a name, description, and file path so you can open the source for full instructions.\n- skill-creator: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations. (file: /some/directory/.codex/skills/.system/skill-creator/SKILL.md)\n- skill-installer: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). (file: /some/directory/.codex/skills/.system/skill-installer/SKILL.md)\n- Discovery: Available skills are listed in project docs and may also appear in a runtime \"## Skills\" section (name + description + file path). These are the sources of truth; skill bodies live on disk at the listed paths.\n- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.\n- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.\n- How to use a skill (progressive disclosure):\n 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.\n 2) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.\n 3) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.\n 4) If `assets/` or templates exist, reuse them instead of recreating from scratch.\n- Description as trigger: The YAML `description` in `SKILL.md` is the primary trigger signal; rely on it to decide applicability. If unsure, ask a brief clarification before proceeding.\n- Coordination and sequencing:\n - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.\n - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.\n- Context hygiene:\n - Keep context small: summarize long sections instead of pasting them; only load extra files when needed.\n - Avoid deeply nested references; prefer one-hop files explicitly linked from `SKILL.md`.\n - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.\n- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.\n" + } + ] + }, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "" + } + ] + }, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hi" + } + ] + }, + { + "type": "reasoning", + "summary": [ + { + "type": "summary_text", + "text": "**Preparing to respond concisely**" + } + ], + "content": null, + "encrypted_content": "gAAAAABpZN9epJCKSvaN79ndV0tQiiSZ-vR3DbtdcYV2ISVmfvWOcTkA4l8xTAv_Oatb-7pfILV6Q1EeqC4leEPj6P3Oos1QsKIJicEAtb7B7XR3wTXi9Afksw2LLVz6u38Zhfgr7chx8vp_ZDgePhY8jVlw9bH3UMsoOk0oLhXMtwHc-s8HEKv3IyNoDoxUYVBZZdDMa2B_227IRgp1y15RFNr8Ikp9k4Ocp8Pp_i2fuItDls7OQ0aunC-x52f065Zu215tzLjjM9jkafVfsluf10Ru9EW_DKJWSX9FlRetRHS03-1ZdozCxtUoorCAK_Tworpy3H_QO8jS-5KocGSkdts_YfnE_6S0mLbpDUKi03Qk7VxzYf8n87tjgljk1EdOHkjGZHnHQSs6j6o7nXLOzA6Qh-rNkApt4iEQQ-gefXGfhp29iVuQFkNekIT9ahrR4y_KACfFOimwjY56bGl7ARaw1d_AXrY38I-UBBBSB977feX_TuPVFoTeW0fju3fcwhiXPuGi9OB7HB9BkcN6iGhmuIa7G1xxM0fSqyma0WZHQTfKxR8GL4ThhcWjvld-EFE5_19i26GGRoi8MYlIRyAfT8adKobQnV33btVza40snylXkU0NMn1BJBKvSn_U1G0vp3as8QV5t0cBUcCDUKm7FN3JYovcc1nQXbzYRVx5SFUVHbqc3RNZCTtVR2WaWSE3eA4MrLPRHkcjqz8jtTCPvp5LHFfr7cMHYlMpHYtlBj_Z-ZBuJ79mPgiWGATvcCjJvQFb9RMUVgwmxVnzH9yK7OsEPiJZM5Gb8OgEgetx6uQXYVUV2HNj5aBPvN1-hH2JXq_YOeEv2mq-PCsVvZtouSVQS2YUrGo_Fy57KKt1460HInyC0eVzzgMmOpN3AhRXQXGGBz0lVv0bqla3o9LtODqIzw==" + }, + { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hey there! What's up?" + } + ] + }, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hi" + } + ] + }, + { + "type": "reasoning", + "summary": [ + { + "type": "summary_text", + "text": "**Preparing a friendly response**" + } + ], + "content": null, + "encrypted_content": "gAAAAABpZOBE-CuwRlXLitYqt3khZxzaGJB-AsaZFGq20VA7PhYp8q6QoNo3PJ_PQnzfP8wkMP-vflysuecrBshC86Ps9HsBQ1j1ZgibAVg0oRNlG0U7VL6CX_YjiBuKmT5DI4TohIbwJeEnUt78E9_GJ24C1yS6M5YgoivZRI7Wztea9bpTWvSAUtIZR3V63yJ2g8TKPAqZRyxpW_HiLVdPHpjgvIeWfl03qj-u56qJmyqVFdzVJ-bhs7LtMUV23pDr-pfu5fDXsRqD9-x8r72uO0P8Q00crHaBRNGA4rOmN4yHzYaMGYHsIA8w60LMdYtKyoxgeGMuRGguzYk76xbTFb6OcxGW5KS_bsDeSCQI8cq1yTYqfNW3s9QSAWDsaW-nPSYdZrdxVTo8kgtD93iWolhrEjXz9OmSqTL3a3WQSHYptDw1jarE7mGmdbztHCWJB5eHtyO4lnxwOQ-pniYFvpdk8tTUkVmakgcp7wjkTj642wjnO0Y2N6BC7ejK6fuP5JVtIWmHiQv28UmvyjXvefKP84IAOBmbpRbWeHkxqOPJGuzwbN7VdYGoGTp_Bllv6_VQxXLCMz4DPdZ5BN8jF4_ZEtb1e3o72bo22wgDQf8oQ9Tcu42bBsffUbIZjlXcvvFmAZebHtFU5thrIt9i9Nzo8TaKt3TKFeQ3TTAITUw8SVtXWxDvqYAz0CfdirHTjM7WOHEUGpK8wCd8Uc_FsMGc2PWn4VTMI9WJ0iNPcb6SV_-jov2YCVEqBQLlT4YFSQubK5Xb6zJDE__c9mT3MYOvfNeiUU-i2xaAGiSzwx6HNPYtBgw3-vt0egPbiFa0WXfl57T7RuqO4WOZZkbp76X2ri90dXyxj2e-FOqSm_hqrcAsESaqdmj6AHk4Oinud3OxTba0" + }, + { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hi again! Anything you’d like to dive into today?" + } + ] + }, + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ], + "tools": [ + { + "type": "function", + "name": "shell_command", + "description": "Runs a shell command and returns its output.\n- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary.", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The shell script to execute in the user's default shell" + }, + "justification": { + "type": "string", + "description": "Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command." + }, + "login": { + "type": "boolean", + "description": "Whether to run the shell with login shell semantics. Defaults to true." + }, + "sandbox_permissions": { + "type": "string", + "description": "Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"." + }, + "timeout_ms": { + "type": "number", + "description": "The timeout for the command in milliseconds" + }, + "workdir": { + "type": "string", + "description": "The working directory to execute the command in" + } + }, + "required": [ + "command" + ], + "additionalProperties": false + } + }, + { + "type": "function", + "name": "list_mcp_resources", + "description": "Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "cursor": { + "type": "string", + "description": "Opaque cursor returned by a previous list_mcp_resources call for the same server." + }, + "server": { + "type": "string", + "description": "Optional MCP server name. When omitted, lists resources from every configured server." + } + }, + "additionalProperties": false + } + }, + { + "type": "function", + "name": "list_mcp_resource_templates", + "description": "Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "cursor": { + "type": "string", + "description": "Opaque cursor returned by a previous list_mcp_resource_templates call for the same server." + }, + "server": { + "type": "string", + "description": "Optional MCP server name. When omitted, lists resource templates from all configured servers." + } + }, + "additionalProperties": false + } + }, + { + "type": "function", + "name": "read_mcp_resource", + "description": "Read a specific resource from an MCP server given the server name and resource URI.", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "server": { + "type": "string", + "description": "MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources." + }, + "uri": { + "type": "string", + "description": "Resource URI to read. Must be one of the URIs returned by list_mcp_resources." + } + }, + "required": [ + "server", + "uri" + ], + "additionalProperties": false + } + }, + { + "type": "function", + "name": "update_plan", + "description": "Updates the task plan.\nProvide an optional explanation and a list of plan items, each with a step and status.\nAt most one step can be in_progress at a time.\n", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "explanation": { + "type": "string" + }, + "plan": { + "type": "array", + "items": { + "type": "object", + "properties": { + "status": { + "type": "string", + "description": "One of: pending, in_progress, completed" + }, + "step": { + "type": "string" + } + }, + "required": [ + "step", + "status" + ], + "additionalProperties": false + }, + "description": "The list of steps" + } + }, + "required": [ + "plan" + ], + "additionalProperties": false + } + }, + { + "type": "custom", + "name": "apply_patch", + "description": "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.", + "format": { + "type": "grammar", + "syntax": "lark", + "definition": "start: begin_patch hunk+ end_patch\nbegin_patch: \"*** Begin Patch\" LF\nend_patch: \"*** End Patch\" LF?\n\nhunk: add_hunk | delete_hunk | update_hunk\nadd_hunk: \"*** Add File: \" filename LF add_line+\ndelete_hunk: \"*** Delete File: \" filename LF\nupdate_hunk: \"*** Update File: \" filename LF change_move? change?\n\nfilename: /(.+)/\nadd_line: \"+\" /(.*)/ LF -> line\n\nchange_move: \"*** Move to: \" filename LF\nchange: (change_context | change_line)+ eof_line?\nchange_context: (\"@@\" | \"@@ \" /(.+)/) LF\nchange_line: (\"+\" | \"-\" | \" \") /(.*)/ LF\neof_line: \"*** End of File\" LF\n\n%import common.LF\n" + } + }, + { + "type": "function", + "name": "view_image", + "description": "Attach a local image (by filesystem path) to the conversation context for this turn.", + "strict": false, + "parameters": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Local filesystem path to an image file" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + } + } + ], + "tool_choice": "auto", + "parallel_tool_calls": false, + "reasoning": { + "effort": "medium", + "summary": "auto" + }, + "store": false, + "stream": true, + "include": [ + "reasoning.encrypted_content" + ], + "prompt_cache_key": "00000000-1111-1111-8888-000000000000" +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_0e172b76542a9100016964f7e63d888191a2a28cb2ba0ab6d3","object":"response","created_at":1768224742,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":"You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.\n\n## General\n\n- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)\n\n## Editing constraints\n\n- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.\n- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like \"Assigns the value to the variable\", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.\n- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).\n- You may be in a dirty git worktree.\n * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.\n * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.\n * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.\n * If the changes are in unrelated files, just ignore them and don't revert them.\n- Do not amend a commit unless explicitly requested to do so.\n- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.\n- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.\n\n## Plan tool\n\nWhen using the planning tool:\n- Skip using the planning tool for straightforward tasks (roughly the easiest 25%).\n- Do not make single-step plans.\n- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.\n\n## Codex CLI harness, sandboxing, and approvals\n\nThe Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.\n\nFilesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:\n- **read-only**: The sandbox only permits reading files.\n- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.\n- **danger-full-access**: No filesystem sandboxing - all commands are permitted.\n\nNetwork sandboxing defines whether network can be accessed without approval. Options for `network_access` are:\n- **restricted**: Requires approval\n- **enabled**: No approval needed\n\nApprovals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are\n- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe \"read\" commands.\n- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.\n- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)\n- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.\n\nWhen you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:\n- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)\n- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.\n- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)\n- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.\n- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for\n- (for all of these, you should weigh alternative paths that do not require approval)\n\nWhen `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.\n\nYou will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.\n\nAlthough they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to \"never\", in which case never ask for approvals.\n\nWhen requesting approval to execute a command that will require escalated privileges:\n - Provide the `sandbox_permissions` parameter with the value `\"require_escalated\"`\n - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter\n\n## Special user requests\n\n- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.\n- If the user asks for a \"review\", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.\n\n## Presenting your work and final message\n\nYou are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.\n\n- Default: be very concise; friendly coding teammate tone.\n- Ask only when needed; suggest ideas; mirror the user's style.\n- For substantial work, summarize clearly; follow final‑answer formatting.\n- Skip heavy formatting for simple confirmations.\n- Don't dump large files you've written; reference paths only.\n- No \"save/copy this file\" - User is on the same machine.\n- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.\n- For code changes:\n * Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with \"summary\", just jump right in.\n * If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.\n * When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.\n- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.\n\n### Final answer structure and style guidelines\n\n- Plain text; CLI handles styling. Use structure only when it helps scanability.\n- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.\n- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.\n- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.\n- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.\n- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.\n- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no \"above/below\"; parallel wording.\n- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.\n- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.\n- File References: When referencing files in your response, make sure to include the relevant start line and always follow the below rules:\n * Use inline code to make file paths clickable.\n * Each reference should have a stand alone path. Even if it's the same file.\n * Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.\n * Line/column (1‑based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1).\n * Do not use URIs like file://, vscode://, or https://.\n * Do not provide range of lines\n * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5\n","max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5-codex","output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":"019bb208-80ac-74e3-880f-d18ae887f7da","prompt_cache_retention":null,"reasoning":{"effort":"medium","summary":"detailed"},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Runs a shell command and returns its output.\n- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary.","name":"shell_command","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The shell script to execute in the user's default shell"},"justification":{"type":"string","description":"Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command."},"login":{"type":"boolean","description":"Whether to run the shell with login shell semantics. Defaults to true."},"sandbox_permissions":{"type":"string","description":"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."},"timeout_ms":{"type":"number","description":"The timeout for the command in milliseconds"},"workdir":{"type":"string","description":"The working directory to execute the command in"}},"required":["command"],"additionalProperties":false},"strict":false},{"type":"function","description":"Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.","name":"list_mcp_resources","parameters":{"type":"object","properties":{"cursor":{"type":"string","description":"Opaque cursor returned by a previous list_mcp_resources call for the same server."},"server":{"type":"string","description":"Optional MCP server name. When omitted, lists resources from every configured server."}},"additionalProperties":false},"strict":false},{"type":"function","description":"Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.","name":"list_mcp_resource_templates","parameters":{"type":"object","properties":{"cursor":{"type":"string","description":"Opaque cursor returned by a previous list_mcp_resource_templates call for the same server."},"server":{"type":"string","description":"Optional MCP server name. When omitted, lists resource templates from all configured servers."}},"additionalProperties":false},"strict":false},{"type":"function","description":"Read a specific resource from an MCP server given the server name and resource URI.","name":"read_mcp_resource","parameters":{"type":"object","properties":{"server":{"type":"string","description":"MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources."},"uri":{"type":"string","description":"Resource URI to read. Must be one of the URIs returned by list_mcp_resources."}},"required":["server","uri"],"additionalProperties":false},"strict":false},{"type":"function","description":"Updates the task plan.\nProvide an optional explanation and a list of plan items, each with a step and status.\nAt most one step can be in_progress at a time.\n","name":"update_plan","parameters":{"type":"object","properties":{"explanation":{"type":"string"},"plan":{"type":"array","items":{"type":"object","properties":{"status":{"type":"string","description":"One of: pending, in_progress, completed"},"step":{"type":"string"}},"required":["step","status"],"additionalProperties":false},"description":"The list of steps"}},"required":["plan"],"additionalProperties":false},"strict":false},{"type":"function","description":"Attach a local image (by filesystem path) to the conversation context for this turn.","name":"view_image","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Local filesystem path to an image file"}},"required":["path"],"additionalProperties":false},"strict":false},{"type":"custom","description":"Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.","format":{"type":"grammar","definition":"start: begin_patch hunk+ end_patch\nbegin_patch: \"*** Begin Patch\" LF\nend_patch: \"*** End Patch\" LF?\n\nhunk: add_hunk | delete_hunk | update_hunk\nadd_hunk: \"*** Add File: \" filename LF add_line+\ndelete_hunk: \"*** Delete File: \" filename LF\nupdate_hunk: \"*** Update File: \" filename LF change_move? change?\n\nfilename: /(.+)/\nadd_line: \"+\" /(.*)/ LF -> line\n\nchange_move: \"*** Move to: \" filename LF\nchange: (change_context | change_line)+ eof_line?\nchange_context: (\"@@\" | \"@@ \" /(.+)/) LF\nchange_line: (\"+\" | \"-\" | \" \") /(.*)/ LF\neof_line: \"*** End of File\" LF\n\n%import common.LF\n","syntax":"lark"},"name":"apply_patch"}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_0e172b76542a9100016964f7e63d888191a2a28cb2ba0ab6d3","object":"response","created_at":1768224742,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":"You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.\n\n## General\n\n- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)\n\n## Editing constraints\n\n- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.\n- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like \"Assigns the value to the variable\", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.\n- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).\n- You may be in a dirty git worktree.\n * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.\n * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.\n * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.\n * If the changes are in unrelated files, just ignore them and don't revert them.\n- Do not amend a commit unless explicitly requested to do so.\n- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.\n- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.\n\n## Plan tool\n\nWhen using the planning tool:\n- Skip using the planning tool for straightforward tasks (roughly the easiest 25%).\n- Do not make single-step plans.\n- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.\n\n## Codex CLI harness, sandboxing, and approvals\n\nThe Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.\n\nFilesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:\n- **read-only**: The sandbox only permits reading files.\n- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.\n- **danger-full-access**: No filesystem sandboxing - all commands are permitted.\n\nNetwork sandboxing defines whether network can be accessed without approval. Options for `network_access` are:\n- **restricted**: Requires approval\n- **enabled**: No approval needed\n\nApprovals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are\n- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe \"read\" commands.\n- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.\n- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)\n- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.\n\nWhen you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:\n- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)\n- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.\n- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)\n- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.\n- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for\n- (for all of these, you should weigh alternative paths that do not require approval)\n\nWhen `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.\n\nYou will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.\n\nAlthough they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to \"never\", in which case never ask for approvals.\n\nWhen requesting approval to execute a command that will require escalated privileges:\n - Provide the `sandbox_permissions` parameter with the value `\"require_escalated\"`\n - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter\n\n## Special user requests\n\n- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.\n- If the user asks for a \"review\", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.\n\n## Presenting your work and final message\n\nYou are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.\n\n- Default: be very concise; friendly coding teammate tone.\n- Ask only when needed; suggest ideas; mirror the user's style.\n- For substantial work, summarize clearly; follow final‑answer formatting.\n- Skip heavy formatting for simple confirmations.\n- Don't dump large files you've written; reference paths only.\n- No \"save/copy this file\" - User is on the same machine.\n- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.\n- For code changes:\n * Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with \"summary\", just jump right in.\n * If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.\n * When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.\n- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.\n\n### Final answer structure and style guidelines\n\n- Plain text; CLI handles styling. Use structure only when it helps scanability.\n- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.\n- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.\n- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.\n- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.\n- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.\n- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no \"above/below\"; parallel wording.\n- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.\n- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.\n- File References: When referencing files in your response, make sure to include the relevant start line and always follow the below rules:\n * Use inline code to make file paths clickable.\n * Each reference should have a stand alone path. Even if it's the same file.\n * Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.\n * Line/column (1‑based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1).\n * Do not use URIs like file://, vscode://, or https://.\n * Do not provide range of lines\n * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5\n","max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5-codex","output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":"019bb208-80ac-74e3-880f-d18ae887f7da","prompt_cache_retention":null,"reasoning":{"effort":"medium","summary":"detailed"},"safety_identifier":null,"service_tier":"auto","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Runs a shell command and returns its output.\n- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary.","name":"shell_command","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The shell script to execute in the user's default shell"},"justification":{"type":"string","description":"Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command."},"login":{"type":"boolean","description":"Whether to run the shell with login shell semantics. Defaults to true."},"sandbox_permissions":{"type":"string","description":"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."},"timeout_ms":{"type":"number","description":"The timeout for the command in milliseconds"},"workdir":{"type":"string","description":"The working directory to execute the command in"}},"required":["command"],"additionalProperties":false},"strict":false},{"type":"function","description":"Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.","name":"list_mcp_resources","parameters":{"type":"object","properties":{"cursor":{"type":"string","description":"Opaque cursor returned by a previous list_mcp_resources call for the same server."},"server":{"type":"string","description":"Optional MCP server name. When omitted, lists resources from every configured server."}},"additionalProperties":false},"strict":false},{"type":"function","description":"Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.","name":"list_mcp_resource_templates","parameters":{"type":"object","properties":{"cursor":{"type":"string","description":"Opaque cursor returned by a previous list_mcp_resource_templates call for the same server."},"server":{"type":"string","description":"Optional MCP server name. When omitted, lists resource templates from all configured servers."}},"additionalProperties":false},"strict":false},{"type":"function","description":"Read a specific resource from an MCP server given the server name and resource URI.","name":"read_mcp_resource","parameters":{"type":"object","properties":{"server":{"type":"string","description":"MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources."},"uri":{"type":"string","description":"Resource URI to read. Must be one of the URIs returned by list_mcp_resources."}},"required":["server","uri"],"additionalProperties":false},"strict":false},{"type":"function","description":"Updates the task plan.\nProvide an optional explanation and a list of plan items, each with a step and status.\nAt most one step can be in_progress at a time.\n","name":"update_plan","parameters":{"type":"object","properties":{"explanation":{"type":"string"},"plan":{"type":"array","items":{"type":"object","properties":{"status":{"type":"string","description":"One of: pending, in_progress, completed"},"step":{"type":"string"}},"required":["step","status"],"additionalProperties":false},"description":"The list of steps"}},"required":["plan"],"additionalProperties":false},"strict":false},{"type":"function","description":"Attach a local image (by filesystem path) to the conversation context for this turn.","name":"view_image","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Local filesystem path to an image file"}},"required":["path"],"additionalProperties":false},"strict":false},{"type":"custom","description":"Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.","format":{"type":"grammar","definition":"start: begin_patch hunk+ end_patch\nbegin_patch: \"*** Begin Patch\" LF\nend_patch: \"*** End Patch\" LF?\n\nhunk: add_hunk | delete_hunk | update_hunk\nadd_hunk: \"*** Add File: \" filename LF add_line+\ndelete_hunk: \"*** Delete File: \" filename LF\nupdate_hunk: \"*** Update File: \" filename LF change_move? change?\n\nfilename: /(.+)/\nadd_line: \"+\" /(.*)/ LF -> line\n\nchange_move: \"*** Move to: \" filename LF\nchange: (change_context | change_line)+ eof_line?\nchange_context: (\"@@\" | \"@@ \" /(.+)/) LF\nchange_line: (\"+\" | \"-\" | \" \") /(.*)/ LF\neof_line: \"*** End of File\" LF\n\n%import common.LF\n","syntax":"lark"},"name":"apply_patch"}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"rs_0e172b76542a9100016964f7e6c200819190235d871bc889a0","type":"reasoning","encrypted_content":"gAAAAABpZPfmkJqjMMJCSc9Ra2dP6rxC7Cov08cqVo35sBkIU0-BMHV63rl1Ey3eJ4VLEIRWpEQxPRXg305LdUDmyJB5bRTkB1UaSLwmQys5RN1QMzwPDsiYp_9QKBYQBPlEHayt7q6oTBxG8j3qsHXGFHq7QlZhxFGHzjOaYxHEDaEn7ephYo79nrAv-lGokKRpgcDgPH6sqSSHg9fI3mIRanRbSWPYH76I6AFM1LbalhCKJvDtEGq4X9ozL-ZoZoNmnHOY-fzCN9eaydMAnA9WGelRObGGjRXiJdNM-c-Hlo-GTgqRpC5MXYFESHyLtQP8m6_AX55Em_HP8BnBG3iOnOJ91yl2AXNB0GGw-WtRKpqycanWB2-1b9DFO7v-EHuHO7coLLrHIzRIWdkRLXkQbjjhn5gC0uT6jhVPcVX6NV2szs2v5CYeWc71ehRIwdTYorMsSTFRI3VHbf4oJtWKVTuptqhfbtFI87ftGOc-j3OtjTdFY0HxYzHgMxpU3D1ZtP8cJBP1NcwwqHCkvKHz_-v2kiUVC0nWmyzpbUM5V6v36m7OpdTWjv9GtYsREzjyxQboPIpmtYYgxZHXLNtGBpEGuVyk2OoOd3zfJ9rIdkSwNjuDA4udBw-x2WAF030YBjoDykXbR-jR9zp7v6rCBV_yQLYMdYnr8tSF1hZH4Ddlh09RLaET0o6Gy32qZs5NMHioULy_L0FOrSun4HZAHTyIxOPpbNTrITSYpJNN2WF-quOGaD4z_j3liiP0OG45StF9wYV0F0OkmaR5XElhvx-HYhgwgIumUwxCBY9QNj40I7Mr21w=","summary":[]},"output_index":0,"sequence_number":2} + +event: response.reasoning_summary_part.added +data: {"type":"response.reasoning_summary_part.added","item_id":"rs_0e172b76542a9100016964f7e6c200819190235d871bc889a0","output_index":0,"part":{"type":"summary_text","text":""},"sequence_number":3,"summary_index":0} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","delta":"**Preparing","item_id":"rs_0e172b76542a9100016964f7e6c200819190235d871bc889a0","obfuscation":"OoWf9","output_index":0,"sequence_number":4,"summary_index":0} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","delta":" simple","item_id":"rs_0e172b76542a9100016964f7e6c200819190235d871bc889a0","obfuscation":"yjbkD1yPF","output_index":0,"sequence_number":5,"summary_index":0} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","delta":" response","item_id":"rs_0e172b76542a9100016964f7e6c200819190235d871bc889a0","obfuscation":"dmqaNFE","output_index":0,"sequence_number":6,"summary_index":0} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","delta":"**","item_id":"rs_0e172b76542a9100016964f7e6c200819190235d871bc889a0","obfuscation":"cFEMCdWxUF5tfz","output_index":0,"sequence_number":7,"summary_index":0} + +event: response.reasoning_summary_text.done +data: {"type":"response.reasoning_summary_text.done","item_id":"rs_0e172b76542a9100016964f7e6c200819190235d871bc889a0","output_index":0,"sequence_number":8,"summary_index":0,"text":"**Preparing simple response**"} + +event: response.reasoning_summary_part.done +data: {"type":"response.reasoning_summary_part.done","item_id":"rs_0e172b76542a9100016964f7e6c200819190235d871bc889a0","output_index":0,"part":{"type":"summary_text","text":"**Preparing simple response**"},"sequence_number":9,"summary_index":0} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"rs_0e172b76542a9100016964f7e6c200819190235d871bc889a0","type":"reasoning","encrypted_content":"gAAAAABpZPfnHaDoFAplBW0lmoPKADk06bztA5H9Pk6CEmeOLBtKMOG0x-Pe-K1Q1xrIIPOFDOEoqBrirPqnWWN68FTgIp_L9f0bvLpkxcWDZR3Uuv9UW4RTI69OHU7t2FlXEgYBvak0kxqvHToaYxOWBS28scHfBoWMSlkUfI5GA9cMlJ9V_P69SfVnSMtDYbNGFGth1sPoXAZz2OZp4bitnMRGJCqUrEO1H0ldfkJOEIB5r-k3tq1WkOox_segPnmF39J3dUWS8Q4xRk9Ggh-z7ZWx6pAfCKE-q4Z9pCduV_TSK9r8YKzlFHdIikIE1JzWpfgjhCiRS5NuI8YO55eml4g7bpOTGAMhc972n2ITsk6NBUNeIpGsWn6bQ-wCmj-cXIgVfAcbBwl4TNvy7fxZ612m6-SuGXTIyUSWYWRHrobto3f7aYgOp4sQda1pxKS3jWZPaWak-swFCEZXgGRS0PWtvmyjsvcB4FH0LKDqPgx17ohy2X-f5XUcTgkry094PGF8A8FkaFUP-GXuOd1LVJ3JpolNucyr-wSjCUnF2F8lOjfUU6DLpBiZBL9O1GKvgbgYZZTa8LH0K8-ywuAjqYfWQ2G0vfBTrWYFsaF1nMj6L1PGnsz7OvX0z4FwZcr5dcWJbwlfU3yO1Pir715D-4stYkQNzqjYE-qU-SXww4VeMjnyj9UKLdgRr9bx7aZY-QMmAu3rjJkjVHbF_Y71z3R7IW4KugQZI_Sa8OfJmGHHObe7oSgfsYb58TbnESxl66C7ASqWOejl9cF_QX60fFHGrvo5rhSjXkGk7uH1undT7aQMSHgfzMwJAOQqXSEsHrL0LnvRhFFYQB6Nx3dHnBNz4WhwVA==","summary":[{"type":"summary_text","text":"**Preparing simple response**"}]},"output_index":0,"sequence_number":10} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"msg_0e172b76542a9100016964f7e72ac4819194f4af4dffe5b676","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":1,"sequence_number":11} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0e172b76542a9100016964f7e72ac4819194f4af4dffe5b676","output_index":1,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":12} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"Hello","item_id":"msg_0e172b76542a9100016964f7e72ac4819194f4af4dffe5b676","logprobs":[],"obfuscation":"PQV6KvHghUK","output_index":1,"sequence_number":13} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"!","item_id":"msg_0e172b76542a9100016964f7e72ac4819194f4af4dffe5b676","logprobs":[],"obfuscation":"k7btWlgL8c626iX","output_index":1,"sequence_number":14} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Ready","item_id":"msg_0e172b76542a9100016964f7e72ac4819194f4af4dffe5b676","logprobs":[],"obfuscation":"1IPwzOkDGn","output_index":1,"sequence_number":15} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" when","item_id":"msg_0e172b76542a9100016964f7e72ac4819194f4af4dffe5b676","logprobs":[],"obfuscation":"Q1IAtELF2aW","output_index":1,"sequence_number":16} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" you","item_id":"msg_0e172b76542a9100016964f7e72ac4819194f4af4dffe5b676","logprobs":[],"obfuscation":"zjuSvuksUtKF","output_index":1,"sequence_number":17} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" are","item_id":"msg_0e172b76542a9100016964f7e72ac4819194f4af4dffe5b676","logprobs":[],"obfuscation":"9hYrMW6mZIsZ","output_index":1,"sequence_number":18} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0e172b76542a9100016964f7e72ac4819194f4af4dffe5b676","logprobs":[],"obfuscation":"xXBIl2HN7bmH6px","output_index":1,"sequence_number":19} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0e172b76542a9100016964f7e72ac4819194f4af4dffe5b676","logprobs":[],"output_index":1,"sequence_number":20,"text":"Hello! Ready when you are."} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0e172b76542a9100016964f7e72ac4819194f4af4dffe5b676","output_index":1,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"Hello! Ready when you are."},"sequence_number":21} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"msg_0e172b76542a9100016964f7e72ac4819194f4af4dffe5b676","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"Hello! Ready when you are."}],"role":"assistant"},"output_index":1,"sequence_number":22} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_0e172b76542a9100016964f7e63d888191a2a28cb2ba0ab6d3","object":"response","created_at":1768224742,"status":"completed","background":false,"completed_at":1768224743,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":"You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.\n\n## General\n\n- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)\n\n## Editing constraints\n\n- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.\n- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like \"Assigns the value to the variable\", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.\n- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).\n- You may be in a dirty git worktree.\n * NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.\n * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.\n * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.\n * If the changes are in unrelated files, just ignore them and don't revert them.\n- Do not amend a commit unless explicitly requested to do so.\n- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.\n- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.\n\n## Plan tool\n\nWhen using the planning tool:\n- Skip using the planning tool for straightforward tasks (roughly the easiest 25%).\n- Do not make single-step plans.\n- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.\n\n## Codex CLI harness, sandboxing, and approvals\n\nThe Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.\n\nFilesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:\n- **read-only**: The sandbox only permits reading files.\n- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.\n- **danger-full-access**: No filesystem sandboxing - all commands are permitted.\n\nNetwork sandboxing defines whether network can be accessed without approval. Options for `network_access` are:\n- **restricted**: Requires approval\n- **enabled**: No approval needed\n\nApprovals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are\n- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe \"read\" commands.\n- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.\n- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)\n- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.\n\nWhen you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:\n- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)\n- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.\n- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)\n- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.\n- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for\n- (for all of these, you should weigh alternative paths that do not require approval)\n\nWhen `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.\n\nYou will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.\n\nAlthough they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to \"never\", in which case never ask for approvals.\n\nWhen requesting approval to execute a command that will require escalated privileges:\n - Provide the `sandbox_permissions` parameter with the value `\"require_escalated\"`\n - Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter\n\n## Special user requests\n\n- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.\n- If the user asks for a \"review\", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.\n\n## Presenting your work and final message\n\nYou are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.\n\n- Default: be very concise; friendly coding teammate tone.\n- Ask only when needed; suggest ideas; mirror the user's style.\n- For substantial work, summarize clearly; follow final‑answer formatting.\n- Skip heavy formatting for simple confirmations.\n- Don't dump large files you've written; reference paths only.\n- No \"save/copy this file\" - User is on the same machine.\n- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.\n- For code changes:\n * Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with \"summary\", just jump right in.\n * If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.\n * When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.\n- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.\n\n### Final answer structure and style guidelines\n\n- Plain text; CLI handles styling. Use structure only when it helps scanability.\n- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.\n- Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent.\n- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.\n- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.\n- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.\n- Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no \"above/below\"; parallel wording.\n- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.\n- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.\n- File References: When referencing files in your response, make sure to include the relevant start line and always follow the below rules:\n * Use inline code to make file paths clickable.\n * Each reference should have a stand alone path. Even if it's the same file.\n * Accepted: absolute, workspace‑relative, a/ or b/ diff prefixes, or bare filename/suffix.\n * Line/column (1‑based, optional): :line[:column] or #Lline[Ccolumn] (column defaults to 1).\n * Do not use URIs like file://, vscode://, or https://.\n * Do not provide range of lines\n * Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\\repo\\project\\main.rs:12:5\n","max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5-codex","output":[{"id":"rs_0e172b76542a9100016964f7e6c200819190235d871bc889a0","type":"reasoning","encrypted_content":"gAAAAABpZPfn161F97aGv4oaf6SpDN7dwSgJrfoIPfX7fUE-j-KRRfqCQOHPhmnwHxgS5GEHwTs81RQr9SsZv9cKn1neM1fWnO7NXUgEpe6P_6pgvJJaV9IeFcfoGiWsvXmoMhBStBZHixFMCZSS5F5QCFXHj9jzwegh6Cma93uTgN-_rMmON9Gv793WBxKlGIoZ3wBlcx5IN5YdX54jaDoKvMEA-9j0vfaNAwCuftkuI52Iu2h6CF4picjBtQFpnZw7aVSR7v0r8HU9K6V2WKKc9D6jl8sNscF8fgh7lF7GFKVqLgMv9sMeyOfVGXoFOuXFRCRDevXP2M0YNekPl7H8tYBcxtbievlyBem4th6W7-DKSZk3h21R7lf3kI-snDOF4L06ncB0ycJ0LjWnXomjMT9aseA3LPRd4xcxUlQWL1SX8OvVBg57St1SwuCInnC0rhISD81LxerE69IlMqyftUMI0V0tNdGYF6haTXjAEGo667Yj-nUmXB25ppWOh5uktcXkHMZS1tfjdVcal_DG86nn9W4IGe9rkVvzuxSo5OYOGv2sJ-2IxCOkvvyUZM6WtEJw0CsnsCcKDuknaP-wSfk-5Ykp9o9iAPB4m6PsU0HPZSMcw_7d3lQBC1hKU-mOpaL2vGzY8FVYmI0Aam_pkY1tOEzdRJu39uDvhkT6FzKAUDb8yfxvtVTMHYTE18AJSaxSUQFDKA-vdpJFDze3e_j1THrxAjqWoMo9FpQcEMJSOiMRhJ5p-NzPXtEeYx41pPant6uffQOj0x3_zSjQZHboDhQ2I579yQHKoje4szJRBqEUhloz1GhmBn3OKE17R3HDY-zz14vYpT-IdMPULXGYD89PNw==","summary":[{"type":"summary_text","text":"**Preparing simple response**"}]},{"id":"msg_0e172b76542a9100016964f7e72ac4819194f4af4dffe5b676","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"Hello! Ready when you are."}],"role":"assistant"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":"019bb208-80ac-74e3-880f-d18ae887f7da","prompt_cache_retention":null,"reasoning":{"effort":"medium","summary":"detailed"},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Runs a shell command and returns its output.\n- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary.","name":"shell_command","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The shell script to execute in the user's default shell"},"justification":{"type":"string","description":"Only set if sandbox_permissions is \"require_escalated\". 1-sentence explanation of why we want to run this command."},"login":{"type":"boolean","description":"Whether to run the shell with login shell semantics. Defaults to true."},"sandbox_permissions":{"type":"string","description":"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."},"timeout_ms":{"type":"number","description":"The timeout for the command in milliseconds"},"workdir":{"type":"string","description":"The working directory to execute the command in"}},"required":["command"],"additionalProperties":false},"strict":false},{"type":"function","description":"Lists resources provided by MCP servers. Resources allow servers to share data that provides context to language models, such as files, database schemas, or application-specific information. Prefer resources over web search when possible.","name":"list_mcp_resources","parameters":{"type":"object","properties":{"cursor":{"type":"string","description":"Opaque cursor returned by a previous list_mcp_resources call for the same server."},"server":{"type":"string","description":"Optional MCP server name. When omitted, lists resources from every configured server."}},"additionalProperties":false},"strict":false},{"type":"function","description":"Lists resource templates provided by MCP servers. Parameterized resource templates allow servers to share data that takes parameters and provides context to language models, such as files, database schemas, or application-specific information. Prefer resource templates over web search when possible.","name":"list_mcp_resource_templates","parameters":{"type":"object","properties":{"cursor":{"type":"string","description":"Opaque cursor returned by a previous list_mcp_resource_templates call for the same server."},"server":{"type":"string","description":"Optional MCP server name. When omitted, lists resource templates from all configured servers."}},"additionalProperties":false},"strict":false},{"type":"function","description":"Read a specific resource from an MCP server given the server name and resource URI.","name":"read_mcp_resource","parameters":{"type":"object","properties":{"server":{"type":"string","description":"MCP server name exactly as configured. Must match the 'server' field returned by list_mcp_resources."},"uri":{"type":"string","description":"Resource URI to read. Must be one of the URIs returned by list_mcp_resources."}},"required":["server","uri"],"additionalProperties":false},"strict":false},{"type":"function","description":"Updates the task plan.\nProvide an optional explanation and a list of plan items, each with a step and status.\nAt most one step can be in_progress at a time.\n","name":"update_plan","parameters":{"type":"object","properties":{"explanation":{"type":"string"},"plan":{"type":"array","items":{"type":"object","properties":{"status":{"type":"string","description":"One of: pending, in_progress, completed"},"step":{"type":"string"}},"required":["step","status"],"additionalProperties":false},"description":"The list of steps"}},"required":["plan"],"additionalProperties":false},"strict":false},{"type":"function","description":"Attach a local image (by filesystem path) to the conversation context for this turn.","name":"view_image","parameters":{"type":"object","properties":{"path":{"type":"string","description":"Local filesystem path to an image file"}},"required":["path"],"additionalProperties":false},"strict":false},{"type":"custom","description":"Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.","format":{"type":"grammar","definition":"start: begin_patch hunk+ end_patch\nbegin_patch: \"*** Begin Patch\" LF\nend_patch: \"*** End Patch\" LF?\n\nhunk: add_hunk | delete_hunk | update_hunk\nadd_hunk: \"*** Add File: \" filename LF add_line+\ndelete_hunk: \"*** Delete File: \" filename LF\nupdate_hunk: \"*** Update File: \" filename LF change_move? change?\n\nfilename: /(.+)/\nadd_line: \"+\" /(.*)/ LF -> line\n\nchange_move: \"*** Move to: \" filename LF\nchange: (change_context | change_line)+ eof_line?\nchange_context: (\"@@\" | \"@@ \" /(.+)/) LF\nchange_line: (\"+\" | \"-\" | \" \") /(.*)/ LF\neof_line: \"*** End of File\" LF\n\n%import common.LF\n","syntax":"lark"},"name":"apply_patch"}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":4006,"input_tokens_details":{"cached_tokens":0},"output_tokens":13,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":4019},"user":null,"metadata":{}},"sequence_number":23} + diff --git a/aibridge/fixtures/openai/responses/streaming/commentary_builtin_tool.txtar b/aibridge/fixtures/openai/responses/streaming/commentary_builtin_tool.txtar new file mode 100644 index 0000000000..2f090f621c --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/commentary_builtin_tool.txtar @@ -0,0 +1,80 @@ +-- request -- +{ + "input": [ + { + "role": "user", + "content": "Is 3 + 5 a prime number? Use the add function to calculate the sum." + } + ], + "model": "gpt-5.4", + "stream": true, + "tools": [ + { + "type": "function", + "name": "add", + "description": "Add two numbers together.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ] + } + } + ] +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_0aba2ac43dc240b30169b15720243c819ebb64977365d42cf5","object":"response","created_at":1773229856,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":"xhigh","summary":null},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"low"},"tool_choice":"auto","tools":[{"type":"function","description":"Add two numbers together.","name":"add","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_0aba2ac43dc240b30169b15720243c819ebb64977365d42cf5","object":"response","created_at":1773229856,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":"xhigh","summary":null},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"low"},"tool_choice":"auto","tools":[{"type":"function","description":"Add two numbers together.","name":"add","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"rs_0aba2ac43dc240b30169b157208c88819e8238a91b5f7a919b","type":"reasoning","status":"in_progress","summary":[]},"output_index":0,"sequence_number":2} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"rs_0aba2ac43dc240b30169b157208c88819e8238a91b5f7a919b","type":"reasoning","status":"completed","encrypted_content":"gAAAAA==","summary":[]},"output_index":0,"sequence_number":3} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"msg_0aba2ac43dc240b30169b1572286d0819eb24b1d0f84c8fb3f","type":"message","status":"in_progress","content":[],"phase":"commentary","role":"assistant"},"output_index":1,"sequence_number":4} + +event: response.content_part.added +data: {"type":"response.content_part.added","item_id":"msg_0aba2ac43dc240b30169b1572286d0819eb24b1d0f84c8fb3f","output_index":1,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]},"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","item_id":"msg_0aba2ac43dc240b30169b1572286d0819eb24b1d0f84c8fb3f","output_index":1,"content_index":0,"delta":"Checking whether 3 + 5 is prime by calling the add function first.","sequence_number":6} + +event: response.output_text.done +data: {"type":"response.output_text.done","item_id":"msg_0aba2ac43dc240b30169b1572286d0819eb24b1d0f84c8fb3f","output_index":1,"content_index":0,"text":"Checking whether 3 + 5 is prime by calling the add function first.","sequence_number":7} + +event: response.content_part.done +data: {"type":"response.content_part.done","item_id":"msg_0aba2ac43dc240b30169b1572286d0819eb24b1d0f84c8fb3f","output_index":1,"content_index":0,"part":{"type":"output_text","text":"Checking whether 3 + 5 is prime by calling the add function first.","annotations":[]},"sequence_number":8} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"msg_0aba2ac43dc240b30169b1572286d0819eb24b1d0f84c8fb3f","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Checking whether 3 + 5 is prime by calling the add function first."}],"phase":"commentary","role":"assistant"},"output_index":1,"sequence_number":9} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"fc_0aba2ac43dc240b30169b157255604819e8a108124efc1635c","type":"function_call","status":"in_progress","arguments":"","call_id":"call_A8TkZmIcKtw2Zw952Wc5QVe7","name":"add"},"output_index":2,"sequence_number":10} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"{\"a\":3,\"b\":5}","item_id":"fc_0aba2ac43dc240b30169b157255604819e8a108124efc1635c","output_index":2,"sequence_number":11} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","arguments":"{\"a\":3,\"b\":5}","item_id":"fc_0aba2ac43dc240b30169b157255604819e8a108124efc1635c","output_index":2,"sequence_number":12} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"fc_0aba2ac43dc240b30169b157255604819e8a108124efc1635c","type":"function_call","status":"completed","arguments":"{\"a\":3,\"b\":5}","call_id":"call_A8TkZmIcKtw2Zw952Wc5QVe7","name":"add"},"output_index":2,"sequence_number":13} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_0aba2ac43dc240b30169b15720243c819ebb64977365d42cf5","object":"response","created_at":1773229856,"status":"completed","background":false,"completed_at":1773229861,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","output":[{"id":"rs_0aba2ac43dc240b30169b157208c88819e8238a91b5f7a919b","type":"reasoning","status":"completed","encrypted_content":"gAAAAA==","summary":[]},{"id":"msg_0aba2ac43dc240b30169b1572286d0819eb24b1d0f84c8fb3f","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Checking whether 3 + 5 is prime by calling the add function first."}],"phase":"commentary","role":"assistant"},{"id":"fc_0aba2ac43dc240b30169b157255604819e8a108124efc1635c","type":"function_call","status":"completed","arguments":"{\"a\":3,\"b\":5}","call_id":"call_A8TkZmIcKtw2Zw952Wc5QVe7","name":"add"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":"xhigh","summary":null},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"low"},"tool_choice":"auto","tools":[{"type":"function","description":"Add two numbers together.","name":"add","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":58,"input_tokens_details":{"cached_tokens":0},"output_tokens":30,"output_tokens_details":{"reasoning_tokens":10},"total_tokens":88},"user":null,"metadata":{}},"sequence_number":14} + diff --git a/aibridge/fixtures/openai/responses/streaming/conversation.txtar b/aibridge/fixtures/openai/responses/streaming/conversation.txtar new file mode 100644 index 0000000000..d01264a128 --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/conversation.txtar @@ -0,0 +1,540 @@ +-- request -- +{ + "conversation": "conv_695fa1132770819795d013275c77e8380108ce40c6fb22bd", + "input": "explain why this is funny.", + "model": "gpt-4o-mini", + "stream": true +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_0108ce40c6fb22bd00695fa11395588197a8207c74e6e3795c","object":"response","created_at":1767874835,"status":"in_progress","background":false,"completed_at":null,"conversation":{"id":"conv_695fa1132770819795d013275c77e8380108ce40c6fb22bd"},"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_0108ce40c6fb22bd00695fa11395588197a8207c74e6e3795c","object":"response","created_at":1767874835,"status":"in_progress","background":false,"completed_at":null,"conversation":{"id":"conv_695fa1132770819795d013275c77e8380108ce40c6fb22bd"},"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"This","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"6JuS91EMbhLA","output_index":0,"sequence_number":4} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" joke","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"y4aKJq6ioqK","output_index":0,"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" is","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"OSK1qGQlQ45Gf","output_index":0,"sequence_number":6} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" funny","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"xOx3biYzfi","output_index":0,"sequence_number":7} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" for","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"B6nzgMtFCPfI","output_index":0,"sequence_number":8} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" a","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"NLJ3uuUUR7HEwL","output_index":0,"sequence_number":9} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" couple","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"axMyCq7cc","output_index":0,"sequence_number":10} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" of","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"wogQAHGbERhyj","output_index":0,"sequence_number":11} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" reasons","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"kaIWALH5","output_index":0,"sequence_number":12} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":\n\n","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"5aWCXnTSm1Ww0","output_index":0,"sequence_number":13} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"1","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"ulbeCHj60aqERM2","output_index":0,"sequence_number":14} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"LS6N4ccoGtkBMf9","output_index":0,"sequence_number":15} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" **","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"RyhciW9kcGtT3","output_index":0,"sequence_number":16} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"Word","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"JJOH0y2lt5ce","output_index":0,"sequence_number":17} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"play","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"FweyacD1kgKU","output_index":0,"sequence_number":18} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"**","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"99utx5f2PR410S","output_index":0,"sequence_number":19} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"dZe5PeQsygjpDJU","output_index":0,"sequence_number":20} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" The","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"3UfyKaxhlu5T","output_index":0,"sequence_number":21} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" humor","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"aTNqJJdtlA","output_index":0,"sequence_number":22} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" comes","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"xK3buVbUHt","output_index":0,"sequence_number":23} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" from","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"igWwXO0tQtm","output_index":0,"sequence_number":24} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" the","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"A39bwmGkGF3T","output_index":0,"sequence_number":25} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" double","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"nLeuH3WdF","output_index":0,"sequence_number":26} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" meaning","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"zxC0qSSE","output_index":0,"sequence_number":27} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" of","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"DIMKV7wc7lnEa","output_index":0,"sequence_number":28} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" the","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"CnM6idZlt3Su","output_index":0,"sequence_number":29} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" phrase","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"DSxcKiYE2","output_index":0,"sequence_number":30} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" \"","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"zKE75xC70J5I8n","output_index":0,"sequence_number":31} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"make","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"oBFujacYh6Qi","output_index":0,"sequence_number":32} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" up","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"MCWKA9PGFz3uH","output_index":0,"sequence_number":33} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".\"","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"Mww11OYYfx46Pn","output_index":0,"sequence_number":34} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" In","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"lDHppT2E9fBjL","output_index":0,"sequence_number":35} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" one","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"qH7241nKwTjN","output_index":0,"sequence_number":36} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" sense","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"aQcSSHwJ3p","output_index":0,"sequence_number":37} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"ZNoviZFdXYechTT","output_index":0,"sequence_number":38} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" atoms","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"nXkzWnQfut","output_index":0,"sequence_number":39} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" are","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"9IE6b6ePg9E6","output_index":0,"sequence_number":40} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" the","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"MN8puLH01K4r","output_index":0,"sequence_number":41} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" basic","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"cHHGWtl6sA","output_index":0,"sequence_number":42} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" building","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"Qh8Lgl6","output_index":0,"sequence_number":43} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" blocks","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"usrQ4Zqhy","output_index":0,"sequence_number":44} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" of","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"UlMkWTr0buDdu","output_index":0,"sequence_number":45} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" matter","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"di7aKyqOB","output_index":0,"sequence_number":46} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" and","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"Jz1ouMsSH5Sq","output_index":0,"sequence_number":47} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" literally","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"bcPU64","output_index":0,"sequence_number":48} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" \"","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"k0mzekJTeeeyjl","output_index":0,"sequence_number":49} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"make","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"osOddu5z1SKn","output_index":0,"sequence_number":50} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" up","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"hxVor1fqBr85z","output_index":0,"sequence_number":51} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"R6QtJIz32R1BVio","output_index":0,"sequence_number":52} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" everything","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"AwhOH","output_index":0,"sequence_number":53} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" in","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"OumZOuQTLGWst","output_index":0,"sequence_number":54} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" the","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"aJI4Tm9Si3rt","output_index":0,"sequence_number":55} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" physical","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"F1cKqO8","output_index":0,"sequence_number":56} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" world","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"QNMNuZEBTi","output_index":0,"sequence_number":57} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"MXn5ZYICLy6vCbY","output_index":0,"sequence_number":58} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" In","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"NeupGqbEKerw6","output_index":0,"sequence_number":59} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" another","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"K8tdy7U8","output_index":0,"sequence_number":60} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" sense","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"pjhD3Np58X","output_index":0,"sequence_number":61} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"ACou7OILpf3wWDR","output_index":0,"sequence_number":62} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" \"","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"L4nsA8ZF0swWRP","output_index":0,"sequence_number":63} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"making","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"loHLh0D52x","output_index":0,"sequence_number":64} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" up","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"ZCbUNkX3fmHK5","output_index":0,"sequence_number":65} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"B9vFmLYXf6C0spM","output_index":0,"sequence_number":66} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" something","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"qYs53A","output_index":0,"sequence_number":67} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" can","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"zZfzpKfcLO4h","output_index":0,"sequence_number":68} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" mean","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"iEoAbAAy5dQ","output_index":0,"sequence_number":69} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" invent","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"ELQYNFOF4","output_index":0,"sequence_number":70} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"ing","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"c9S0EIus0bjBk","output_index":0,"sequence_number":71} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" or","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"zFOwG7sjVX8cZ","output_index":0,"sequence_number":72} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" lying","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"kLOSno5hAZ","output_index":0,"sequence_number":73} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" about","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"sZW682cjzl","output_index":0,"sequence_number":74} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" it","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"5SdVpOpP3tDW9","output_index":0,"sequence_number":75} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".\n\n","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"jIJkdpLZee7yv","output_index":0,"sequence_number":76} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"2","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"nPIBCntK2ClgdQs","output_index":0,"sequence_number":77} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"BzMXERtY6UTcark","output_index":0,"sequence_number":78} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" **","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"Gk753o2HBcSud","output_index":0,"sequence_number":79} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"Sur","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"UCUX6DSgEibpa","output_index":0,"sequence_number":80} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"prise","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"P9oQNuV01zl","output_index":0,"sequence_number":81} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Element","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"qBups9bc","output_index":0,"sequence_number":82} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"**","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"Z9dIdjqTsefoUa","output_index":0,"sequence_number":83} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"qm08Sch66EBWq9k","output_index":0,"sequence_number":84} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" J","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"J9bucKcls8A7M6","output_index":0,"sequence_number":85} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"okes","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"waZa21wHngIb","output_index":0,"sequence_number":86} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" often","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"VFnDaAMga6","output_index":0,"sequence_number":87} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" rely","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"YAFlPgnPcJC","output_index":0,"sequence_number":88} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" on","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"lLGSFHXK52aiW","output_index":0,"sequence_number":89} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" a","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"T7x2svQFyo3BjR","output_index":0,"sequence_number":90} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" setup","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"ZMt6PMeCWr","output_index":0,"sequence_number":91} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" that","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"8l1qJa3KTEX","output_index":0,"sequence_number":92} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" leads","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"zhhqrWIZAm","output_index":0,"sequence_number":93} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" the","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"yWdpvincjoJy","output_index":0,"sequence_number":94} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" audience","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"0ozlgo3","output_index":0,"sequence_number":95} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" to","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"S1HPNJAwEcewT","output_index":0,"sequence_number":96} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" expect","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"8KjGDm8mT","output_index":0,"sequence_number":97} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" one","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"XXmBZEjiFMNK","output_index":0,"sequence_number":98} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" thing","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"zmoaWMkdXD","output_index":0,"sequence_number":99} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"HJoNcrcVeIKLodt","output_index":0,"sequence_number":100} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" only","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"fCI023RmwwQ","output_index":0,"sequence_number":101} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" to","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"2Zsh2cdqDmHB8","output_index":0,"sequence_number":102} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" deliver","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"Hu5TXO23","output_index":0,"sequence_number":103} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" an","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"VZuZDgkAFfI1d","output_index":0,"sequence_number":104} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" unexpected","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"XZdrj","output_index":0,"sequence_number":105} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" punch","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"YwFnYN01eH","output_index":0,"sequence_number":106} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"line","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"iR5aKzuGEseR","output_index":0,"sequence_number":107} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"kSY2QLPXpQKhhD7","output_index":0,"sequence_number":108} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Here","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"3r3xEOpBXyF","output_index":0,"sequence_number":109} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"F69vhN3jEtN497d","output_index":0,"sequence_number":110} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" the","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"dySiTv3oGlxo","output_index":0,"sequence_number":111} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" punch","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"NCRSrY6Eb5","output_index":0,"sequence_number":112} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"line","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"cY6NHRaYJHx0","output_index":0,"sequence_number":113} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" plays","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"VPEZBBm0Hh","output_index":0,"sequence_number":114} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" with","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"eF3lZXVH1To","output_index":0,"sequence_number":115} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" our","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"GZ348T5reB6D","output_index":0,"sequence_number":116} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" understanding","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"j6","output_index":0,"sequence_number":117} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" of","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"PavNXetPHc38s","output_index":0,"sequence_number":118} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" language","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"Wj2Mv0J","output_index":0,"sequence_number":119} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"mWAw8s19WeQnY6i","output_index":0,"sequence_number":120} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" catching","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"3jyf8Cc","output_index":0,"sequence_number":121} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" the","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"J0L0wwVuGgxF","output_index":0,"sequence_number":122} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" listener","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"S2Vnlgk","output_index":0,"sequence_number":123} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" off","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"NtUUpay2a64F","output_index":0,"sequence_number":124} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" guard","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"b0wp7OyGDX","output_index":0,"sequence_number":125} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".\n\n","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"YKTvffawS9ptn","output_index":0,"sequence_number":126} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"3","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"NzNDjdBJrz4ag81","output_index":0,"sequence_number":127} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"rjI3dk1wGFtYDBd","output_index":0,"sequence_number":128} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" **","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"8WnxSsuSFODHO","output_index":0,"sequence_number":129} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"Rel","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"BhV12AQZ9qmT2","output_index":0,"sequence_number":130} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"atable","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"UTzXf0v3oH","output_index":0,"sequence_number":131} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Knowledge","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"qZOZIo","output_index":0,"sequence_number":132} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"**","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"cJm6vlGXwyzZXy","output_index":0,"sequence_number":133} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"dNoUfruWzSEiGbh","output_index":0,"sequence_number":134} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" The","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"9biJGwkcf8DT","output_index":0,"sequence_number":135} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" joke","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"Fc2ayZORxSk","output_index":0,"sequence_number":136} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" uses","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"I2yi0U5MA3a","output_index":0,"sequence_number":137} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" common","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"0u1MaStc6","output_index":0,"sequence_number":138} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" knowledge","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"IRlavB","output_index":0,"sequence_number":139} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" about","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"CbPPGMmDGP","output_index":0,"sequence_number":140} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" science","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"s5Vc9kMd","output_index":0,"sequence_number":141} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" (","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"4aUXFyZztDOb20","output_index":0,"sequence_number":142} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"atoms","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"DwBfSdw5Z3T","output_index":0,"sequence_number":143} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":")","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"gdKE9yfh3BfiOk8","output_index":0,"sequence_number":144} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" in","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"lcnGy3TQDzeBy","output_index":0,"sequence_number":145} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" a","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"7sx3DNuKWmMa7t","output_index":0,"sequence_number":146} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" light","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"6LZkpgf4xU","output_index":0,"sequence_number":147} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"hearted","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"AvS1EEdHW","output_index":0,"sequence_number":148} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" way","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"h0NWSBAWvBOV","output_index":0,"sequence_number":149} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"Cbi5mDUOpI44h46","output_index":0,"sequence_number":150} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" allowing","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"715Tb92","output_index":0,"sequence_number":151} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" it","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"Yg9uD6tBhUwFO","output_index":0,"sequence_number":152} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" to","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"tNVbx8ZDFQ8SY","output_index":0,"sequence_number":153} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" resonate","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"gUJhGv2","output_index":0,"sequence_number":154} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" with","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"AgivlEZAqmk","output_index":0,"sequence_number":155} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" a","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"lXG5SHj7QhLL1s","output_index":0,"sequence_number":156} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" wide","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"b0BP9ORJI2X","output_index":0,"sequence_number":157} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" audience","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"zMj6fOG","output_index":0,"sequence_number":158} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".\n\n","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"Agq84NjYCn4xs","output_index":0,"sequence_number":159} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"These","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"dof54LQG7uE","output_index":0,"sequence_number":160} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" elements","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"1oWvGIK","output_index":0,"sequence_number":161} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" combine","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"kvuq0yp6","output_index":0,"sequence_number":162} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" to","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"SEn7dk277XYB5","output_index":0,"sequence_number":163} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" create","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"hyGSspNs9","output_index":0,"sequence_number":164} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" a","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"cO1mGkek487Zem","output_index":0,"sequence_number":165} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" playful","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"kJJQB4N6","output_index":0,"sequence_number":166} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" twist","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"CTJ0Ri1sOS","output_index":0,"sequence_number":167} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" that","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"xFCmJyq5ghR","output_index":0,"sequence_number":168} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" el","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"INwzSkCCOVkWg","output_index":0,"sequence_number":169} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"icits","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"9rgQQMWSwBj","output_index":0,"sequence_number":170} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" laughter","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"ymfcFY8","output_index":0,"sequence_number":171} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"!","item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"obfuscation":"QOWTZahcZGIHoZB","output_index":0,"sequence_number":172} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","logprobs":[],"output_index":0,"sequence_number":173,"text":"This joke is funny for a couple of reasons:\n\n1. **Wordplay**: The humor comes from the double meaning of the phrase \"make up.\" In one sense, atoms are the basic building blocks of matter and literally \"make up\" everything in the physical world. In another sense, \"making up\" something can mean inventing or lying about it.\n\n2. **Surprise Element**: Jokes often rely on a setup that leads the audience to expect one thing, only to deliver an unexpected punchline. Here, the punchline plays with our understanding of language, catching the listener off guard.\n\n3. **Relatable Knowledge**: The joke uses common knowledge about science (atoms) in a lighthearted way, allowing it to resonate with a wide audience.\n\nThese elements combine to create a playful twist that elicits laughter!"} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"This joke is funny for a couple of reasons:\n\n1. **Wordplay**: The humor comes from the double meaning of the phrase \"make up.\" In one sense, atoms are the basic building blocks of matter and literally \"make up\" everything in the physical world. In another sense, \"making up\" something can mean inventing or lying about it.\n\n2. **Surprise Element**: Jokes often rely on a setup that leads the audience to expect one thing, only to deliver an unexpected punchline. Here, the punchline plays with our understanding of language, catching the listener off guard.\n\n3. **Relatable Knowledge**: The joke uses common knowledge about science (atoms) in a lighthearted way, allowing it to resonate with a wide audience.\n\nThese elements combine to create a playful twist that elicits laughter!"},"sequence_number":174} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"msg_0108ce40c6fb22bd00695fa11416548197bd5b43b5a507d23d","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"This joke is funny for a couple of reasons:\n\n1. **Wordplay**: The humor comes from the double meaning of the phrase \"make up.\" In one sense, atoms are the basic building blocks of matter and literally \"make up\" everything in the physical world. In another sense, \"making up\" something can mean inventing or lying about it.\n\n2. **Surprise Element**: Jokes often rely on a setup that leads the audience to expect one thing, only to deliver an unexpected punchline. Here, the punchline plays with our understanding of language, catching the listener off guard.\n\n3. **Relatable Knowledge**: The joke uses common knowledge about science (atoms) in a lighthearted way, allowing it to resonate with a wide audience.\n\nThese elements combine to create a playful twist that elicits laughter!"}],"role":"assistant"},"output_index":0,"sequence_number":175} + +event: error +data: {"type":"error","error":{"type":"invalid_request_error","code":null,"message":"Conversation with id 'conv_695fa1132770819795d013275c77e8380108ce40c6fb22bd' not found.","param":null},"sequence_number":177} + diff --git a/aibridge/fixtures/openai/responses/streaming/custom_tool.txtar b/aibridge/fixtures/openai/responses/streaming/custom_tool.txtar new file mode 100644 index 0000000000..2d43889201 --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/custom_tool.txtar @@ -0,0 +1,54 @@ +-- request -- +{ + "input": "Use the code_exec tool to print hello world to the console.", + "model": "gpt-5", + "stream": true, + "tools": [ + { + "type": "custom", + "name": "code_exec", + "description": "Executes arbitrary Python code." + } + ] +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_0c26996bc41c2a0500696942e83634819fb71b2b8ff8a4a76c","object":"response","created_at":1768506088,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5-2025-08-07","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":"medium","summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"custom","description":"Executes arbitrary Python code.","format":{"type":"text"},"name":"code_exec"}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_0c26996bc41c2a0500696942e83634819fb71b2b8ff8a4a76c","object":"response","created_at":1768506088,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5-2025-08-07","output":[],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":"medium","summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"custom","description":"Executes arbitrary Python code.","format":{"type":"text"},"name":"code_exec"}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"rs_0c26996bc41c2a0500696942e8ae90819fb421c1b6a945aa99","type":"reasoning","summary":[]},"output_index":0,"sequence_number":2} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"rs_0c26996bc41c2a0500696942e8ae90819fb421c1b6a945aa99","type":"reasoning","summary":[]},"output_index":0,"sequence_number":3} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"ctc_0c26996bc41c2a0500696942ee6db8819fa6e841317eecbfb2","type":"custom_tool_call","status":"in_progress","call_id":"call_2gSnF58IEhXLwlbnqbm5XKMd","input":"","name":"code_exec"},"output_index":1,"sequence_number":4} + +event: response.custom_tool_call_input.delta +data: {"type":"response.custom_tool_call_input.delta","delta":"print","item_id":"ctc_0c26996bc41c2a0500696942ee6db8819fa6e841317eecbfb2","obfuscation":"sTDUEAHu5aJ","output_index":1,"sequence_number":5} + +event: response.custom_tool_call_input.delta +data: {"type":"response.custom_tool_call_input.delta","delta":"(\"","item_id":"ctc_0c26996bc41c2a0500696942ee6db8819fa6e841317eecbfb2","obfuscation":"qvFA5MbN9ZUnBH","output_index":1,"sequence_number":6} + +event: response.custom_tool_call_input.delta +data: {"type":"response.custom_tool_call_input.delta","delta":"hello","item_id":"ctc_0c26996bc41c2a0500696942ee6db8819fa6e841317eecbfb2","obfuscation":"rRrXgQDOuwG","output_index":1,"sequence_number":7} + +event: response.custom_tool_call_input.delta +data: {"type":"response.custom_tool_call_input.delta","delta":" world","item_id":"ctc_0c26996bc41c2a0500696942ee6db8819fa6e841317eecbfb2","obfuscation":"DwnJdEFXvZ","output_index":1,"sequence_number":8} + +event: response.custom_tool_call_input.delta +data: {"type":"response.custom_tool_call_input.delta","delta":"\")","item_id":"ctc_0c26996bc41c2a0500696942ee6db8819fa6e841317eecbfb2","obfuscation":"pEr2t8Vpv3Ij96","output_index":1,"sequence_number":9} + +event: response.custom_tool_call_input.done +data: {"type":"response.custom_tool_call_input.done","input":"print(\"hello world\")","item_id":"ctc_0c26996bc41c2a0500696942ee6db8819fa6e841317eecbfb2","output_index":1,"sequence_number":10} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"ctc_0c26996bc41c2a0500696942ee6db8819fa6e841317eecbfb2","type":"custom_tool_call","status":"completed","call_id":"call_2gSnF58IEhXLwlbnqbm5XKMd","input":"print(\"hello world\")","name":"code_exec"},"output_index":1,"sequence_number":11} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_0c26996bc41c2a0500696942e83634819fb71b2b8ff8a4a76c","object":"response","created_at":1768506088,"status":"completed","background":false,"completed_at":1768506095,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5-2025-08-07","output":[{"id":"rs_0c26996bc41c2a0500696942e8ae90819fb421c1b6a945aa99","type":"reasoning","summary":[]},{"id":"ctc_0c26996bc41c2a0500696942ee6db8819fa6e841317eecbfb2","type":"custom_tool_call","status":"completed","call_id":"call_2gSnF58IEhXLwlbnqbm5XKMd","input":"print(\"hello world\")","name":"code_exec"}],"parallel_tool_calls":true,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":"medium","summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"custom","description":"Executes arbitrary Python code.","format":{"type":"text"},"name":"code_exec"}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":64,"input_tokens_details":{"cached_tokens":0},"output_tokens":340,"output_tokens_details":{"reasoning_tokens":320},"total_tokens":404},"user":null,"metadata":{}},"sequence_number":12} + diff --git a/aibridge/fixtures/openai/responses/streaming/http_error.txtar b/aibridge/fixtures/openai/responses/streaming/http_error.txtar new file mode 100644 index 0000000000..9c7827fff8 --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/http_error.txtar @@ -0,0 +1,21 @@ +-- request -- +{ + "input": "tell me a joke", + "model": "gpt-4o-mini", + "stream": true +} + +-- streaming -- +HTTP/2.0 429 Too Many Requests +Content-Length: 176 +Content-Type: application/json + +{ + "error": { + "message": "Rate limit exceeded. Please try again in 20 seconds.", + "type": "rate_limit_error", + "param": null, + "code": "rate_limit_exceeded" + } +} + diff --git a/aibridge/fixtures/openai/responses/streaming/multi_reasoning_builtin_tool.txtar b/aibridge/fixtures/openai/responses/streaming/multi_reasoning_builtin_tool.txtar new file mode 100644 index 0000000000..b54ebc7a09 --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/multi_reasoning_builtin_tool.txtar @@ -0,0 +1,94 @@ +Two reasoning output items before a function_call. + +-- request -- +{ + "input": [ + { + "role": "user", + "content": "Is 3 + 5 a prime number? Use the add function to calculate the sum." + } + ], + "model": "gpt-4.1", + "stream": true, + "tools": [ + { + "type": "function", + "name": "add", + "description": "Add two numbers together.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ] + } + } + ] +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_0c3fb28cfcf463a500695fa2f0239481a095ec6ce3dfe4d458","object":"response","created_at":1767875312,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Add two numbers together.","name":"add","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_0c3fb28cfcf463a500695fa2f0239481a095ec6ce3dfe4d458","object":"response","created_at":1767875312,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Add two numbers together.","name":"add","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"rs_0c3fb28cfcf463a500695fa2f0a0a881a0890103ba88b0628e","type":"reasoning","status":"in_progress","summary":[]},"output_index":0,"sequence_number":2} + +event: response.reasoning_summary_part.added +data: {"type":"response.reasoning_summary_part.added","item_id":"rs_0c3fb28cfcf463a500695fa2f0a0a881a0890103ba88b0628e","output_index":0,"part":{"type":"summary_text","text":""},"summary_index":0,"sequence_number":3} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","item_id":"rs_0c3fb28cfcf463a500695fa2f0a0a881a0890103ba88b0628e","output_index":0,"summary_index":0,"delta":"The user wants to add 3 and 5. Let me call the add function.","sequence_number":4} + +event: response.reasoning_summary_text.done +data: {"type":"response.reasoning_summary_text.done","item_id":"rs_0c3fb28cfcf463a500695fa2f0a0a881a0890103ba88b0628e","output_index":0,"summary_index":0,"text":"The user wants to add 3 and 5. Let me call the add function.","sequence_number":5} + +event: response.reasoning_summary_part.done +data: {"type":"response.reasoning_summary_part.done","item_id":"rs_0c3fb28cfcf463a500695fa2f0a0a881a0890103ba88b0628e","output_index":0,"part":{"type":"summary_text","text":"The user wants to add 3 and 5. Let me call the add function."},"summary_index":0,"sequence_number":6} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"rs_0c3fb28cfcf463a500695fa2f0a0a881a0890103ba88b0628e","type":"reasoning","status":"completed","summary":[{"type":"summary_text","text":"The user wants to add 3 and 5. Let me call the add function."}]},"output_index":0,"sequence_number":7} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"rs_1aa7045a8b68fa5200695fa23e200082b29cf79998e58bf94e","type":"reasoning","status":"in_progress","summary":[]},"output_index":1,"sequence_number":8} + +event: response.reasoning_summary_part.added +data: {"type":"response.reasoning_summary_part.added","item_id":"rs_1aa7045a8b68fa5200695fa23e200082b29cf79998e58bf94e","output_index":1,"part":{"type":"summary_text","text":""},"summary_index":0,"sequence_number":9} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","item_id":"rs_1aa7045a8b68fa5200695fa23e200082b29cf79998e58bf94e","output_index":1,"summary_index":0,"delta":"After adding, I will check if the result is prime.","sequence_number":10} + +event: response.reasoning_summary_text.done +data: {"type":"response.reasoning_summary_text.done","item_id":"rs_1aa7045a8b68fa5200695fa23e200082b29cf79998e58bf94e","output_index":1,"summary_index":0,"text":"After adding, I will check if the result is prime.","sequence_number":11} + +event: response.reasoning_summary_part.done +data: {"type":"response.reasoning_summary_part.done","item_id":"rs_1aa7045a8b68fa5200695fa23e200082b29cf79998e58bf94e","output_index":1,"part":{"type":"summary_text","text":"After adding, I will check if the result is prime."},"summary_index":0,"sequence_number":12} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"rs_1aa7045a8b68fa5200695fa23e200082b29cf79998e58bf94e","type":"reasoning","status":"completed","summary":[{"type":"summary_text","text":"After adding, I will check if the result is prime."}]},"output_index":1,"sequence_number":13} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","type":"function_call","status":"in_progress","arguments":"","call_id":"call_7VaiUXZYuuuwWwviCrckxq6t","name":"add"},"output_index":2,"sequence_number":14} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"{\"a\":3,\"b\":5}","item_id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","obfuscation":"gWZHP8i4lSgQYT","output_index":2,"sequence_number":15} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","arguments":"{\"a\":3,\"b\":5}","item_id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","output_index":2,"sequence_number":16} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","type":"function_call","status":"completed","arguments":"{\"a\":3,\"b\":5}","call_id":"call_7VaiUXZYuuuwWwviCrckxq6t","name":"add"},"output_index":2,"sequence_number":17} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_0c3fb28cfcf463a500695fa2f0239481a095ec6ce3dfe4d458","object":"response","created_at":1767875312,"status":"completed","background":false,"completed_at":1767875312,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[{"id":"rs_0c3fb28cfcf463a500695fa2f0a0a881a0890103ba88b0628e","type":"reasoning","status":"completed","summary":[{"type":"summary_text","text":"The user wants to add 3 and 5. Let me call the add function."}]},{"id":"rs_1aa7045a8b68fa5200695fa23e200082b29cf79998e58bf94e","type":"reasoning","status":"completed","summary":[{"type":"summary_text","text":"After adding, I will check if the result is prime."}]},{"id":"fc_0c3fb28cfcf463a500695fa2f0b0a881a0890103ba88b0628e","type":"function_call","status":"completed","arguments":"{\"a\":3,\"b\":5}","call_id":"call_7VaiUXZYuuuwWwviCrckxq6t","name":"add"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Add two numbers together.","name":"add","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":58,"input_tokens_details":{"cached_tokens":0},"output_tokens":18,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":76},"user":null,"metadata":{}},"sequence_number":18} + diff --git a/aibridge/fixtures/openai/responses/streaming/prev_response_id.txtar b/aibridge/fixtures/openai/responses/streaming/prev_response_id.txtar new file mode 100644 index 0000000000..2a48378fc5 --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/prev_response_id.txtar @@ -0,0 +1,576 @@ +-- request -- +{ + "input": "explain why this is funny.", + "model": "gpt-4o-mini", + "previous_response_id": "resp_0f9c4b2f224d858000695fa062bf048197a680f357bbb09000", + "stream": true +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_0f9c4b2f224d858000695fa0649b8c8197b38914b15a7add0e","object":"response","created_at":1767874660,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":"resp_0f9c4b2f224d858000695fa062bf048197a680f357bbb09000","prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_0f9c4b2f224d858000695fa0649b8c8197b38914b15a7add0e","object":"response","created_at":1767874660,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":"resp_0f9c4b2f224d858000695fa062bf048197a680f357bbb09000","prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"The","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"DHEzS6FGVUr5E","output_index":0,"sequence_number":4} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" joke","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"QHJlLKd1i4I","output_index":0,"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" is","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"OUQeCkINJ5VDR","output_index":0,"sequence_number":6} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" funny","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"edUq2nh7rM","output_index":0,"sequence_number":7} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" because","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"lfIvyMYF","output_index":0,"sequence_number":8} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" it","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"IevxLSVnUQUv1","output_index":0,"sequence_number":9} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" uses","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"WCP3pFvqO6f","output_index":0,"sequence_number":10} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" a","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"Q5qCDtvROr5ZP0","output_index":0,"sequence_number":11} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" play","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"uYCIUmPmOxY","output_index":0,"sequence_number":12} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" on","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"eDN8BZywTMbfE","output_index":0,"sequence_number":13} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" words","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"m9d5ApPbls","output_index":0,"sequence_number":14} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"tZo36JrN5e2844D","output_index":0,"sequence_number":15} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" which","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"CVRHFumykU","output_index":0,"sequence_number":16} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" is","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"rdAYifDkSO66w","output_index":0,"sequence_number":17} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" a","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"qdkX1IGsZFixdS","output_index":0,"sequence_number":18} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" common","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"wqcOXveYt","output_index":0,"sequence_number":19} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" form","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"TkeTQ4v6hWr","output_index":0,"sequence_number":20} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" of","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"D38VdvUE7l0H9","output_index":0,"sequence_number":21} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" humor","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"iGyDNUGr0C","output_index":0,"sequence_number":22} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"cutbtYnZfT0n4JO","output_index":0,"sequence_number":23} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" \n\n","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"AnxZS7kyw6A9j","output_index":0,"sequence_number":24} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"1","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"RzSDkMTUnlSn0MZ","output_index":0,"sequence_number":25} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"5QY6AzdMey52NAl","output_index":0,"sequence_number":26} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" **","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"IfJewJwbvV84B","output_index":0,"sequence_number":27} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"Double","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"d1QfJAfDG1","output_index":0,"sequence_number":28} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Meaning","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"uUtusErd","output_index":0,"sequence_number":29} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"**","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"eEynq2ECHVNFHD","output_index":0,"sequence_number":30} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"KFnQwxpnVwbMrCS","output_index":0,"sequence_number":31} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" The","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"EmahvP8dVtog","output_index":0,"sequence_number":32} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" phrase","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"vWNyEuOHx","output_index":0,"sequence_number":33} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" \"","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"lAqrd6cYAXlhCz","output_index":0,"sequence_number":34} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"out","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"M2xl0znKS7ci1","output_index":0,"sequence_number":35} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"standing","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"e7X0kd8A","output_index":0,"sequence_number":36} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" in","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"ghB38DUHuwyZv","output_index":0,"sequence_number":37} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" his","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"T53kggqnrHeK","output_index":0,"sequence_number":38} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" field","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"jc98KS0TBP","output_index":0,"sequence_number":39} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"vYewPc6Rn7twA59","output_index":0,"sequence_number":40} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" can","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"89reGpcrNM4F","output_index":0,"sequence_number":41} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" be","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"b5CoQSqeiPpDZ","output_index":0,"sequence_number":42} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" interpreted","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"K9js","output_index":0,"sequence_number":43} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" literally","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"weYNMB","output_index":0,"sequence_number":44} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"dkNP1549QnPgaK5","output_index":0,"sequence_number":45} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" meaning","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"smEFitne","output_index":0,"sequence_number":46} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" the","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"zKo3ymbuz2f3","output_index":0,"sequence_number":47} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" scare","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"3R7vsK0FsP","output_index":0,"sequence_number":48} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"crow","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"4f59ggc8KAOe","output_index":0,"sequence_number":49} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" is","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"c6MBXeF3KPdZ9","output_index":0,"sequence_number":50} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" literally","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"fMSP1r","output_index":0,"sequence_number":51} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" standing","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"ka1O1zO","output_index":0,"sequence_number":52} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" out","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"OxpPkKaOI4gI","output_index":0,"sequence_number":53} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" in","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"zKfYV5jEfCzt7","output_index":0,"sequence_number":54} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" a","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"KJg3i2F6LFQxzp","output_index":0,"sequence_number":55} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" field","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"HfFZ4RRe3f","output_index":0,"sequence_number":56} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" (","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"pQ4oXqVqV36gE0","output_index":0,"sequence_number":57} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"as","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"8SaeYXxOQU3cnd","output_index":0,"sequence_number":58} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" that's","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"MKgo8fAnG","output_index":0,"sequence_number":59} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" where","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"2fo6SoMB7u","output_index":0,"sequence_number":60} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" scare","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"HNfJHQO7Lu","output_index":0,"sequence_number":61} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"c","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"tJm1UVUt453MlZC","output_index":0,"sequence_number":62} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"rows","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"boBkPXPM6PM0","output_index":0,"sequence_number":63} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" are","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"4wv4vIp7bnqT","output_index":0,"sequence_number":64} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" found","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"7jbVDFFDrR","output_index":0,"sequence_number":65} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":").","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"iPVX4f8Nk2R36u","output_index":0,"sequence_number":66} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" However","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"WXD8NM59","output_index":0,"sequence_number":67} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"0zylfpXdumQWL3A","output_index":0,"sequence_number":68} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" it","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"r21NPwPwh6gWv","output_index":0,"sequence_number":69} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" also","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"yBuwgjQM3TS","output_index":0,"sequence_number":70} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" has","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"bKu6Uq5lPnBt","output_index":0,"sequence_number":71} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" a","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"UqLYVw32sivCxo","output_index":0,"sequence_number":72} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" figur","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"D9R8bxIy42","output_index":0,"sequence_number":73} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"ative","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"VPMseVGqlG2","output_index":0,"sequence_number":74} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" meaning","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"qKBa0orJ","output_index":0,"sequence_number":75} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"eXIpmNUtluw8Kvs","output_index":0,"sequence_number":76} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" it","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"1VBnyXJquHKL3","output_index":0,"sequence_number":77} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" suggests","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"b7tCjGH","output_index":0,"sequence_number":78} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" that","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"a0OorLr8zoQ","output_index":0,"sequence_number":79} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" someone","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"ihsOjyxt","output_index":0,"sequence_number":80} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" is","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"li0qLt2sYBmxJ","output_index":0,"sequence_number":81} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" exceptionally","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"FE","output_index":0,"sequence_number":82} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" skilled","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"v9HhHkN0","output_index":0,"sequence_number":83} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" or","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"mRkKQtBPBkrFb","output_index":0,"sequence_number":84} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" accomplished","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"cul","output_index":0,"sequence_number":85} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" in","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"3MJtuI4xfHA14","output_index":0,"sequence_number":86} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" their","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"rfRTP1G1LR","output_index":0,"sequence_number":87} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" area","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"IoFxhHT0S2D","output_index":0,"sequence_number":88} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" of","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"8ocFOGBmBxLAy","output_index":0,"sequence_number":89} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" expertise","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"MsxIJs","output_index":0,"sequence_number":90} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".\n\n","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"0hXVHSxmEzAfo","output_index":0,"sequence_number":91} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"2","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"kYR0FdWcxaVIyoT","output_index":0,"sequence_number":92} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"8AVkzTH5oQ2Ea3w","output_index":0,"sequence_number":93} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" **","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"uSEIHZyUCn6Ns","output_index":0,"sequence_number":94} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"Sur","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"P73cMx6kWmrpf","output_index":0,"sequence_number":95} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"prise","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"3x0V86slZfc","output_index":0,"sequence_number":96} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Element","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"P54ucKKE","output_index":0,"sequence_number":97} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"**","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"Y4gTEKEAXxQd5Z","output_index":0,"sequence_number":98} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"mb4rbxmph7FBfFY","output_index":0,"sequence_number":99} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" The","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"WOQucBmTB3W1","output_index":0,"sequence_number":100} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" punch","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"dh6riwNrDQ","output_index":0,"sequence_number":101} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"line","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"dG8x2aWeLBvy","output_index":0,"sequence_number":102} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" delivers","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"AvywpI0","output_index":0,"sequence_number":103} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" an","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"x7bDi4kmePshO","output_index":0,"sequence_number":104} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" unexpected","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"aa13X","output_index":0,"sequence_number":105} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" twist","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"5vWJPzoyXJ","output_index":0,"sequence_number":106} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"I4SgVqsdgh4Iq9y","output_index":0,"sequence_number":107} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" You","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"QmG22ploL4PA","output_index":0,"sequence_number":108} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" expect","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"d7pmncL1I","output_index":0,"sequence_number":109} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" the","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"DE3zEEd48D60","output_index":0,"sequence_number":110} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" award","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"9emuHJ8kzC","output_index":0,"sequence_number":111} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" to","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"zLlgDWd6XZnBI","output_index":0,"sequence_number":112} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" be","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"IofL9iR1fZWH7","output_index":0,"sequence_number":113} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" for","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"uZbOQUgwCQNS","output_index":0,"sequence_number":114} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" some","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"VdOVg200trS","output_index":0,"sequence_number":115} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" human","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"ZR1jijs6RR","output_index":0,"sequence_number":116} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" trait","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"YFiuWDRVqT","output_index":0,"sequence_number":117} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"yfYVyWUTwDCOlng","output_index":0,"sequence_number":118} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" but","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"fezlQ9HKgG29","output_index":0,"sequence_number":119} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" it's","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"kOKjHhMKvxo","output_index":0,"sequence_number":120} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" actually","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"8OzqVUl","output_index":0,"sequence_number":121} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" a","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"7ElfyBZnK0yTdq","output_index":0,"sequence_number":122} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" humorous","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"3hWMHah","output_index":0,"sequence_number":123} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" observation","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"eJyp","output_index":0,"sequence_number":124} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" about","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"NzbrTnXscy","output_index":0,"sequence_number":125} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" the","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"vEh4ykDzVtjw","output_index":0,"sequence_number":126} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" scare","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"DxDYdByBKX","output_index":0,"sequence_number":127} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"crow","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"b6cTjeCsdgS9","output_index":0,"sequence_number":128} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"’s","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"fA0DCqJ1zIPX7z","output_index":0,"sequence_number":129} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" existence","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"g60ZOk","output_index":0,"sequence_number":130} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".\n\n","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"Cy7j62pp0KmeC","output_index":0,"sequence_number":131} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"3","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"j2isSvjsvXEfLT8","output_index":0,"sequence_number":132} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"hwl3YJGsYuliUZc","output_index":0,"sequence_number":133} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" **","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"OW7wjSZuS9PUF","output_index":0,"sequence_number":134} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"Abs","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"hGDaoSd3EyQi0","output_index":0,"sequence_number":135} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"urd","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"kzwdZb5gdRBUO","output_index":0,"sequence_number":136} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"ity","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"AGB4ZWKhdAmpl","output_index":0,"sequence_number":137} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"**","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"AQM9tjRdYuiDxU","output_index":0,"sequence_number":138} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"zkwYjpymmS54zLL","output_index":0,"sequence_number":139} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" The","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"2bpD1VPjVqT4","output_index":0,"sequence_number":140} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" idea","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"yJrTH0IE5EI","output_index":0,"sequence_number":141} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" of","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"2F9lKnywGkXeg","output_index":0,"sequence_number":142} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" a","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"DeHfaCfUZ3OFUD","output_index":0,"sequence_number":143} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" scare","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"XbHJOoxc2T","output_index":0,"sequence_number":144} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"crow","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"5KhIZhunW2MB","output_index":0,"sequence_number":145} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"CUjg4FXgNB6fW9T","output_index":0,"sequence_number":146} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" an","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"nppy6fsrODqdD","output_index":0,"sequence_number":147} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" in","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"9f3xNqHJ31DbK","output_index":0,"sequence_number":148} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"animate","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"x5WNWGnkw","output_index":0,"sequence_number":149} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" object","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"JMehZgCZL","output_index":0,"sequence_number":150} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"G4moFDLqPgXl2og","output_index":0,"sequence_number":151} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" receiving","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"usujJs","output_index":0,"sequence_number":152} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" an","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"7rqwpfzZZwmpe","output_index":0,"sequence_number":153} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" award","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"ld5vgi60uy","output_index":0,"sequence_number":154} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" adds","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"kErKYzpCcOX","output_index":0,"sequence_number":155} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" an","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"1f6bhXZSy1GeE","output_index":0,"sequence_number":156} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" element","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"33nyGp9n","output_index":0,"sequence_number":157} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" of","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"YIa5Wv8NUAeAT","output_index":0,"sequence_number":158} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" absurd","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"s1Dxhug3I","output_index":0,"sequence_number":159} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"ity","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"RybQeNxIszXqy","output_index":0,"sequence_number":160} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"SKxMJyTX66sfon9","output_index":0,"sequence_number":161} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" making","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"SAXT80cOM","output_index":0,"sequence_number":162} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" it","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"tzZHDUqVepH96","output_index":0,"sequence_number":163} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" more","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"8qRMxic0p2b","output_index":0,"sequence_number":164} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" amusing","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"Zb7GsyKt","output_index":0,"sequence_number":165} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".\n\n","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"31laY4QlnMB6y","output_index":0,"sequence_number":166} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"Overall","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"95bVDR9T0","output_index":0,"sequence_number":167} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"OhUixHaPQ5ebUzy","output_index":0,"sequence_number":168} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" it's","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"bbYLkiw2T8E","output_index":0,"sequence_number":169} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" the","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"ostR0cxyGIJD","output_index":0,"sequence_number":170} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" clever","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"PpGqKElOs","output_index":0,"sequence_number":171} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" word","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"I0DETY9xxgm","output_index":0,"sequence_number":172} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"play","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"6zWRZleG0DvD","output_index":0,"sequence_number":173} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" combined","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"buIFOKO","output_index":0,"sequence_number":174} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" with","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"32zyLmemqJP","output_index":0,"sequence_number":175} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" an","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"Ua7JQewv7wBMa","output_index":0,"sequence_number":176} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" unexpected","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"sFOzn","output_index":0,"sequence_number":177} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" twist","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"2VbhR1bqcr","output_index":0,"sequence_number":178} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" that","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"F7jlTqm5mqb","output_index":0,"sequence_number":179} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" makes","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"Ywx6KbSzzU","output_index":0,"sequence_number":180} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" the","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"B4aGSKflNN22","output_index":0,"sequence_number":181} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" joke","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"hNMEMTZL5Ja","output_index":0,"sequence_number":182} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" effective","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"bsB12A","output_index":0,"sequence_number":183} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"!","item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"obfuscation":"pjObCPZ3LfG6WVF","output_index":0,"sequence_number":184} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","logprobs":[],"output_index":0,"sequence_number":185,"text":"The joke is funny because it uses a play on words, which is a common form of humor. \n\n1. **Double Meaning**: The phrase \"outstanding in his field\" can be interpreted literally, meaning the scarecrow is literally standing out in a field (as that's where scarecrows are found). However, it also has a figurative meaning: it suggests that someone is exceptionally skilled or accomplished in their area of expertise.\n\n2. **Surprise Element**: The punchline delivers an unexpected twist. You expect the award to be for some human trait, but it's actually a humorous observation about the scarecrow’s existence.\n\n3. **Absurdity**: The idea of a scarecrow, an inanimate object, receiving an award adds an element of absurdity, making it more amusing.\n\nOverall, it's the clever wordplay combined with an unexpected twist that makes the joke effective!"} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"The joke is funny because it uses a play on words, which is a common form of humor. \n\n1. **Double Meaning**: The phrase \"outstanding in his field\" can be interpreted literally, meaning the scarecrow is literally standing out in a field (as that's where scarecrows are found). However, it also has a figurative meaning: it suggests that someone is exceptionally skilled or accomplished in their area of expertise.\n\n2. **Surprise Element**: The punchline delivers an unexpected twist. You expect the award to be for some human trait, but it's actually a humorous observation about the scarecrow’s existence.\n\n3. **Absurdity**: The idea of a scarecrow, an inanimate object, receiving an award adds an element of absurdity, making it more amusing.\n\nOverall, it's the clever wordplay combined with an unexpected twist that makes the joke effective!"},"sequence_number":186} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The joke is funny because it uses a play on words, which is a common form of humor. \n\n1. **Double Meaning**: The phrase \"outstanding in his field\" can be interpreted literally, meaning the scarecrow is literally standing out in a field (as that's where scarecrows are found). However, it also has a figurative meaning: it suggests that someone is exceptionally skilled or accomplished in their area of expertise.\n\n2. **Surprise Element**: The punchline delivers an unexpected twist. You expect the award to be for some human trait, but it's actually a humorous observation about the scarecrow’s existence.\n\n3. **Absurdity**: The idea of a scarecrow, an inanimate object, receiving an award adds an element of absurdity, making it more amusing.\n\nOverall, it's the clever wordplay combined with an unexpected twist that makes the joke effective!"}],"role":"assistant"},"output_index":0,"sequence_number":187} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_0f9c4b2f224d858000695fa0649b8c8197b38914b15a7add0e","object":"response","created_at":1767874660,"status":"completed","background":false,"completed_at":1767874663,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-mini-2024-07-18","output":[{"id":"msg_0f9c4b2f224d858000695fa064f1dc81979e4a37fab905af69","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The joke is funny because it uses a play on words, which is a common form of humor. \n\n1. **Double Meaning**: The phrase \"outstanding in his field\" can be interpreted literally, meaning the scarecrow is literally standing out in a field (as that's where scarecrows are found). However, it also has a figurative meaning: it suggests that someone is exceptionally skilled or accomplished in their area of expertise.\n\n2. **Surprise Element**: The punchline delivers an unexpected twist. You expect the award to be for some human trait, but it's actually a humorous observation about the scarecrow’s existence.\n\n3. **Absurdity**: The idea of a scarecrow, an inanimate object, receiving an award adds an element of absurdity, making it more amusing.\n\nOverall, it's the clever wordplay combined with an unexpected twist that makes the joke effective!"}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":"resp_0f9c4b2f224d858000695fa062bf048197a680f357bbb09000","prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":43,"input_tokens_details":{"cached_tokens":0},"output_tokens":182,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":225},"user":null,"metadata":{}},"sequence_number":188} + diff --git a/aibridge/fixtures/openai/responses/streaming/simple.txtar b/aibridge/fixtures/openai/responses/streaming/simple.txtar new file mode 100644 index 0000000000..d86aa6e469 --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/simple.txtar @@ -0,0 +1,83 @@ +-- request -- +{ + "input": "tell me a joke", + "model": "gpt-4o-mini", + "stream": true +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_0f9c4b2f224d858000695fa062bf048197a680f357bbb09000","object":"response","created_at":1767874658,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_0f9c4b2f224d858000695fa062bf048197a680f357bbb09000","object":"response","created_at":1767874658,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-mini-2024-07-18","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"Why","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"N16SG5UiLncOU","output_index":0,"sequence_number":4} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" did","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"OpojJ3pv0h55","output_index":0,"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" the","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"11RCrnBxLo5x","output_index":0,"sequence_number":6} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" scare","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"QZrRBlk6BV","output_index":0,"sequence_number":7} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"crow","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"gp7F8IVupiHG","output_index":0,"sequence_number":8} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" win","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"uKq4X8mT1jl9","output_index":0,"sequence_number":9} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" an","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"2Ox5JzaAsJHuT","output_index":0,"sequence_number":10} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" award","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"ZOQbZabNAQ","output_index":0,"sequence_number":11} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"?\n\n","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"N2dSd0FHBxooR","output_index":0,"sequence_number":12} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"Because","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"LZ1O4laHt","output_index":0,"sequence_number":13} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" he","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"dqcS6ePaMvxMD","output_index":0,"sequence_number":14} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" was","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"nR6CtC7MUsWW","output_index":0,"sequence_number":15} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" outstanding","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"dNVG","output_index":0,"sequence_number":16} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" in","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"P7w4jjOcdVOla","output_index":0,"sequence_number":17} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" his","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"u9dg4RLIld4e","output_index":0,"sequence_number":18} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" field","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"qefuqzOCOy","output_index":0,"sequence_number":19} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"!","item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"obfuscation":"DT9j4dSh0xyJdxU","output_index":0,"sequence_number":20} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","logprobs":[],"output_index":0,"sequence_number":21,"text":"Why did the scarecrow win an award?\n\nBecause he was outstanding in his field!"} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"Why did the scarecrow win an award?\n\nBecause he was outstanding in his field!"},"sequence_number":22} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"Why did the scarecrow win an award?\n\nBecause he was outstanding in his field!"}],"role":"assistant"},"output_index":0,"sequence_number":23} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_0f9c4b2f224d858000695fa062bf048197a680f357bbb09000","object":"response","created_at":1767874658,"status":"completed","background":false,"completed_at":1767874660,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-mini-2024-07-18","output":[{"id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"Why did the scarecrow win an award?\n\nBecause he was outstanding in his field!"}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":11,"input_tokens_details":{"cached_tokens":0},"output_tokens":18,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":29},"user":null,"metadata":{}},"sequence_number":24} + diff --git a/aibridge/fixtures/openai/responses/streaming/single_builtin_tool_parallel.txtar b/aibridge/fixtures/openai/responses/streaming/single_builtin_tool_parallel.txtar new file mode 100644 index 0000000000..0319cab031 --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/single_builtin_tool_parallel.txtar @@ -0,0 +1,86 @@ +-- request -- +{ + "input": [ + { + "role": "user", + "content": "Is 3 + 5 a prime number? Also add 10 + 20. Use the add function for both." + } + ], + "model": "gpt-4.1", + "stream": true, + "tools": [ + { + "type": "function", + "name": "add", + "description": "Add two numbers together.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ] + } + } + ] +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_parallel_streaming_001","object":"response","created_at":1767875312,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Add two numbers together.","name":"add","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_parallel_streaming_001","object":"response","created_at":1767875312,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Add two numbers together.","name":"add","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"rs_parallel_streaming_reasoning_001","type":"reasoning","status":"in_progress","summary":[]},"output_index":0,"sequence_number":2} + +event: response.reasoning_summary_part.added +data: {"type":"response.reasoning_summary_part.added","item_id":"rs_parallel_streaming_reasoning_001","output_index":0,"part":{"type":"summary_text","text":""},"summary_index":0,"sequence_number":3} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","item_id":"rs_parallel_streaming_reasoning_001","output_index":0,"summary_index":0,"delta":"The user wants two additions: 3+5 and 10+20. I'll call add for both.","sequence_number":4} + +event: response.reasoning_summary_text.done +data: {"type":"response.reasoning_summary_text.done","item_id":"rs_parallel_streaming_reasoning_001","output_index":0,"summary_index":0,"text":"The user wants two additions: 3+5 and 10+20. I'll call add for both.","sequence_number":5} + +event: response.reasoning_summary_part.done +data: {"type":"response.reasoning_summary_part.done","item_id":"rs_parallel_streaming_reasoning_001","output_index":0,"part":{"type":"summary_text","text":"The user wants two additions: 3+5 and 10+20. I'll call add for both."},"summary_index":0,"sequence_number":6} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"rs_parallel_streaming_reasoning_001","type":"reasoning","status":"completed","summary":[{"type":"summary_text","text":"The user wants two additions: 3+5 and 10+20. I'll call add for both."}]},"output_index":0,"sequence_number":7} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"fc_parallel_streaming_first_001","type":"function_call","status":"in_progress","arguments":"","call_id":"call_ParallelStreamFirst001","name":"add"},"output_index":1,"sequence_number":8} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"{\"a\":3,\"b\":5}","item_id":"fc_parallel_streaming_first_001","output_index":1,"sequence_number":9} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","arguments":"{\"a\":3,\"b\":5}","item_id":"fc_parallel_streaming_first_001","output_index":1,"sequence_number":10} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"fc_parallel_streaming_first_001","type":"function_call","status":"completed","arguments":"{\"a\":3,\"b\":5}","call_id":"call_ParallelStreamFirst001","name":"add"},"output_index":1,"sequence_number":11} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"fc_parallel_streaming_second_001","type":"function_call","status":"in_progress","arguments":"","call_id":"call_ParallelStreamSecond01","name":"add"},"output_index":2,"sequence_number":12} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"{\"a\":10,\"b\":20}","item_id":"fc_parallel_streaming_second_001","output_index":2,"sequence_number":13} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","arguments":"{\"a\":10,\"b\":20}","item_id":"fc_parallel_streaming_second_001","output_index":2,"sequence_number":14} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"fc_parallel_streaming_second_001","type":"function_call","status":"completed","arguments":"{\"a\":10,\"b\":20}","call_id":"call_ParallelStreamSecond01","name":"add"},"output_index":2,"sequence_number":15} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_parallel_streaming_001","object":"response","created_at":1767875312,"status":"completed","background":false,"completed_at":1767875312,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[{"id":"rs_parallel_streaming_reasoning_001","type":"reasoning","status":"completed","summary":[{"type":"summary_text","text":"The user wants two additions: 3+5 and 10+20. I'll call add for both."}]},{"id":"fc_parallel_streaming_first_001","type":"function_call","status":"completed","arguments":"{\"a\":3,\"b\":5}","call_id":"call_ParallelStreamFirst001","name":"add"},{"id":"fc_parallel_streaming_second_001","type":"function_call","status":"completed","arguments":"{\"a\":10,\"b\":20}","call_id":"call_ParallelStreamSecond01","name":"add"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Add two numbers together.","name":"add","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":65,"input_tokens_details":{"cached_tokens":0},"output_tokens":30,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":95},"user":null,"metadata":{}},"sequence_number":16} + diff --git a/aibridge/fixtures/openai/responses/streaming/single_injected_tool.txtar b/aibridge/fixtures/openai/responses/streaming/single_injected_tool.txtar new file mode 100644 index 0000000000..0e079d1e7a --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/single_injected_tool.txtar @@ -0,0 +1,595 @@ +-- request -- +{ + "input": "List my coder templates.", + "model": "gpt-4.1-mini", + "stream": true +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_016595fe42aa62ca0069724419c52081a0b7eb479c6bc8109f","object":"response","created_at":1769096217,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-mini-2025-04-14","output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_016595fe42aa62ca0069724419c52081a0b7eb479c6bc8109f","object":"response","created_at":1769096217,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-mini-2025-04-14","output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"fc_016595fe42aa62ca006972441b4d0081a0bbf6b65aa91022df","type":"function_call","status":"in_progress","arguments":"","call_id":"call_GuuoyhUrVJQbWfHHz0xaX3n9","name":"bmcp_coder_coder_list_templates"},"output_index":0,"sequence_number":2} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"{}","item_id":"fc_016595fe42aa62ca006972441b4d0081a0bbf6b65aa91022df","obfuscation":"YDuSX3LFLxsY5W","output_index":0,"sequence_number":3} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","arguments":"{}","item_id":"fc_016595fe42aa62ca006972441b4d0081a0bbf6b65aa91022df","output_index":0,"sequence_number":4} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"fc_016595fe42aa62ca006972441b4d0081a0bbf6b65aa91022df","type":"function_call","status":"completed","arguments":"{}","call_id":"call_GuuoyhUrVJQbWfHHz0xaX3n9","name":"bmcp_coder_coder_list_templates"},"output_index":0,"sequence_number":5} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_016595fe42aa62ca0069724419c52081a0b7eb479c6bc8109f","object":"response","created_at":1769096217,"status":"completed","background":false,"completed_at":1769096219,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-mini-2025-04-14","output":[{"id":"fc_016595fe42aa62ca006972441b4d0081a0bbf6b65aa91022df","type":"function_call","status":"completed","arguments":"{}","call_id":"call_GuuoyhUrVJQbWfHHz0xaX3n9","name":"bmcp_coder_coder_list_templates"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":6269,"input_tokens_details":{"cached_tokens":0},"output_tokens":18,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":6287},"user":null,"metadata":{}},"sequence_number":6} + + +-- streaming/tool-call -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_0bc5f54fce6df69a006972442175908194bb81d31f576e6ca6","object":"response","created_at":1769096225,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-mini-2025-04-14","output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_0bc5f54fce6df69a006972442175908194bb81d31f576e6ca6","object":"response","created_at":1769096225,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-mini-2025-04-14","output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"You","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"QZM4urw1xaak6","output_index":0,"sequence_number":4} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" have","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"usbHqXys37s","output_index":0,"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" two","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"WKgFw2FY55RQ","output_index":0,"sequence_number":6} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" C","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"wPjrBzI29jjsB2","output_index":0,"sequence_number":7} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"oder","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"eDZmc9rjdvIF","output_index":0,"sequence_number":8} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" templates","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"evyfkj","output_index":0,"sequence_number":9} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":\n\n","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"BZRjLCOEOiuOh","output_index":0,"sequence_number":10} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"1","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"DQ8cCLt2XwnOfAQ","output_index":0,"sequence_number":11} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"wxFEJ0ZmPm9vAC9","output_index":0,"sequence_number":12} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Template","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"EqlgJyv","output_index":0,"sequence_number":13} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Name","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"IQzmuTwbKIW","output_index":0,"sequence_number":14} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"Tsm0URNHfetH1a0","output_index":0,"sequence_number":15} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" cod","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"unx1BK55WIq2","output_index":0,"sequence_number":16} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"ex","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"x61Oq01d0MlYup","output_index":0,"sequence_number":17} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-test","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"U9Utb2NbayF","output_index":0,"sequence_number":18} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\n","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"MhPCizJlZ6x0NAn","output_index":0,"sequence_number":19} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"hkLCM3FwejBVOn","output_index":0,"sequence_number":20} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" -","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"YqWYXmbHDFkKqo","output_index":0,"sequence_number":21} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Template","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"dKpeliD","output_index":0,"sequence_number":22} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ID","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"ZCpJPje0kioew","output_index":0,"sequence_number":23} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"f0FiI4P7Hw9QwFe","output_index":0,"sequence_number":24} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" d","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"GpGpdz5ggqUt9v","output_index":0,"sequence_number":25} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"85","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"jRiNicALP0TLuw","output_index":0,"sequence_number":26} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"cac","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"TOzkOsNDw4w1T","output_index":0,"sequence_number":27} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"35","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"9JI2E2fDlv7uGV","output_index":0,"sequence_number":28} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"jGZWiKpVBDuIKuB","output_index":0,"sequence_number":29} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"15","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"45vKLG0yKv1BkL","output_index":0,"sequence_number":30} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"a","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"RQiOieioJ32cC1M","output_index":0,"sequence_number":31} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"1","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"mHvgRqlKkgttJV0","output_index":0,"sequence_number":32} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"dQeAGrDM3ubfvnR","output_index":0,"sequence_number":33} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"4","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"Qi8Iqa9bKORcJ8f","output_index":0,"sequence_number":34} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"b","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"ixlmkIKIOY8Sm6d","output_index":0,"sequence_number":35} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"de","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"NHdvFUatWY2KcI","output_index":0,"sequence_number":36} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"gqAA7EfVeEJGRzz","output_index":0,"sequence_number":37} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"97","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"ErFDrzsCQLWqGE","output_index":0,"sequence_number":38} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"d","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"UqmClnYIeebOazH","output_index":0,"sequence_number":39} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"9","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"mRtql59MNGPcG23","output_index":0,"sequence_number":40} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"G2P0ixCA4iwTdea","output_index":0,"sequence_number":41} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"1","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"IV6jKd8GBouWr9E","output_index":0,"sequence_number":42} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"f","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"7LJzB4KhyNuCAIr","output_index":0,"sequence_number":43} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"3","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"jfKY1gS6oAbbG1r","output_index":0,"sequence_number":44} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"e","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"Gp170LGnW92KKPG","output_index":0,"sequence_number":45} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"4","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"jyZukjaVMuHwgDP","output_index":0,"sequence_number":46} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"b","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"aFOqDKgVveh2mtH","output_index":0,"sequence_number":47} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"851","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"zVEuHzpaeaElq","output_index":0,"sequence_number":48} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"246","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"uzCs5SweJSCcH","output_index":0,"sequence_number":49} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\n","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"mIwlvcCc03ehtty","output_index":0,"sequence_number":50} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"BFVmZiGV6qwn3V","output_index":0,"sequence_number":51} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" -","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"LHItf6Lqckhg0x","output_index":0,"sequence_number":52} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Active","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"kA5XfDOas","output_index":0,"sequence_number":53} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Version","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"yVX4epGs","output_index":0,"sequence_number":54} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ID","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"UsCBI3ilV5wSn","output_index":0,"sequence_number":55} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"lqb8Bbq8KNXdq43","output_index":0,"sequence_number":56} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"dDg5ePBosaMGrtB","output_index":0,"sequence_number":57} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"22","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"leI4f1hPQjEaXJ","output_index":0,"sequence_number":58} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"a","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"raV1BrKjm06ANNU","output_index":0,"sequence_number":59} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"3","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"5FanzMEq1jr4kiQ","output_index":0,"sequence_number":60} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"face","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"iDaDrGL2Bago","output_index":0,"sequence_number":61} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"jvKVV5v18zQCeaW","output_index":0,"sequence_number":62} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"0","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"DzfkZrcc8wSIfuo","output_index":0,"sequence_number":63} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"c","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"hT0Wl1KeEl2DzH6","output_index":0,"sequence_number":64} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"93","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"VDYX9dJkwO9Vco","output_index":0,"sequence_number":65} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"BiLJ7GaLI6OhJyo","output_index":0,"sequence_number":66} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"4","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"qBUSrkS4f7UiylD","output_index":0,"sequence_number":67} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"b","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"eSCGxxie1lfuIUU","output_index":0,"sequence_number":68} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"88","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"SPT9iYL5zvRmZe","output_index":0,"sequence_number":69} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-a","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"wTuFgv1hEJgxlH","output_index":0,"sequence_number":70} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"63","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"cDJJqYxrZ7UswS","output_index":0,"sequence_number":71} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"a","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"KyEmxIKjfQA7F7b","output_index":0,"sequence_number":72} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"AWlYRAVgVMfbraE","output_index":0,"sequence_number":73} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"1","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"b5fZV8eVfXHz8ce","output_index":0,"sequence_number":74} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"ec","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"QgnKaFspngIZdo","output_index":0,"sequence_number":75} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"165","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"D1AILoL2iuA3c","output_index":0,"sequence_number":76} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"1","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"rMAN6VCe9boBz7m","output_index":0,"sequence_number":77} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"e","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"H8sXR5csvG7tGAj","output_index":0,"sequence_number":78} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"019","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"0QgkQxXh7GsGV","output_index":0,"sequence_number":79} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"9","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"eqirLGzq8xA6lIO","output_index":0,"sequence_number":80} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\n","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"j62cz299oO91UYb","output_index":0,"sequence_number":81} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"POcsFRp3Xwtkqa","output_index":0,"sequence_number":82} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" -","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"C5l02h9XkmTjyD","output_index":0,"sequence_number":83} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Active","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"zj1EV7Aoc","output_index":0,"sequence_number":84} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" User","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"h5ZM2gBg5r9","output_index":0,"sequence_number":85} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Count","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"6aCr04Jz9d","output_index":0,"sequence_number":86} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"wCRjrOkyglj3jwc","output_index":0,"sequence_number":87} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"Xn0cr3EP3QE08ZU","output_index":0,"sequence_number":88} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"1","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"U9yhOtmZKr5TEAq","output_index":0,"sequence_number":89} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\n\n","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"UVeNPaqbxeFc5u","output_index":0,"sequence_number":90} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"2","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"1CUN8j8XNWsAFha","output_index":0,"sequence_number":91} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"ZegakiompB9P3fd","output_index":0,"sequence_number":92} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Template","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"Ir4C4TM","output_index":0,"sequence_number":93} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Name","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"8pFcHwZZiuK","output_index":0,"sequence_number":94} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"b8Hgw5SRMoMu3TR","output_index":0,"sequence_number":95} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" docker","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"s7o53JDb7","output_index":0,"sequence_number":96} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\n","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"42J11COksbtIy78","output_index":0,"sequence_number":97} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"zXZeG0dptA3lPv","output_index":0,"sequence_number":98} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" -","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"95ei03gWz31fsM","output_index":0,"sequence_number":99} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Template","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"f47E2Nw","output_index":0,"sequence_number":100} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ID","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"6z2FL8mbgg6hB","output_index":0,"sequence_number":101} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"aC5OyAKJVDSDJWI","output_index":0,"sequence_number":102} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"xZQbbKDDTQFfWRr","output_index":0,"sequence_number":103} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"7","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"O7WOTOQO5q53xc2","output_index":0,"sequence_number":104} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"e","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"2ndoXnggHzbvvAN","output_index":0,"sequence_number":105} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"799","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"tY2j0L7sZQgub","output_index":0,"sequence_number":106} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"e","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"aKl6RlgYcPwRzFu","output_index":0,"sequence_number":107} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"56","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"AL1ZZLMRuuA71d","output_index":0,"sequence_number":108} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"DW1fZhBtCkhmJyd","output_index":0,"sequence_number":109} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"659","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"KNV2KI6mTjqCE","output_index":0,"sequence_number":110} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"1","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"GpnSWFsp46Kovsu","output_index":0,"sequence_number":111} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"GruIcMmjsvZsunC","output_index":0,"sequence_number":112} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"4","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"OxK9Djfbz4ErnHx","output_index":0,"sequence_number":113} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"c","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"2bpbdnKClUsCFYe","output_index":0,"sequence_number":114} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"44","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"VazYtPtUNMgXVh","output_index":0,"sequence_number":115} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-b","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"OxRYRFAGjhxWMr","output_index":0,"sequence_number":116} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"575","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"1FGrVta9WeL6f","output_index":0,"sequence_number":117} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"2OphNITXU4p0EQe","output_index":0,"sequence_number":118} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"3","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"QyUJ6yRtky4xHwq","output_index":0,"sequence_number":119} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"c","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"ATMZPePP0IHBVWo","output_index":0,"sequence_number":120} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"72","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"VlP0dIsv69bymP","output_index":0,"sequence_number":121} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"b","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"UYj80B1HMrieRFD","output_index":0,"sequence_number":122} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"55","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"NKnztJJhpu10qJ","output_index":0,"sequence_number":123} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"b","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"LRDtjlT0DNOfLHi","output_index":0,"sequence_number":124} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"721","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"GvGBR88Vndet8","output_index":0,"sequence_number":125} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"7","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"G7dut5FO3UqLPut","output_index":0,"sequence_number":126} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\n","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"7ZguIKpgJxeULjx","output_index":0,"sequence_number":127} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"gVvZobOdwrr9aO","output_index":0,"sequence_number":128} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" -","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"VM4ZYLxcdx1Bob","output_index":0,"sequence_number":129} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Active","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"i33ftucJO","output_index":0,"sequence_number":130} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Version","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"uhDIgLyB","output_index":0,"sequence_number":131} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ID","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"2t4QL1nxgfK2s","output_index":0,"sequence_number":132} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"Rw1WGdlruDYmKfd","output_index":0,"sequence_number":133} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"Y1MlhBYrAGdgLpn","output_index":0,"sequence_number":134} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"805","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"MmdARl3jNXTwr","output_index":0,"sequence_number":135} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"7","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"qdWBOGnWGKbqJkP","output_index":0,"sequence_number":136} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"a","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"xcHamOysvg93oNb","output_index":0,"sequence_number":137} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"565","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"Kf3FMdWVFsB3T","output_index":0,"sequence_number":138} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"m1ap3NPTwOPZNkv","output_index":0,"sequence_number":139} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"1","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"b6eOy8hWgvKOlK1","output_index":0,"sequence_number":140} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"c","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"AW39acYsIcY3nMe","output_index":0,"sequence_number":141} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"12","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"zcIqeZHpnTZE1d","output_index":0,"sequence_number":142} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"swUCTpVmrGy2pPl","output_index":0,"sequence_number":143} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"489","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"j8GemL6YS3CMM","output_index":0,"sequence_number":144} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"e","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"JfIHjscIRln0K48","output_index":0,"sequence_number":145} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-a","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"eKKulDMnKwU60y","output_index":0,"sequence_number":146} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"563","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"kLWsukgaGxmAO","output_index":0,"sequence_number":147} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"-","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"1odZxSNeYBoCWqm","output_index":0,"sequence_number":148} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"8","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"31PLucOfEXFamMc","output_index":0,"sequence_number":149} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"e","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"rlUMmxWjdw2XN39","output_index":0,"sequence_number":150} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"8","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"keAUZGLKLzQLG89","output_index":0,"sequence_number":151} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"bb","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"o65s3ilddqnwOa","output_index":0,"sequence_number":152} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"162","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"8s8F6l4j5p6wh","output_index":0,"sequence_number":153} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"c","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"MyEUf4XE5LOnvYf","output_index":0,"sequence_number":154} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"867","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"QVSfza1vuMgZx","output_index":0,"sequence_number":155} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\n","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"XTiN1AyHl3hbaP6","output_index":0,"sequence_number":156} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"lZCGvlxTdGGCFg","output_index":0,"sequence_number":157} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" -","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"2ry2tDBVuuGzxY","output_index":0,"sequence_number":158} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Active","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"1aS5q26NB","output_index":0,"sequence_number":159} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" User","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"DMvFqJDYQ9T","output_index":0,"sequence_number":160} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Count","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"nukadYlYL4","output_index":0,"sequence_number":161} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"YinpsRGW8RsKfMf","output_index":0,"sequence_number":162} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"dsBFCguXzmJBRFg","output_index":0,"sequence_number":163} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"1","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"auF57xJRN1YraEc","output_index":0,"sequence_number":164} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\n\n","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"qMbvEysx53XAfI","output_index":0,"sequence_number":165} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"Let","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"xv8GZQm3X0GA3","output_index":0,"sequence_number":166} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" me","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"SPwAMUU4xtfND","output_index":0,"sequence_number":167} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" know","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"E2PStq8dSUC","output_index":0,"sequence_number":168} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" if","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"PKctrSZqBpGfV","output_index":0,"sequence_number":169} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" you","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"0iLQFx5BRIvP","output_index":0,"sequence_number":170} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" want","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"KCzAYJMVovk","output_index":0,"sequence_number":171} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" more","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"q5gOJpigugA","output_index":0,"sequence_number":172} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" details","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"LtZRfMwf","output_index":0,"sequence_number":173} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" or","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"5PLdaHh6O5J2D","output_index":0,"sequence_number":174} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" want","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"LMR3Gp2HPo2","output_index":0,"sequence_number":175} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" to","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"FeOdiIXVytej9","output_index":0,"sequence_number":176} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" perform","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"4EFU400U","output_index":0,"sequence_number":177} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" any","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"SSpEmxPx6MIf","output_index":0,"sequence_number":178} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" actions","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"xJ18CqJy","output_index":0,"sequence_number":179} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" with","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"PqcjO40BntE","output_index":0,"sequence_number":180} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" these","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"ZpvWw5Hgz0","output_index":0,"sequence_number":181} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" templates","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"MElg3Z","output_index":0,"sequence_number":182} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"obfuscation":"pcZp5SPrtMJIkc6","output_index":0,"sequence_number":183} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","logprobs":[],"output_index":0,"sequence_number":184,"text":"You have two Coder templates:\n\n1. Template Name: codex-test\n - Template ID: d85cac35-15a1-4bde-97d9-1f3e4b851246\n - Active Version ID: 22a3face-0c93-4b88-a63a-1ec1651e0199\n - Active User Count: 1\n\n2. Template Name: docker\n - Template ID: 7e799e56-6591-4c44-b575-3c72b55b7217\n - Active Version ID: 8057a565-1c12-489e-a563-8e8bb162c867\n - Active User Count: 1\n\nLet me know if you want more details or want to perform any actions with these templates."} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"You have two Coder templates:\n\n1. Template Name: codex-test\n - Template ID: d85cac35-15a1-4bde-97d9-1f3e4b851246\n - Active Version ID: 22a3face-0c93-4b88-a63a-1ec1651e0199\n - Active User Count: 1\n\n2. Template Name: docker\n - Template ID: 7e799e56-6591-4c44-b575-3c72b55b7217\n - Active Version ID: 8057a565-1c12-489e-a563-8e8bb162c867\n - Active User Count: 1\n\nLet me know if you want more details or want to perform any actions with these templates."},"sequence_number":185} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"You have two Coder templates:\n\n1. Template Name: codex-test\n - Template ID: d85cac35-15a1-4bde-97d9-1f3e4b851246\n - Active Version ID: 22a3face-0c93-4b88-a63a-1ec1651e0199\n - Active User Count: 1\n\n2. Template Name: docker\n - Template ID: 7e799e56-6591-4c44-b575-3c72b55b7217\n - Active Version ID: 8057a565-1c12-489e-a563-8e8bb162c867\n - Active User Count: 1\n\nLet me know if you want more details or want to perform any actions with these templates."}],"role":"assistant"},"output_index":0,"sequence_number":186} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_0bc5f54fce6df69a006972442175908194bb81d31f576e6ca6","object":"response","created_at":1769096225,"status":"completed","background":false,"completed_at":1769096230,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-mini-2025-04-14","output":[{"id":"msg_0bc5f54fce6df69a0069724421feb88194acb48ce194f3ee14","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"You have two Coder templates:\n\n1. Template Name: codex-test\n - Template ID: d85cac35-15a1-4bde-97d9-1f3e4b851246\n - Active Version ID: 22a3face-0c93-4b88-a63a-1ec1651e0199\n - Active User Count: 1\n\n2. Template Name: docker\n - Template ID: 7e799e56-6591-4c44-b575-3c72b55b7217\n - Active Version ID: 8057a565-1c12-489e-a563-8e8bb162c867\n - Active User Count: 1\n\nLet me know if you want more details or want to perform any actions with these templates."}],"role":"assistant"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":6463,"input_tokens_details":{"cached_tokens":6144},"output_tokens":182,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":6645},"user":null,"metadata":{}},"sequence_number":187} + diff --git a/aibridge/fixtures/openai/responses/streaming/single_injected_tool_error.txtar b/aibridge/fixtures/openai/responses/streaming/single_injected_tool_error.txtar new file mode 100644 index 0000000000..95dd43e543 --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/single_injected_tool_error.txtar @@ -0,0 +1,250 @@ +-- request -- +{ + "input": "Create a new workspace build for an workspace with id: 'non_existing_id'", + "model": "gpt-4.1", + "stream": true +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_0dfed48e1052ad7f0069725ca129f88193b97d6deff1760524","object":"response","created_at":1769102497,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_0dfed48e1052ad7f0069725ca129f88193b97d6deff1760524","object":"response","created_at":1769102497,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","type":"function_call","status":"in_progress","arguments":"","call_id":"call_1wHAlwmnxtbUzowDJkmlcpJ4","name":"bmcp_coder_coder_create_workspace_build"},"output_index":0,"sequence_number":2} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"{\"","item_id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","obfuscation":"eb7NTGNIx3zf72","output_index":0,"sequence_number":3} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"transition","item_id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","obfuscation":"3dmpMw","output_index":0,"sequence_number":4} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"\":\"","item_id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","obfuscation":"nfPTq6DHhjWLu","output_index":0,"sequence_number":5} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"start","item_id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","obfuscation":"XsznuHiS3Vt","output_index":0,"sequence_number":6} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"\",\"","item_id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","obfuscation":"bNBG2rRR9bS4r","output_index":0,"sequence_number":7} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"workspace","item_id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","obfuscation":"FDeCYyM","output_index":0,"sequence_number":8} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"_id","item_id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","obfuscation":"WRVFUzAs232ss","output_index":0,"sequence_number":9} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"\":\"","item_id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","obfuscation":"54VnaDyyihKnk","output_index":0,"sequence_number":10} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"non","item_id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","obfuscation":"og8U8E2WaaDry","output_index":0,"sequence_number":11} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"_existing","item_id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","obfuscation":"vMfbN4q","output_index":0,"sequence_number":12} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"_id","item_id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","obfuscation":"ageUrWCZ4NtvN","output_index":0,"sequence_number":13} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"\"}","item_id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","obfuscation":"QAr11uV3Xjv4mz","output_index":0,"sequence_number":14} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","arguments":"{\"transition\":\"start\",\"workspace_id\":\"non_existing_id\"}","item_id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","output_index":0,"sequence_number":15} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","type":"function_call","status":"completed","arguments":"{\"transition\":\"start\",\"workspace_id\":\"non_existing_id\"}","call_id":"call_1wHAlwmnxtbUzowDJkmlcpJ4","name":"bmcp_coder_coder_create_workspace_build"},"output_index":0,"sequence_number":16} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_0dfed48e1052ad7f0069725ca129f88193b97d6deff1760524","object":"response","created_at":1769102497,"status":"completed","background":false,"completed_at":1769102499,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[{"id":"fc_0dfed48e1052ad7f0069725ca2cbac8193a79ff3716ec63dda","type":"function_call","status":"completed","arguments":"{\"transition\":\"start\",\"workspace_id\":\"non_existing_id\"}","call_id":"call_1wHAlwmnxtbUzowDJkmlcpJ4","name":"bmcp_coder_coder_create_workspace_build"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":6280,"input_tokens_details":{"cached_tokens":0},"output_tokens":30,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":6310},"user":null,"metadata":{}},"sequence_number":17} + + +-- streaming/tool-call -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_0dfed48e1052ad7f0069725ca39880819390fcc5b2eb8cf8c6","object":"response","created_at":1769102499,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_0dfed48e1052ad7f0069725ca39880819390fcc5b2eb8cf8c6","object":"response","created_at":1769102499,"status":"in_progress","background":false,"completed_at":null,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"auto","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"The","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"TKgTL0Pm6EogW","output_index":0,"sequence_number":4} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" workspace","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"e4sZAa","output_index":0,"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ID","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"yse6sk70MvBjq","output_index":0,"sequence_number":6} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" you","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"JHoPiuz85VV8","output_index":0,"sequence_number":7} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" provided","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"aMFkYF0","output_index":0,"sequence_number":8} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ('","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"2zu5pVeyPsBbB","output_index":0,"sequence_number":9} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"non","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"6dDKJt6WPQ9hc","output_index":0,"sequence_number":10} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"_existing","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"jfUWlxy","output_index":0,"sequence_number":11} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"_id","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"IMYReVeCsK7dq","output_index":0,"sequence_number":12} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"')","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"scWRiKDyU1ZpA0","output_index":0,"sequence_number":13} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" is","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"oAQP4OQVYR9zZ","output_index":0,"sequence_number":14} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" not","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"jz6pvM10z2Av","output_index":0,"sequence_number":15} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" valid","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"c5JrDo34X4","output_index":0,"sequence_number":16} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"wMuYbFeA2oJ0o10","output_index":0,"sequence_number":17} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Workspace","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"QKQ6VQ","output_index":0,"sequence_number":18} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" IDs","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"tOu6hXGHygZK","output_index":0,"sequence_number":19} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" must","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"oDF4o3hbxzl","output_index":0,"sequence_number":20} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" be","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"gmociys8LhrUB","output_index":0,"sequence_number":21} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" valid","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"PEBQD6ceau","output_index":0,"sequence_number":22} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" UUID","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"QwCvBEyXRJe","output_index":0,"sequence_number":23} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"s","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"QNKHadT1sLfnHpq","output_index":0,"sequence_number":24} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" (","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"dU5qvnsUhBX2e0","output_index":0,"sequence_number":25} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"typically","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"4EUnnTT","output_index":0,"sequence_number":26} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"xK3LQlp2Rop19Yz","output_index":0,"sequence_number":27} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"36","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"5gMRSnNRXJgfsK","output_index":0,"sequence_number":28} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" characters","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"hOSE1","output_index":0,"sequence_number":29} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" long","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"YPMeubesRDi","output_index":0,"sequence_number":30} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":").","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"V4BiwQVWWtYzwx","output_index":0,"sequence_number":31} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" Please","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"N04RU3zKV","output_index":0,"sequence_number":32} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" provide","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"p1RReFPU","output_index":0,"sequence_number":33} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" a","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"II0BFYCJOkM0Sd","output_index":0,"sequence_number":34} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" valid","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"hvsZ05Fz8L","output_index":0,"sequence_number":35} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" workspace","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"kzdEey","output_index":0,"sequence_number":36} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ID","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"oIqhs2yNz26fs","output_index":0,"sequence_number":37} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" to","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"HXAqJ1Ab6M9bg","output_index":0,"sequence_number":38} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" create","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"GeoaFDc17","output_index":0,"sequence_number":39} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" a","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"6tSm506RxPkETp","output_index":0,"sequence_number":40} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" new","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"NZemUimGK14v","output_index":0,"sequence_number":41} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" workspace","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"UVRvTN","output_index":0,"sequence_number":42} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" build","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"BtxRKmyw2n","output_index":0,"sequence_number":43} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"zpUUDA14iR75rEV","output_index":0,"sequence_number":44} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" If","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"gOPfM80ZWLQpV","output_index":0,"sequence_number":45} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" you","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"WFxoe8eLGgju","output_index":0,"sequence_number":46} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" need","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"B8BmiwWQ9jX","output_index":0,"sequence_number":47} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" help","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"KMnOBdOse5K","output_index":0,"sequence_number":48} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" finding","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"KOMWfui2","output_index":0,"sequence_number":49} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" your","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"dHNHO0vDHaG","output_index":0,"sequence_number":50} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" workspace","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"xljKhX","output_index":0,"sequence_number":51} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ID","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"4u8DmtcUycHKX","output_index":0,"sequence_number":52} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"Z1Swx6A7cYB71dZ","output_index":0,"sequence_number":53} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" let","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"pYfjOG7nluHG","output_index":0,"sequence_number":54} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" me","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"tSNEY9rCu9vIy","output_index":0,"sequence_number":55} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" know","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"cP0kmsLtpTY","output_index":0,"sequence_number":56} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"!","item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"obfuscation":"zPqpWOWpNnTX5D8","output_index":0,"sequence_number":57} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","logprobs":[],"output_index":0,"sequence_number":58,"text":"The workspace ID you provided ('non_existing_id') is not valid. Workspace IDs must be valid UUIDs (typically 36 characters long). Please provide a valid workspace ID to create a new workspace build. If you need help finding your workspace ID, let me know!"} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"The workspace ID you provided ('non_existing_id') is not valid. Workspace IDs must be valid UUIDs (typically 36 characters long). Please provide a valid workspace ID to create a new workspace build. If you need help finding your workspace ID, let me know!"},"sequence_number":59} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The workspace ID you provided ('non_existing_id') is not valid. Workspace IDs must be valid UUIDs (typically 36 characters long). Please provide a valid workspace ID to create a new workspace build. If you need help finding your workspace ID, let me know!"}],"role":"assistant"},"output_index":0,"sequence_number":60} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_0dfed48e1052ad7f0069725ca39880819390fcc5b2eb8cf8c6","object":"response","created_at":1769102499,"status":"completed","background":false,"completed_at":1769102501,"error":null,"frequency_penalty":0.0,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4.1-2025-04-14","output":[{"id":"msg_0dfed48e1052ad7f0069725ca4c2488193a652eba330c51e5b","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"The workspace ID you provided ('non_existing_id') is not valid. Workspace IDs must be valid UUIDs (typically 36 characters long). Please provide a valid workspace ID to create a new workspace build. If you need help finding your workspace ID, let me know!"}],"role":"assistant"}],"parallel_tool_calls":false,"presence_penalty":0.0,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[{"type":"function","description":"Create a task.","name":"bmcp_coder_coder_create_task","parameters":{"properties":{"input":{"description":"Input/prompt for the task.","type":"string"},"template_version_id":{"description":"ID of the template version to create the task from.","type":"string"},"template_version_preset_id":{"description":"Optional ID of the template version preset to create the task from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a task. Omit or use the `me` keyword to create a task for the authenticated user.","type":"string"}},"required":["input","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template in Coder. First, you must create a template version.","name":"bmcp_coder_coder_create_template","parameters":{"properties":{"description":{"type":"string"},"display_name":{"type":"string"},"icon":{"description":"A URL to an icon to use.","type":"string"},"name":{"type":"string"},"version_id":{"description":"The ID of the version to use.","type":"string"}},"required":["name","display_name","description","version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new template version. This is a precursor to creating a template, or you can update an existing template.\n\nTemplates are Terraform defining a development environment. The provisioned infrastructure must run\nan Agent that connects to the Coder Control Plane to provide a rich experience.\n\nHere are some strict rules for creating a template version:\n- YOU MUST NOT use \"variable\" or \"output\" blocks in the Terraform code.\n- YOU MUST ALWAYS check template version logs after creation to ensure the template was imported successfully.\n\nWhen a template version is created, a Terraform Plan occurs that ensures the infrastructure\n_could_ be provisioned, but actual provisioning occurs when a workspace is created.\n\n\nThe Coder Terraform Provider can be imported like:\n\n```hcl\nterraform {\n required_providers {\n coder = {\n source = \"coder/coder\"\n }\n }\n}\n```\n\nA destroy does not occur when a user stops a workspace, but rather the transition changes:\n\n```hcl\ndata \"coder_workspace\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace.\n- name: The name of the workspace.\n- transition: Either \"start\" or \"stop\".\n- start_count: A computed count based on the transition field. If \"start\", this will be 1.\n\nAccess workspace owner information with:\n\n```hcl\ndata \"coder_workspace_owner\" \"me\" {}\n```\n\nThis data source provides the following fields:\n- id: The UUID of the workspace owner.\n- name: The name of the workspace owner.\n- full_name: The full name of the workspace owner.\n- email: The email of the workspace owner.\n- session_token: A token that can be used to authenticate the workspace owner. It is regenerated every time the workspace is started.\n- oidc_access_token: A valid OpenID Connect access token of the workspace owner. This is only available if the workspace owner authenticated with OpenID Connect. If a valid token cannot be obtained, this value will be an empty string.\n\nParameters are defined in the template version. They are rendered in the UI on the workspace creation page:\n\n```hcl\nresource \"coder_parameter\" \"region\" {\n name = \"region\"\n type = \"string\"\n default = \"us-east-1\"\n}\n```\n\nThis resource accepts the following properties:\n- name: The name of the parameter.\n- default: The default value of the parameter.\n- type: The type of the parameter. Must be one of: \"string\", \"number\", \"bool\", or \"list(string)\".\n- display_name: The displayed name of the parameter as it will appear in the UI.\n- description: The description of the parameter as it will appear in the UI.\n- ephemeral: The value of an ephemeral parameter will not be preserved between consecutive workspace builds.\n- form_type: The type of this parameter. Must be one of: [radio, slider, input, dropdown, checkbox, switch, multi-select, tag-select, textarea, error].\n- icon: A URL to an icon to display in the UI.\n- mutable: Whether this value can be changed after workspace creation. This can be destructive for values like region, so use with caution!\n- option: Each option block defines a value for a user to select from. (see below for nested schema)\n Required:\n - name: The name of the option.\n - value: The value of the option.\n Optional:\n - description: The description of the option as it will appear in the UI.\n - icon: A URL to an icon to display in the UI.\n\nA Workspace Agent runs on provisioned infrastructure to provide access to the workspace:\n\n```hcl\nresource \"coder_agent\" \"dev\" {\n arch = \"amd64\"\n os = \"linux\"\n}\n```\n\nThis resource accepts the following properties:\n- arch: The architecture of the agent. Must be one of: \"amd64\", \"arm64\", or \"armv7\".\n- os: The operating system of the agent. Must be one of: \"linux\", \"windows\", or \"darwin\".\n- auth: The authentication method for the agent. Must be one of: \"token\", \"google-instance-identity\", \"aws-instance-identity\", or \"azure-instance-identity\". It is insecure to pass the agent token via exposed variables to Virtual Machines. Instance Identity enables provisioned VMs to authenticate by instance ID on start.\n- dir: The starting directory when a user creates a shell session. Defaults to \"$HOME\".\n- env: A map of environment variables to set for the agent.\n- startup_script: A script to run after the agent starts. This script MUST exit eventually to signal that startup has completed. Use \"&\" or \"screen\" to run processes in the background.\n\nThis resource provides the following fields:\n- id: The UUID of the agent.\n- init_script: The script to run on provisioned infrastructure to fetch and start the agent.\n- token: Set the environment variable CODER_AGENT_TOKEN to this value to authenticate the agent.\n\nThe agent MUST be installed and started using the init_script. A utility like curl or wget to fetch the agent binary must exist in the provisioned infrastructure.\n\nExpose terminal or HTTP applications running in a workspace with:\n\n```hcl\nresource \"coder_app\" \"dev\" {\n agent_id = coder_agent.dev.id\n slug = \"my-app-name\"\n display_name = \"My App\"\n icon = \"https://my-app.com/icon.svg\"\n url = \"http://127.0.0.1:3000\"\n}\n```\n\nThis resource accepts the following properties:\n- agent_id: The ID of the agent to attach the app to.\n- slug: The slug of the app.\n- display_name: The displayed name of the app as it will appear in the UI.\n- icon: A URL to an icon to display in the UI.\n- url: An external url if external=true or a URL to be proxied to from inside the workspace. This should be of the form http://localhost:PORT[/SUBPATH]. Either command or url may be specified, but not both.\n- command: A command to run in a terminal opening this app. In the web, this will open in a new tab. In the CLI, this will SSH and execute the command. Either command or url may be specified, but not both.\n- external: Whether this app is an external app. If true, the url will be opened in a new tab.\n\n\nThe Coder Server may not be authenticated with the infrastructure provider a user requests. In this scenario,\nthe user will need to provide credentials to the Coder Server before the workspace can be provisioned.\n\nHere are examples of provisioning the Coder Agent on specific infrastructure providers:\n\n\n// The agent is configured with \"aws-instance-identity\" auth.\nterraform {\n required_providers {\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n aws = {\n source = \"hashicorp/aws\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = false\n boundary = \"//\"\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${linux_user}\n\t// sudo: ALL=(ALL) NOPASSWD:ALL\n\t// shell: /bin/bash\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n hostname = local.hostname\n linux_user = local.linux_user\n })\n }\n\n part {\n filename = \"userdata.sh\"\n content_type = \"text/x-shellscript\"\n\n\t// Here is the content of the userdata.sh.tftpl file:\n\t// #!/bin/bash\n\t// sudo -u '${linux_user}' sh -c '${init_script}'\n content = templatefile(\"${path.module}/cloud-init/userdata.sh.tftpl\", {\n linux_user = local.linux_user\n\n init_script = try(coder_agent.dev[0].init_script, \"\")\n })\n }\n}\n\nresource \"aws_instance\" \"dev\" {\n ami = data.aws_ami.ubuntu.id\n availability_zone = \"${data.coder_parameter.region.value}a\"\n instance_type = data.coder_parameter.instance_type.value\n\n user_data = data.cloudinit_config.user_data.rendered\n tags = {\n Name = \"coder-${data.coder_workspace_owner.me.name}-${data.coder_workspace.me.name}\"\n }\n lifecycle {\n ignore_changes = [ami]\n }\n}\n\n\n\n// The agent is configured with \"google-instance-identity\" auth.\nterraform {\n required_providers {\n google = {\n source = \"hashicorp/google\"\n }\n }\n}\n\nresource \"google_compute_instance\" \"dev\" {\n zone = module.gcp_region.value\n count = data.coder_workspace.me.start_count\n name = \"coder-${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace.me.name)}-root\"\n machine_type = \"e2-medium\"\n network_interface {\n network = \"default\"\n access_config {\n // Ephemeral public IP\n }\n }\n boot_disk {\n auto_delete = false\n source = google_compute_disk.root.name\n }\n // In order to use google-instance-identity, a service account *must* be provided.\n service_account {\n email = data.google_compute_default_service_account.default.email\n scopes = [\"cloud-platform\"]\n }\n # ONLY FOR WINDOWS:\n # metadata = {\n # windows-startup-script-ps1 = coder_agent.main.init_script\n # }\n # The startup script runs as root with no $HOME environment set up, so instead of directly\n # running the agent init script, create a user (with a homedir, default shell and sudo\n # permissions) and execute the init script as that user.\n #\n # The agent MUST be started in here.\n metadata_startup_script = </dev/null 2>&1; then\n useradd -m -s /bin/bash \"${local.linux_user}\"\n echo \"${local.linux_user} ALL=(ALL) NOPASSWD:ALL\" > /etc/sudoers.d/coder-user\nfi\n\nexec sudo -u \"${local.linux_user}\" sh -c '${coder_agent.main.init_script}'\nEOMETA\n}\n\n\n\n// The agent is configured with \"azure-instance-identity\" auth.\nterraform {\n required_providers {\n azurerm = {\n source = \"hashicorp/azurerm\"\n }\n cloudinit = {\n source = \"hashicorp/cloudinit\"\n }\n }\n}\n\ndata \"cloudinit_config\" \"user_data\" {\n gzip = false\n base64_encode = true\n\n boundary = \"//\"\n\n part {\n filename = \"cloud-config.yaml\"\n content_type = \"text/cloud-config\"\n\n\t// Here is the content of the cloud-config.yaml.tftpl file:\n\t// #cloud-config\n\t// cloud_final_modules:\n\t// - [scripts-user, always]\n\t// bootcmd:\n\t// # work around https://github.com/hashicorp/terraform-provider-azurerm/issues/6117\n\t// - until [ -e /dev/disk/azure/scsi1/lun10 ]; do sleep 1; done\n\t// device_aliases:\n\t// homedir: /dev/disk/azure/scsi1/lun10\n\t// disk_setup:\n\t// homedir:\n\t// table_type: gpt\n\t// layout: true\n\t// fs_setup:\n\t// - label: coder_home\n\t// filesystem: ext4\n\t// device: homedir.1\n\t// mounts:\n\t// - [\"LABEL=coder_home\", \"/home/${username}\"]\n\t// hostname: ${hostname}\n\t// users:\n\t// - name: ${username}\n\t// sudo: [\"ALL=(ALL) NOPASSWD:ALL\"]\n\t// groups: sudo\n\t// shell: /bin/bash\n\t// packages:\n\t// - git\n\t// write_files:\n\t// - path: /opt/coder/init\n\t// permissions: \"0755\"\n\t// encoding: b64\n\t// content: ${init_script}\n\t// - path: /etc/systemd/system/coder-agent.service\n\t// permissions: \"0644\"\n\t// content: |\n\t// [Unit]\n\t// Description=Coder Agent\n\t// After=network-online.target\n\t// Wants=network-online.target\n\n\t// [Service]\n\t// User=${username}\n\t// ExecStart=/opt/coder/init\n\t// Restart=always\n\t// RestartSec=10\n\t// TimeoutStopSec=90\n\t// KillMode=process\n\n\t// OOMScoreAdjust=-900\n\t// SyslogIdentifier=coder-agent\n\n\t// [Install]\n\t// WantedBy=multi-user.target\n\t// runcmd:\n\t// - chown ${username}:${username} /home/${username}\n\t// - systemctl enable coder-agent\n\t// - systemctl start coder-agent\n content = templatefile(\"${path.module}/cloud-init/cloud-config.yaml.tftpl\", {\n username = \"coder\" # Ensure this user/group does not exist in your VM image\n init_script = base64encode(coder_agent.main.init_script)\n hostname = lower(data.coder_workspace.me.name)\n })\n }\n}\n\nresource \"azurerm_linux_virtual_machine\" \"main\" {\n count = data.coder_workspace.me.start_count\n name = \"vm\"\n resource_group_name = azurerm_resource_group.main.name\n location = azurerm_resource_group.main.location\n size = data.coder_parameter.instance_type.value\n // cloud-init overwrites this, so the value here doesn't matter\n admin_username = \"adminuser\"\n admin_ssh_key {\n public_key = tls_private_key.dummy.public_key_openssh\n username = \"adminuser\"\n }\n\n network_interface_ids = [\n azurerm_network_interface.main.id,\n ]\n computer_name = lower(data.coder_workspace.me.name)\n os_disk {\n caching = \"ReadWrite\"\n storage_account_type = \"Standard_LRS\"\n }\n source_image_reference {\n publisher = \"Canonical\"\n offer = \"0001-com-ubuntu-server-focal\"\n sku = \"20_04-lts-gen2\"\n version = \"latest\"\n }\n user_data = data.cloudinit_config.user_data.rendered\n}\n\n\n\nterraform {\n required_providers {\n coder = {\n source = \"kreuzwerker/docker\"\n }\n }\n}\n\n// The agent is configured with \"token\" auth.\n\nresource \"docker_container\" \"workspace\" {\n count = data.coder_workspace.me.start_count\n image = \"codercom/enterprise-base:ubuntu\"\n # Uses lower() to avoid Docker restriction on container names.\n name = \"coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}\"\n # Hostname makes the shell more user friendly: coder@my-workspace:~$\n hostname = data.coder_workspace.me.name\n # Use the docker gateway if the access URL is 127.0.0.1.\n entrypoint = [\"sh\", \"-c\", replace(coder_agent.main.init_script, \"/localhost|127\\\\.0\\\\.0\\\\.1/\", \"host.docker.internal\")]\n env = [\"CODER_AGENT_TOKEN=${coder_agent.main.token}\"]\n host {\n host = \"host.docker.internal\"\n ip = \"host-gateway\"\n }\n volumes {\n container_path = \"/home/coder\"\n volume_name = docker_volume.home_volume.name\n read_only = false\n }\n}\n\n\n\n// The agent is configured with \"token\" auth.\n\nresource \"kubernetes_deployment\" \"main\" {\n count = data.coder_workspace.me.start_count\n depends_on = [\n kubernetes_persistent_volume_claim.home\n ]\n wait_for_rollout = false\n metadata {\n name = \"coder-${data.coder_workspace.me.id}\"\n }\n\n spec {\n replicas = 1\n strategy {\n type = \"Recreate\"\n }\n\n template {\n spec {\n security_context {\n run_as_user = 1000\n fs_group = 1000\n run_as_non_root = true\n }\n\n container {\n name = \"dev\"\n image = \"codercom/enterprise-base:ubuntu\"\n image_pull_policy = \"Always\"\n command = [\"sh\", \"-c\", coder_agent.main.init_script]\n security_context {\n run_as_user = \"1000\"\n }\n env {\n name = \"CODER_AGENT_TOKEN\"\n value = coder_agent.main.token\n }\n }\n }\n }\n }\n}\n\n\nThe file_id provided is a reference to a tar file you have uploaded containing the Terraform.\n","name":"bmcp_coder_coder_create_template_version","parameters":{"properties":{"file_id":{"type":"string"},"template_id":{"type":"string"}},"required":["file_id"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace in Coder.\n\nIf a user is asking to \"test a template\", they are typically referring\nto creating a workspace from a template to ensure the infrastructure\nis provisioned correctly and the agent can connect to the control plane.\n\nBefore creating a workspace, always confirm the template choice with the user by:\n\n\t1. Listing the available templates that match their request.\n\t2. Recommending the most relevant option.\n\t2. Asking the user to confirm which template to use.\n\nIt is important to not create a workspace without confirming the template\nchoice with the user.\n\nAfter creating a workspace, watch the build logs and wait for the workspace to\nbe ready before trying to use or connect to the workspace.\n","name":"bmcp_coder_coder_create_workspace","parameters":{"properties":{"name":{"description":"Name of the workspace to create.","type":"string"},"rich_parameters":{"description":"Key/value pairs of rich parameters to pass to the template version to create the workspace.","type":"object"},"template_version_id":{"description":"ID of the template version to create the workspace from.","type":"string"},"user":{"description":"Username or ID of the user for which to create a workspace. Omit or use the `me` keyword to create a workspace for the authenticated user.","type":"string"}},"required":["user","template_version_id","name","rich_parameters"],"type":"object"},"strict":false},{"type":"function","description":"Create a new workspace build for an existing workspace. Use this to start, stop, or delete.\n\nAfter creating a workspace build, watch the build logs and wait for the\nworkspace build to complete before trying to start another build or use or\nconnect to the workspace.\n","name":"bmcp_coder_coder_create_workspace_build","parameters":{"properties":{"template_version_id":{"description":"(Optional) The template version ID to use for the workspace build. If not provided, the previously built version will be used.","type":"string"},"transition":{"description":"The transition to perform. Must be one of: start, stop, delete","enum":["start","stop","delete"],"type":"string"},"workspace_id":{"type":"string"}},"required":["workspace_id","transition"],"type":"object"},"strict":false},{"type":"function","description":"Delete a task.","name":"bmcp_coder_coder_delete_task","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to delete. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Delete a template. This is irreversible.","name":"bmcp_coder_coder_delete_template","parameters":{"properties":{"template_id":{"type":"string"}},"required":["template_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the currently authenticated user, similar to the `whoami` command.","name":"bmcp_coder_coder_get_authenticated_user","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a task.","name":"bmcp_coder_coder_get_task_logs","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to query. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the status of a task.","name":"bmcp_coder_coder_get_task_status","parameters":{"properties":{"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to get. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a template version. This is useful to check whether a template version successfully imports or not.","name":"bmcp_coder_coder_get_template_version_logs","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Get a workspace by name or ID.\n\nThis returns more data than list_workspaces to reduce token usage.","name":"bmcp_coder_coder_get_workspace","parameters":{"properties":{"workspace_id":{"description":"The workspace ID or name in the format [owner/]workspace. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace agent.\n\n\t\tMore logs may appear after this call. It does not wait for the agent to finish.","name":"bmcp_coder_coder_get_workspace_agent_logs","parameters":{"properties":{"workspace_agent_id":{"type":"string"}},"required":["workspace_agent_id"],"type":"object"},"strict":false},{"type":"function","description":"Get the logs of a workspace build.\n\n\t\tUseful for checking whether a workspace builds successfully or not.","name":"bmcp_coder_coder_get_workspace_build_logs","parameters":{"properties":{"workspace_build_id":{"type":"string"}},"required":["workspace_build_id"],"type":"object"},"strict":false},{"type":"function","description":"List tasks.","name":"bmcp_coder_coder_list_tasks","parameters":{"properties":{"status":{"description":"Optional filter by task status.","type":"string"},"user":{"description":"Username or ID of the user for which to list tasks. Omit or use the `me` keyword to list tasks for the authenticated user.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Lists templates for the authenticated user.","name":"bmcp_coder_coder_list_templates","parameters":{"properties":{},"type":"object"},"strict":false},{"type":"function","description":"Lists workspaces for the authenticated user.","name":"bmcp_coder_coder_list_workspaces","parameters":{"properties":{"owner":{"description":"The owner of the workspaces to list. Use \"me\" to list workspaces for the authenticated user. If you do not specify an owner, \"me\" will be assumed by default.","type":"string"}},"type":"object"},"strict":false},{"type":"function","description":"Send input to a running task.","name":"bmcp_coder_coder_send_task_input","parameters":{"properties":{"input":{"description":"The input to send to the task.","type":"string"},"task_id":{"description":"ID or workspace identifier in the format [owner/]workspace[.agent] for the task to prompt. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["task_id","input"],"type":"object"},"strict":false},{"type":"function","description":"Get the parameters for a template version. You can refer to these as workspace parameters to the user, as they are typically important for creating a workspace.","name":"bmcp_coder_coder_template_version_parameters","parameters":{"properties":{"template_version_id":{"type":"string"}},"required":["template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Update the active version of a template. This is helpful when iterating on templates.","name":"bmcp_coder_coder_update_template_active_version","parameters":{"properties":{"template_id":{"type":"string"},"template_version_id":{"type":"string"}},"required":["template_id","template_version_id"],"type":"object"},"strict":false},{"type":"function","description":"Create and upload a tar file by key/value mapping of file names to file contents. Use this to create template versions. Reference the tool description of \"create_template_version\" to understand template requirements.","name":"bmcp_coder_coder_upload_tar_file","parameters":{"properties":{"files":{"description":"A map of file names to file contents.","type":"object"}},"required":["files"],"type":"object"},"strict":false},{"type":"function","description":"Execute a bash command in a Coder workspace.\n\nThis tool provides the same functionality as the 'coder ssh ' CLI command.\nIt automatically starts the workspace if it's stopped and waits for the agent to be ready.\nThe output is trimmed of leading and trailing whitespace.\n\nThe workspace parameter supports various formats:\n- workspace (uses current user)\n- owner/workspace\n- owner--workspace\n- workspace.agent (specific agent)\n- owner/workspace.agent\n\nThe timeout_ms parameter specifies the command timeout in milliseconds (defaults to 60000ms, maximum of 300000ms).\nIf the command times out, all output captured up to that point is returned with a cancellation message.\n\nFor background commands (background: true), output is captured until the timeout is reached, then the command\ncontinues running in the background. The captured output is returned as the result.\n\nFor file operations (list, write, edit), always prefer the dedicated file tools.\nDo not use bash commands (ls, cat, echo, heredoc, etc.) to list, write, or read\nfiles when the file tools are available. The bash tool should be used for:\n\n\t- Running commands and scripts\n\t- Installing packages\n\t- Starting services\n\t- Executing programs\n\nExamples:\n- workspace: \"john/dev-env\", command: \"git status\", timeout_ms: 30000\n- workspace: \"my-workspace\", command: \"npm run dev\", background: true, timeout_ms: 10000\n- workspace: \"my-workspace.main\", command: \"docker ps\"","name":"bmcp_coder_coder_workspace_bash","parameters":{"properties":{"background":{"description":"Whether to run the command in the background. Output is captured until timeout, then the command continues running in the background.","type":"boolean"},"command":{"description":"The bash command to execute in the workspace.","type":"string"},"timeout_ms":{"default":60000,"description":"Command timeout in milliseconds. Defaults to 60000ms (60 seconds) if not specified.","minimum":1,"type":"integer"},"workspace":{"description":"The workspace name in format [owner/]workspace[.agent]. If owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","command"],"type":"object"},"strict":false},{"type":"function","description":"Edit a file in a workspace.","name":"bmcp_coder_coder_workspace_edit_file","parameters":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","edits"],"type":"object"},"strict":false},{"type":"function","description":"Edit one or more files in a workspace.","name":"bmcp_coder_coder_workspace_edit_files","parameters":{"properties":{"files":{"description":"An array of files to edit.","items":{"properties":{"edits":{"description":"An array of edit operations.","items":{"properties":{"replace":{"description":"The new string that replaces the old string.","type":"string"},"search":{"description":"The old string to replace.","type":"string"}},"required":["search","replace"],"type":"object"},"type":"array"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"}},"required":["path","edits"],"type":"object"},"type":"array"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","files"],"type":"object"},"strict":false},{"type":"function","description":"List the URLs of Coder apps running in a workspace for a single agent.","name":"bmcp_coder_coder_workspace_list_apps","parameters":{"properties":{"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace"],"type":"object"},"strict":false},{"type":"function","description":"List directories in a workspace.","name":"bmcp_coder_coder_workspace_ls","parameters":{"properties":{"path":{"description":"The absolute path of the directory in the workspace to list.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Fetch URLs that forward to the specified port.","name":"bmcp_coder_coder_workspace_port_forward","parameters":{"properties":{"port":{"description":"The port to forward.","type":"number"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["workspace","port"],"type":"object"},"strict":false},{"type":"function","description":"Read from a file in a workspace.","name":"bmcp_coder_coder_workspace_read_file","parameters":{"properties":{"limit":{"description":"The number of bytes to read. Cannot exceed 1 MiB. Defaults to the full size of the file or 1 MiB, whichever is lower.","type":"integer"},"offset":{"description":"A byte offset indicating where in the file to start reading. Defaults to zero. An empty string indicates the end of the file has been reached.","type":"integer"},"path":{"description":"The absolute path of the file to read in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace"],"type":"object"},"strict":false},{"type":"function","description":"Write a file in a workspace.\n\nIf a file write fails due to syntax errors or encoding issues, do NOT switch\nto using bash commands as a workaround. Instead:\n\n\t1. Read the error message carefully to identify the issue\n\t2. Fix the content encoding/syntax\n\t3. Retry with this tool\n\nThe content parameter expects base64-encoded bytes. Ensure your source content\nis correct before encoding it. If you encounter errors, decode and verify the\ncontent you are trying to write, then re-encode it properly.\n","name":"bmcp_coder_coder_workspace_write_file","parameters":{"properties":{"content":{"description":"The base64-encoded bytes to write to the file.","type":"string"},"path":{"description":"The absolute path of the file to write in the workspace.","type":"string"},"workspace":{"description":"The workspace name in the format [owner/]workspace[.agent]. If an owner is not specified, the authenticated user is used.","type":"string"}},"required":["path","workspace","content"],"type":"object"},"strict":false}],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":6346,"input_tokens_details":{"cached_tokens":0},"output_tokens":56,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":6402},"user":null,"metadata":{}},"sequence_number":61} + diff --git a/aibridge/fixtures/openai/responses/streaming/stream_error.txtar b/aibridge/fixtures/openai/responses/streaming/stream_error.txtar new file mode 100644 index 0000000000..9851a00234 --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/stream_error.txtar @@ -0,0 +1,20 @@ +-- request -- +{ + "input": "hello_stream_error", + "model": "gpt-6.7", + "stream": true +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_123","object":"response","status":"in_progress","error":null,"output":[]},"sequence_number":1} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_123","object":"response","status":"in_progress","error":null,"output":[]},"sequence_number":2} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","item_id":"msg_123","output_index":0,"content_index":0,"delta":"Hello","sequence_number":3} + +event: error +data: {"type":"error","code":"ERR_SOMETHING","message":"Something went wrong","param":null,"sequence_number":4} + diff --git a/aibridge/fixtures/openai/responses/streaming/stream_failure.txtar b/aibridge/fixtures/openai/responses/streaming/stream_failure.txtar new file mode 100644 index 0000000000..199d860443 --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/stream_failure.txtar @@ -0,0 +1,20 @@ +-- request -- +{ + "input": "hello_stream_failure", + "model": "gpt-6.7", + "stream": true +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_123","object":"response","status":"in_progress","error":null,"output":[]},"sequence_number":1} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_123","object":"response","status":"in_progress","error":null,"output":[]},"sequence_number":2} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","item_id":"msg_123","output_index":0,"content_index":0,"delta":"Hello","sequence_number":3} + +event: response.failed +data: {"type":"response.failed","response":{"id":"resp_123","object":"response","status":"failed","error":{"code":"server_error","message":"The model failed to generate a response."},"output":[]},"sequence_number":4} + diff --git a/aibridge/fixtures/openai/responses/streaming/summary_and_commentary_builtin_tool.txtar b/aibridge/fixtures/openai/responses/streaming/summary_and_commentary_builtin_tool.txtar new file mode 100644 index 0000000000..172b006505 --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/summary_and_commentary_builtin_tool.txtar @@ -0,0 +1,94 @@ +Both a reasoning summary and a commentary message before a function_call. + +-- request -- +{ + "input": [ + { + "role": "user", + "content": "Is 3 + 5 a prime number? Use the add function to calculate the sum." + } + ], + "model": "gpt-5.4", + "stream": true, + "tools": [ + { + "type": "function", + "name": "add", + "description": "Add two numbers together.", + "parameters": { + "type": "object", + "properties": { + "a": { + "type": "number" + }, + "b": { + "type": "number" + } + }, + "required": [ + "a", + "b" + ] + } + } + ] +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_1bba3bc54ed351c41270c26831354d920fcc75088476e53de6","object":"response","created_at":1773229900,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":"xhigh","summary":null},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"low"},"tool_choice":"auto","tools":[{"type":"function","description":"Add two numbers together.","name":"add","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_1bba3bc54ed351c41270c26831354d920fcc75088476e53de6","object":"response","created_at":1773229900,"status":"in_progress","background":false,"completed_at":null,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","output":[],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":"xhigh","summary":null},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"low"},"tool_choice":"auto","tools":[{"type":"function","description":"Add two numbers together.","name":"add","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"rs_1bba3bc54ed351c41270c26831908d920fcc75088476e53de6","type":"reasoning","status":"in_progress","summary":[]},"output_index":0,"sequence_number":2} + +event: response.reasoning_summary_part.added +data: {"type":"response.reasoning_summary_part.added","item_id":"rs_1bba3bc54ed351c41270c26831908d920fcc75088476e53de6","output_index":0,"part":{"type":"summary_text","text":""},"summary_index":0,"sequence_number":3} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","item_id":"rs_1bba3bc54ed351c41270c26831908d920fcc75088476e53de6","output_index":0,"summary_index":0,"delta":"I need to add 3 and 5 to check primality.","sequence_number":4} + +event: response.reasoning_summary_text.done +data: {"type":"response.reasoning_summary_text.done","item_id":"rs_1bba3bc54ed351c41270c26831908d920fcc75088476e53de6","output_index":0,"summary_index":0,"text":"I need to add 3 and 5 to check primality.","sequence_number":5} + +event: response.reasoning_summary_part.done +data: {"type":"response.reasoning_summary_part.done","item_id":"rs_1bba3bc54ed351c41270c26831908d920fcc75088476e53de6","output_index":0,"part":{"type":"summary_text","text":"I need to add 3 and 5 to check primality."},"summary_index":0,"sequence_number":6} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"rs_1bba3bc54ed351c41270c26831908d920fcc75088476e53de6","type":"reasoning","status":"completed","encrypted_content":"gAAAAA==","summary":[{"type":"summary_text","text":"I need to add 3 and 5 to check primality."}]},"output_index":0,"sequence_number":7} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"msg_1bba3bc54ed351c41270c26831a09d920fdd86199587f64ef7","type":"message","status":"in_progress","content":[],"phase":"commentary","role":"assistant"},"output_index":1,"sequence_number":8} + +event: response.content_part.added +data: {"type":"response.content_part.added","item_id":"msg_1bba3bc54ed351c41270c26831a09d920fdd86199587f64ef7","output_index":1,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]},"sequence_number":9} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","item_id":"msg_1bba3bc54ed351c41270c26831a09d920fdd86199587f64ef7","output_index":1,"content_index":0,"delta":"Let me calculate the sum first using the add function.","sequence_number":10} + +event: response.output_text.done +data: {"type":"response.output_text.done","item_id":"msg_1bba3bc54ed351c41270c26831a09d920fdd86199587f64ef7","output_index":1,"content_index":0,"text":"Let me calculate the sum first using the add function.","sequence_number":11} + +event: response.content_part.done +data: {"type":"response.content_part.done","item_id":"msg_1bba3bc54ed351c41270c26831a09d920fdd86199587f64ef7","output_index":1,"content_index":0,"part":{"type":"output_text","text":"Let me calculate the sum first using the add function.","annotations":[]},"sequence_number":12} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"msg_1bba3bc54ed351c41270c26831a09d920fdd86199587f64ef7","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Let me calculate the sum first using the add function."}],"phase":"commentary","role":"assistant"},"output_index":1,"sequence_number":13} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"fc_1bba3bc54ed351c41270c26831b0ad920fee97200698074f08","type":"function_call","status":"in_progress","arguments":"","call_id":"call_B9UjYX01Lvvv1XwjDsdmRW3f","name":"add"},"output_index":2,"sequence_number":14} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"{\"a\":3,\"b\":5}","item_id":"fc_1bba3bc54ed351c41270c26831b0ad920fee97200698074f08","output_index":2,"sequence_number":15} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","arguments":"{\"a\":3,\"b\":5}","item_id":"fc_1bba3bc54ed351c41270c26831b0ad920fee97200698074f08","output_index":2,"sequence_number":16} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"fc_1bba3bc54ed351c41270c26831b0ad920fee97200698074f08","type":"function_call","status":"completed","arguments":"{\"a\":3,\"b\":5}","call_id":"call_B9UjYX01Lvvv1XwjDsdmRW3f","name":"add"},"output_index":2,"sequence_number":17} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_1bba3bc54ed351c41270c26831354d920fcc75088476e53de6","object":"response","created_at":1773229900,"status":"completed","background":false,"completed_at":1773229905,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.4-2026-03-05","output":[{"id":"rs_1bba3bc54ed351c41270c26831908d920fcc75088476e53de6","type":"reasoning","status":"completed","encrypted_content":"gAAAAA==","summary":[{"type":"summary_text","text":"I need to add 3 and 5 to check primality."}]},{"id":"msg_1bba3bc54ed351c41270c26831a09d920fdd86199587f64ef7","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"text":"Let me calculate the sum first using the add function."}],"phase":"commentary","role":"assistant"},{"id":"fc_1bba3bc54ed351c41270c26831b0ad920fee97200698074f08","type":"function_call","status":"completed","arguments":"{\"a\":3,\"b\":5}","call_id":"call_B9UjYX01Lvvv1XwjDsdmRW3f","name":"add"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":"xhigh","summary":null},"safety_identifier":null,"service_tier":"default","store":false,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"low"},"tool_choice":"auto","tools":[{"type":"function","description":"Add two numbers together.","name":"add","parameters":{"type":"object","properties":{"a":{"type":"number"},"b":{"type":"number"}},"required":["a","b"],"additionalProperties":false},"strict":true}],"top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":58,"input_tokens_details":{"cached_tokens":0},"output_tokens":35,"output_tokens_details":{"reasoning_tokens":10},"total_tokens":93},"user":null,"metadata":{}},"sequence_number":18} + diff --git a/aibridge/fixtures/openai/responses/streaming/wrong_response_format.txtar b/aibridge/fixtures/openai/responses/streaming/wrong_response_format.txtar new file mode 100644 index 0000000000..19834cc8da --- /dev/null +++ b/aibridge/fixtures/openai/responses/streaming/wrong_response_format.txtar @@ -0,0 +1,21 @@ +-- request -- +{ + "input": "hello_wrong_format", + "model": "gpt-6.7", + "stream": true +} + +-- streaming -- +event: response.created +data: {"type":"response.created","response":{"id":"resp_123","object":"response","status":"in_progress","error":null,"output":[]},"sequence_number":1} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_123","object":"response","status":"in_progress","error":null,"output":[]},"sequence_number":2} + +event: response.output_text.delta +da +ta: { "wrong format": should be forwarded as received + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_123","object":"response","created_at":1767874658,"status":"completed","background":false,"completed_at":1767874660,"error":null,"incomplete_details":null,"instructions":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-4o-mini-2024-07-18","output":[{"id":"msg_0f9c4b2f224d858000695fa063d4708197af73c2f37cb0b9d3","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"Why did the scarecrow win an award?\n\nBecause he was outstanding in his field!"}],"role":"assistant"}],"parallel_tool_calls":true,"previous_response_id":null,"prompt_cache_key":null,"prompt_cache_retention":null,"reasoning":{"effort":null,"summary":null},"safety_identifier":null,"service_tier":"default","store":true,"temperature":1.0,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","tools":[],"top_logprobs":0,"top_p":1.0,"truncation":"disabled","usage":{"input_tokens":11,"input_tokens_details":{"cached_tokens":0},"output_tokens":18,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":29},"user":null,"metadata":{}},"sequence_number":24} + diff --git a/aibridge/intercept/actor_headers.go b/aibridge/intercept/actor_headers.go new file mode 100644 index 0000000000..8a94a313c7 --- /dev/null +++ b/aibridge/intercept/actor_headers.go @@ -0,0 +1,80 @@ +package intercept + +import ( + "fmt" + "strings" + + ant_option "github.com/anthropics/anthropic-sdk-go/option" + oai_option "github.com/openai/openai-go/v3/option" + + "github.com/coder/coder/v2/aibridge/context" +) + +const ( + prefix = "X-AI-Bridge-Actor" +) + +func ActorIDHeader() string { + return fmt.Sprintf("%s-ID", prefix) +} + +func ActorMetadataHeader(name string) string { + return fmt.Sprintf("%s-Metadata-%s", prefix, name) +} + +func IsActorHeader(name string) bool { + return strings.HasPrefix(strings.ToLower(name), strings.ToLower(prefix)) +} + +// ActorHeadersAsOpenAIOpts produces a slice of headers using OpenAI's RequestOption type. +func ActorHeadersAsOpenAIOpts(actor *context.Actor) []oai_option.RequestOption { + var opts []oai_option.RequestOption + + headers := headersFromActor(actor) + if len(headers) == 0 { + return nil + } + + for k, v := range headers { + // [k] will be canonicalized, see [http.Header]'s [Add] method. + opts = append(opts, oai_option.WithHeaderAdd(k, v)) + } + + return opts +} + +// ActorHeadersAsAnthropicOpts produces a slice of headers using Anthropic's RequestOption type. +func ActorHeadersAsAnthropicOpts(actor *context.Actor) []ant_option.RequestOption { + var opts []ant_option.RequestOption + + headers := headersFromActor(actor) + if len(headers) == 0 { + return nil + } + + for k, v := range headers { + // [k] will be canonicalized, see [http.Header]'s [Add] method. + opts = append(opts, ant_option.WithHeaderAdd(k, v)) + } + + return opts +} + +// headersFromActor produces a map of headers from a given [context.Actor]. +func headersFromActor(actor *context.Actor) map[string]string { + if actor == nil { + return nil + } + + headers := make(map[string]string, len(actor.Metadata)+1) + + // Add actor ID. + headers[ActorIDHeader()] = actor.ID + + // Add headers for provided metadata. + for k, v := range actor.Metadata { + headers[ActorMetadataHeader(k)] = fmt.Sprintf("%v", v) + } + + return headers +} diff --git a/aibridge/intercept/actor_headers_test.go b/aibridge/intercept/actor_headers_test.go new file mode 100644 index 0000000000..aa2b1a7771 --- /dev/null +++ b/aibridge/intercept/actor_headers_test.go @@ -0,0 +1,57 @@ +package intercept_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/recorder" +) + +func TestNilActor(t *testing.T) { + t.Parallel() + + require.Nil(t, intercept.ActorHeadersAsOpenAIOpts(nil)) + require.Nil(t, intercept.ActorHeadersAsAnthropicOpts(nil)) +} + +func TestBasic(t *testing.T) { + t.Parallel() + + actorID := uuid.NewString() + actor := &context.Actor{ + ID: actorID, + } + + // We can't peek inside since these opts require an internal type to apply onto. + // All we can do is check the length. + // See TestActorHeaders for an integration test. + oaiOpts := intercept.ActorHeadersAsOpenAIOpts(actor) + require.Len(t, oaiOpts, 1) + antOpts := intercept.ActorHeadersAsAnthropicOpts(actor) + require.Len(t, antOpts, 1) +} + +func TestBasicAndMetadata(t *testing.T) { + t.Parallel() + + actorID := uuid.NewString() + actor := &context.Actor{ + ID: actorID, + Metadata: recorder.Metadata{ + "This": "That", + "And": "The other", + }, + } + + // We can't peek inside since these opts require an internal type to apply onto. + // All we can do is check the length. + // See TestActorHeaders for an integration test. + oaiOpts := intercept.ActorHeadersAsOpenAIOpts(actor) + require.Len(t, oaiOpts, 1+len(actor.Metadata)) + antOpts := intercept.ActorHeadersAsAnthropicOpts(actor) + require.Len(t, antOpts, 1+len(actor.Metadata)) +} diff --git a/aibridge/intercept/apidump/apidump.go b/aibridge/intercept/apidump/apidump.go new file mode 100644 index 0000000000..05d1c83e48 --- /dev/null +++ b/aibridge/intercept/apidump/apidump.go @@ -0,0 +1,290 @@ +package apidump + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/google/uuid" + "github.com/tidwall/pretty" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/utils" + "github.com/coder/quartz" +) + +const ( + // SuffixRequest is the file suffix for request dump files. + SuffixRequest = ".req.txt" + // SuffixResponse is the file suffix for response dump files. + SuffixResponse = ".resp.txt" + // SuffixError is the file suffix for error dump files written when a request fails. + SuffixError = ".req_error.txt" +) + +// MiddlewareNext is the function to call the next middleware or the actual request. +type MiddlewareNext = func(*http.Request) (*http.Response, error) + +// Middleware is an HTTP middleware function compatible with SDK WithMiddleware options. +type Middleware = func(*http.Request, MiddlewareNext) (*http.Response, error) + +// NewBridgeMiddleware returns a middleware function that dumps requests and responses to files. +// If baseDir is empty, returns nil (no middleware). +func NewBridgeMiddleware(baseDir string, provider string, model string, interceptionID uuid.UUID, logger slog.Logger, clk quartz.Clock) Middleware { + if baseDir == "" { + return nil + } + + d := &dumper{ + dumpPath: interceptDumpPath(baseDir, provider, model, interceptionID, clk), + logger: logger, + } + + return func(req *http.Request, next MiddlewareNext) (*http.Response, error) { + if err := d.dumpRequest(req); err != nil { + logger.Named("apidump").Warn(req.Context(), "failed to dump request", slog.Error(err)) + } + + resp, err := next(req) + if err != nil { + if dumpErr := d.dumpError(err); dumpErr != nil { + logger.Named("apidump").Warn(req.Context(), "failed to dump request error", slog.Error(dumpErr)) + } + return resp, err + } + + if err := d.dumpResponse(resp); err != nil { + logger.Named("apidump").Warn(req.Context(), "failed to dump response", slog.Error(err)) + } + + return resp, nil + } +} + +type dumper struct { + dumpPath string + logger slog.Logger +} + +func (d *dumper) dumpRequest(req *http.Request) error { + dumpPath := d.dumpPath + SuffixRequest + if err := os.MkdirAll(filepath.Dir(dumpPath), 0o755); err != nil { + return xerrors.Errorf("create dump dir: %w", err) + } + + // Read and restore body + var bodyBytes []byte + if req.Body != nil { + var err error + bodyBytes, err = io.ReadAll(req.Body) + if err != nil { + return xerrors.Errorf("read request body: %w", err) + } + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + + prettyBody := prettyPrintJSON(bodyBytes) + + // Build raw HTTP request format + var buf bytes.Buffer + _, err := fmt.Fprintf(&buf, "%s %s %s\r\n", req.Method, req.URL.RequestURI(), req.Proto) + if err != nil { + return xerrors.Errorf("write request uri: %w", err) + } + err = d.writeRedactedHeaders(&buf, req.Header, sensitiveRequestHeaders, map[string]string{ + "Content-Length": fmt.Sprintf("%d", len(prettyBody)), + }) + if err != nil { + return xerrors.Errorf("write request headers: %w", err) + } + + _, err = fmt.Fprintf(&buf, "\r\n") + if err != nil { + return xerrors.Errorf("write request header terminator: %w", err) + } + // bytes.Buffer writes to in-memory storage and never return errors. + _, _ = buf.Write(prettyBody) + _ = buf.WriteByte('\n') + + return os.WriteFile(dumpPath, buf.Bytes(), 0o644) //nolint:gosec // https://github.com/coder/aibridge/pull/256#discussion_r3072143983 +} + +func (d *dumper) dumpError(reqErr error) error { + dumpPath := d.dumpPath + SuffixError + if err := os.MkdirAll(filepath.Dir(dumpPath), 0o755); err != nil { + return xerrors.Errorf("create dump dir: %w", err) + } + return os.WriteFile(dumpPath, []byte(reqErr.Error()+"\n"), 0o644) //nolint:gosec // same rationale as other dump files +} + +func (d *dumper) dumpResponse(resp *http.Response) error { + dumpPath := d.dumpPath + SuffixResponse + + // Build raw HTTP response headers + var headerBuf bytes.Buffer + _, err := fmt.Fprintf(&headerBuf, "%s %s\r\n", resp.Proto, resp.Status) + if err != nil { + return xerrors.Errorf("write response status: %w", err) + } + err = d.writeRedactedHeaders(&headerBuf, resp.Header, sensitiveResponseHeaders, nil) + if err != nil { + return xerrors.Errorf("write response headers: %w", err) + } + _, err = fmt.Fprintf(&headerBuf, "\r\n") + if err != nil { + return xerrors.Errorf("write response header terminator: %w", err) + } + + if resp.Body == nil { + // No body, just write headers + return os.WriteFile(dumpPath, headerBuf.Bytes(), 0o644) //nolint:gosec // https://github.com/coder/aibridge/pull/256#discussion_r3072143983 + } + + // Wrap the response body to capture it as it streams + resp.Body = &streamingBodyDumper{ + body: resp.Body, + dumpPath: dumpPath, + headerData: headerBuf.Bytes(), + logger: func(err error) { + d.logger.Named("apidump").Warn(context.Background(), "failed to initialize response dump", slog.Error(err)) + }, + } + + return nil +} + +// writeRedactedHeaders writes HTTP headers in wire format (Key: Value\r\n) to w, +// redacting sensitive values and applying any overrides. Headers are sorted by key +// for deterministic output. +// `sensitive` and `overrides` must both supply keys in canonicalized form. +// See [textproto.MIMEHeader]. +func (*dumper) writeRedactedHeaders(w io.Writer, headers http.Header, sensitive map[string]struct{}, overrides map[string]string) error { + // Collect all header keys including overrides. + headerKeys := make([]string, 0, len(headers)+len(overrides)) + seen := make(map[string]struct{}, len(headers)+len(overrides)) + for key := range headers { + headerKeys = append(headerKeys, key) + seen[key] = struct{}{} + } + // Add override keys that don't exist in headers. + for key := range overrides { + if _, ok := seen[key]; !ok { + headerKeys = append(headerKeys, key) + } + } + slices.Sort(headerKeys) + + for _, key := range headerKeys { + _, isSensitive := sensitive[key] + values := headers[key] + // If no values exist but we have an override, use that. + if len(values) == 0 { + if override, ok := overrides[key]; ok { + _, err := fmt.Fprintf(w, "%s: %s\r\n", key, override) + if err != nil { + return xerrors.Errorf("write response header override: %w", err) + } + } + continue + } + for _, value := range values { + if override, ok := overrides[key]; ok { + value = override + } + + if isSensitive { + value = utils.MaskSecret(value) + } + _, err := fmt.Fprintf(w, "%s: %s\r\n", key, value) + if err != nil { + return xerrors.Errorf("write response headers: %w", err) + } + } + } + return nil +} + +// interceptDumpPath returns the base file path (without req/resp suffix) for an interception dump. +func interceptDumpPath(baseDir string, provider string, model string, interceptionID uuid.UUID, clk quartz.Clock) string { + safeModel := strings.ReplaceAll(model, "/", "-") + return filepath.Join(baseDir, provider, safeModel, fmt.Sprintf("%d-%s", clk.Now().UTC().UnixMilli(), interceptionID)) +} + +// passthroughDumpPath returns the base file path (without req/resp suffix) for a passthrough dump. +func passthroughDumpPath(baseDir string, provider string, urlPath string, clk quartz.Clock) string { + safeURLPath := strings.ReplaceAll(strings.TrimPrefix(urlPath, "/"), "/", "-") + return filepath.Join(baseDir, provider, "passthrough", fmt.Sprintf("%d-%s-%s", clk.Now().UTC().UnixMilli(), safeURLPath, uuid.NewString()[:4])) +} + +// NewPassthroughMiddleware returns http.RoundTripper that dumps requests and responses to files. +// If baseDir is empty, returns the original transport unchanged. +// Used for logging in pass through routes. +func NewPassthroughMiddleware(transport http.RoundTripper, baseDir string, provider string, logger slog.Logger, clk quartz.Clock) http.RoundTripper { + if baseDir == "" { + return transport + } + return &dumpRoundTripper{ + inner: transport, + baseDir: baseDir, + provider: provider, + clk: clk, + logger: logger, + } +} + +type dumpRoundTripper struct { + inner http.RoundTripper + baseDir string + provider string + clk quartz.Clock + logger slog.Logger +} + +func (rt *dumpRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + dumper := dumper{ + dumpPath: passthroughDumpPath(rt.baseDir, rt.provider, req.URL.Path, rt.clk), + logger: rt.logger, + } + + if err := dumper.dumpRequest(req); err != nil { + dumper.logger.Named("apidump").Warn(req.Context(), "failed to dump passthrough request", slog.Error(err)) + } + + resp, err := rt.inner.RoundTrip(req) + if err != nil { + if dumpErr := dumper.dumpError(err); dumpErr != nil { + dumper.logger.Named("apidump").Warn(req.Context(), "failed to dump passthrough request error", slog.Error(dumpErr)) + } + return resp, err + } + + if err := dumper.dumpResponse(resp); err != nil { + dumper.logger.Named("apidump").Warn(req.Context(), "failed to dump passthrough response", slog.Error(err)) + } + + return resp, nil +} + +// prettyPrintJSON returns indented JSON if body is valid JSON, otherwise returns body as-is. +// Unlike json.MarshalIndent, this preserves the original key order from the input, +// which makes the dumps easier to read and compare with the original requests. +func prettyPrintJSON(body []byte) []byte { + if len(body) == 0 { + return body + } + + result := body + if json.Valid(body) { + result = pretty.Pretty(body) + } + + return result +} diff --git a/aibridge/intercept/apidump/apidump_test.go b/aibridge/intercept/apidump/apidump_test.go new file mode 100644 index 0000000000..f3fb30e60c --- /dev/null +++ b/aibridge/intercept/apidump/apidump_test.go @@ -0,0 +1,500 @@ +package apidump //nolint:testpackage // tests unexported internals + +import ( + "bytes" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/quartz" +) + +// findDumpFile finds a dump file matching the pattern in the given directory. +func findDumpFile(t *testing.T, dir, suffix string) string { + t.Helper() + pattern := filepath.Join(dir, "*"+suffix) + matches, err := filepath.Glob(pattern) + require.NoError(t, err) + require.Len(t, matches, 1, "expected exactly one %s file in %s", suffix, dir) + return matches[0] +} + +func TestBridgedMiddleware_RedactsSensitiveRequestHeaders(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + clk := quartz.NewMock(t) + interceptionID := uuid.New() + + middleware := NewBridgeMiddleware(tmpDir, "openai", "gpt-4", interceptionID, logger, clk) + require.NotNil(t, middleware) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://api.openai.com/v1/chat/completions", bytes.NewReader([]byte(`{"test": true}`))) + require.NoError(t, err) + + // Add sensitive headers that should be redacted + req.Header.Set("Authorization", "Bearer sk-secret-key-12345") + req.Header.Set("X-Api-Key", "secret-api-key-value") + req.Header.Set("Cookie", "session=abc123") + + // Add non-sensitive headers that should be kept as-is + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "test-client") + + // Call middleware with a mock next function + resp, err := middleware(req, func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Proto: "HTTP/1.1", + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader([]byte(`{"ok": true}`))), + }, nil + }) + require.NoError(t, err) + defer resp.Body.Close() + + // Read the request dump file + modelDir := filepath.Join(tmpDir, "openai", "gpt-4") + reqDumpPath := findDumpFile(t, modelDir, SuffixRequest) + reqContent, err := os.ReadFile(reqDumpPath) + require.NoError(t, err) + + content := string(reqContent) + + // Verify sensitive headers ARE present but redacted + require.Contains(t, content, "Authorization: Bear...2345") + require.Contains(t, content, "X-Api-Key: secr...alue") + require.Contains(t, content, "Cookie: se...23") // "session=abc123" is 14 chars, so first 2 + last 2 + + // Verify the full secret values are NOT present + require.NotContains(t, content, "sk-secret-key-12345") + require.NotContains(t, content, "secret-api-key-value") + + // Verify non-sensitive headers ARE present in full + require.Contains(t, content, "Content-Type: application/json") + require.Contains(t, content, "User-Agent: test-client") +} + +func TestBridgedMiddleware_RedactsSensitiveResponseHeaders(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + clk := quartz.NewMock(t) + interceptionID := uuid.New() + + middleware := NewBridgeMiddleware(tmpDir, "openai", "gpt-4", interceptionID, logger, clk) + require.NotNil(t, middleware) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://api.openai.com/v1/chat/completions", bytes.NewReader([]byte(`{}`))) + require.NoError(t, err) + + // Call middleware with a response containing sensitive headers + resp, err := middleware(req, func(r *http.Request) (*http.Response, error) { + resp := &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Proto: "HTTP/1.1", + Header: make(http.Header), + Body: io.NopCloser(bytes.NewReader([]byte(`{"ok": true}`))), + } + // Add sensitive response headers + resp.Header.Set("Set-Cookie", "session=secret123; HttpOnly; Secure") + resp.Header.Set("WWW-Authenticate", "Bearer realm=\"api\"") + // Add non-sensitive headers + resp.Header.Set("Content-Type", "application/json") + resp.Header.Set("X-Request-Id", "req-123") + return resp, nil + }) + require.NoError(t, err) + + // Must read and close response body to trigger the streaming dump + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + // Read the response dump file + modelDir := filepath.Join(tmpDir, "openai", "gpt-4") + respDumpPath := findDumpFile(t, modelDir, SuffixResponse) + respContent, err := os.ReadFile(respDumpPath) + require.NoError(t, err) + + content := string(respContent) + + // Verify sensitive headers are present but redacted + require.Contains(t, content, "Set-Cookie: sess...cure") + // Note: Go canonicalizes WWW-Authenticate to Www-Authenticate + // "Bearer realm=\"api\"" = 18 chars, first 2 = "Be", last 2 = "i\"" + require.Contains(t, content, "Www-Authenticate: Be...i\"") + + // Verify full secret values are NOT present + require.NotContains(t, content, "secret123") + require.NotContains(t, content, "realm=\"api\"") + + // Verify non-sensitive headers ARE present in full + require.Contains(t, content, "Content-Type: application/json") + require.Contains(t, content, "X-Request-Id: req-123") +} + +func TestBridgedMiddleware_WritesErrorFile_WhenNextFails(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + clk := quartz.NewMock(t) + interceptionID := uuid.New() + + middleware := NewBridgeMiddleware(tmpDir, "openai", "gpt-4", interceptionID, logger, clk) + require.NotNil(t, middleware) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://api.openai.com/v1/chat/completions", bytes.NewReader([]byte(`{}`))) + require.NoError(t, err) + + upstreamErr := io.ErrUnexpectedEOF + resp, err := middleware(req, func(_ *http.Request) (*http.Response, error) { //nolint:bodyclose // resp is nil on error + return nil, upstreamErr + }) + require.ErrorIs(t, err, upstreamErr) + require.Nil(t, resp) + + modelDir := filepath.Join(tmpDir, "openai", "gpt-4") + errDumpPath := findDumpFile(t, modelDir, SuffixError) + content, readErr := os.ReadFile(errDumpPath) + require.NoError(t, readErr) + require.Contains(t, string(content), upstreamErr.Error()) +} + +func TestBridgedMiddleware_EmptyBaseDir_ReturnsNil(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + middleware := NewBridgeMiddleware("", "openai", "gpt-4", uuid.New(), logger, quartz.NewMock(t)) + require.Nil(t, middleware) +} + +func TestBridgedMiddleware_PreservesRequestBody(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + clk := quartz.NewMock(t) + interceptionID := uuid.New() + + middleware := NewBridgeMiddleware(tmpDir, "openai", "gpt-4", interceptionID, logger, clk) + require.NotNil(t, middleware) + + originalBody := `{"messages": [{"role": "user", "content": "hello"}]}` + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://api.openai.com/v1/chat/completions", bytes.NewReader([]byte(originalBody))) + require.NoError(t, err) + + var capturedBody []byte + resp2, err := middleware(req, func(r *http.Request) (*http.Response, error) { + // Read the body in the next handler to verify it's still available + capturedBody, _ = io.ReadAll(r.Body) + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Proto: "HTTP/1.1", + Header: http.Header{}, + Body: io.NopCloser(bytes.NewReader([]byte(`{}`))), + }, nil + }) + require.NoError(t, err) + defer resp2.Body.Close() + + // Verify the body was preserved for the next handler + require.Equal(t, originalBody, string(capturedBody)) +} + +func TestBridgedMiddleware_ModelWithSlash(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + clk := quartz.NewMock(t) + interceptionID := uuid.New() + + // Model with slash should have it replaced with dash + middleware := NewBridgeMiddleware(tmpDir, "google", "gemini/1.5-pro", interceptionID, logger, clk) + require.NotNil(t, middleware) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://api.google.com/v1/chat", bytes.NewReader([]byte(`{}`))) + require.NoError(t, err) + + resp3, err := middleware(req, func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Proto: "HTTP/1.1", + Header: http.Header{}, + Body: io.NopCloser(bytes.NewReader([]byte(`{}`))), + }, nil + }) + require.NoError(t, err) + defer resp3.Body.Close() + + // Verify files are created with sanitized model name + modelDir := filepath.Join(tmpDir, "google", "gemini-1.5-pro") + reqDumpPath := findDumpFile(t, modelDir, SuffixRequest) + _, err = os.Stat(reqDumpPath) + require.NoError(t, err) +} + +func TestPrettyPrintJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []byte + expected string + }{ + { + name: "empty", + input: []byte{}, + expected: "", + }, + { + name: "valid JSON", + input: []byte(`{"key":"value"}`), + expected: "{\n \"key\": \"value\"\n}\n", + }, + { + name: "invalid JSON returns as-is", + input: []byte("not json"), + expected: "not json", + }, + // see: https://github.com/tidwall/pretty/blob/9090695766b652478676cc3e55bc3187056b1ff0/pretty.go#L117 + // for input starting with "t" it would change it to "true", eg. "t_rest_of_the_string_is_discarded" -> "true" + // similar for inputs startrting with "f" and "n" + { + name: "invalid JSON edge case t", + input: []byte("test"), + expected: "test", + }, + { + name: "invalid JSON edge case f", + input: []byte("f"), + expected: "f", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + result := prettyPrintJSON(tc.input) + require.Equal(t, tc.expected, string(result)) + }) + } +} + +func TestBridgedMiddleware_AllSensitiveRequestHeaders(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + clk := quartz.NewMock(t) + interceptionID := uuid.New() + + middleware := NewBridgeMiddleware(tmpDir, "openai", "gpt-4", interceptionID, logger, clk) + require.NotNil(t, middleware) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://api.openai.com/v1/chat/completions", bytes.NewReader([]byte(`{}`))) + require.NoError(t, err) + + // Set all sensitive headers + req.Header.Set("Authorization", "Bearer sk-secret-key") + req.Header.Set("X-Api-Key", "secret-api-key") + req.Header.Set("Api-Key", "another-secret") + req.Header.Set("X-Auth-Token", "auth-token-val") + req.Header.Set("Cookie", "session=abc123def") + req.Header.Set("Proxy-Authorization", "Basic proxy-creds") + req.Header.Set("X-Amz-Security-Token", "aws-security-token") + + resp4, err := middleware(req, func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Proto: "HTTP/1.1", + Header: http.Header{}, + Body: io.NopCloser(bytes.NewReader([]byte(`{}`))), + }, nil + }) + require.NoError(t, err) + defer resp4.Body.Close() + + modelDir := filepath.Join(tmpDir, "openai", "gpt-4") + reqDumpPath := findDumpFile(t, modelDir, SuffixRequest) + reqContent, err := os.ReadFile(reqDumpPath) + require.NoError(t, err) + + content := string(reqContent) + + // Verify none of the full secret values are present + require.NotContains(t, content, "sk-secret-key") + require.NotContains(t, content, "secret-api-key") + require.NotContains(t, content, "another-secret") + require.NotContains(t, content, "auth-token-val") + require.NotContains(t, content, "abc123def") + require.NotContains(t, content, "proxy-creds") + require.NotContains(t, content, "aws-security-token") + require.NotContains(t, content, "google-api-key") + + // But headers themselves are present (redacted) + require.Contains(t, content, "Authorization:") + require.Contains(t, content, "X-Api-Key:") + require.Contains(t, content, "Api-Key:") + require.Contains(t, content, "X-Auth-Token:") + require.Contains(t, content, "Cookie:") + require.Contains(t, content, "Proxy-Authorization:") + require.Contains(t, content, "X-Amz-Security-Token:") +} + +func TestPassthroughMiddleware(t *testing.T) { + t.Parallel() + + t.Run("empty_base_dir_returns_original_transport", func(t *testing.T) { + t.Parallel() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + inner := http.DefaultTransport + rt := NewPassthroughMiddleware(inner, "", "openai", logger, quartz.NewMock(t)) + require.Equal(t, inner, rt) + }) + + t.Run("returns_error_from_inner_round_trip", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + clk := quartz.NewMock(t) + + innerErr := io.ErrUnexpectedEOF + inner := &mockRoundTripper{ + roundTrip: func(_ *http.Request) (*http.Response, error) { + return nil, innerErr + }, + } + + rt := NewPassthroughMiddleware(inner, tmpDir, "openai", logger, clk) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://api.openai.com/v1/models", nil) + require.NoError(t, err) + + resp, err := rt.RoundTrip(req) //nolint:bodyclose // resp is nil on error + require.ErrorIs(t, err, innerErr) + require.Nil(t, resp) + + passthroughDir := filepath.Join(tmpDir, "openai", "passthrough") + errDumpPath := findDumpFile(t, passthroughDir, SuffixError) + content, readErr := os.ReadFile(errDumpPath) + require.NoError(t, readErr) + require.Contains(t, string(content), innerErr.Error()) + }) + + t.Run("dumps_request_and_response", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + clk := quartz.NewMock(t) + + req1Body := `first request` + req2Body := `{"request": 2}` + req2BodyPretty := "{\n \"request\": 2\n}\n" + + callCount := 0 + inner := &mockRoundTripper{ + roundTrip: func(req *http.Request) (*http.Response, error) { + // Verify body is still readable after dump + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + callCount++ + if callCount == 1 { + require.Equal(t, req1Body, string(body)) + } else { + require.Equal(t, req2Body, string(body)) + } + + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Proto: "HTTP/1.1", + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(bytes.NewReader([]byte(fmt.Sprintf(`{"call": %d}"`, callCount)))), + }, nil + }, + } + + rt := NewPassthroughMiddleware(inner, tmpDir, "openai", logger, clk) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/models", bytes.NewReader([]byte(req1Body))) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer sk-secret-key-12345") + resp, err := rt.RoundTrip(req) + require.NoError(t, err) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + // Second request should create new req/resp files + req2, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "/v1/conversations", bytes.NewReader([]byte(req2Body))) + require.NoError(t, err) + resp2, err := rt.RoundTrip(req2) + require.NoError(t, err) + _, err = io.ReadAll(resp2.Body) + require.NoError(t, err) + require.NoError(t, resp2.Body.Close()) + + // Validate request files contents + passthroughDir := filepath.Join(tmpDir, "openai", "passthrough") + req1Dump := readDumpFileContent(t, filepath.Join(passthroughDir, "*-v1-models-*"+SuffixRequest)) + req2Dump := readDumpFileContent(t, filepath.Join(passthroughDir, "*-v1-conversations-*"+SuffixRequest)) + + require.Contains(t, req1Dump, req1Body+"\n") + require.Contains(t, req2Dump, req2BodyPretty) + // Sensitive header should be redacted + require.NotContains(t, req1Dump, "sk-secret-key-12345") + require.NotContains(t, req2Dump, "sk-secret-key-12345") + require.Contains(t, req1Dump, "Authorization:") + require.NotContains(t, req2Dump, "Authorization:") + + // Validate response files contents + resp1Dump := readDumpFileContent(t, filepath.Join(passthroughDir, "*-v1-models-*"+SuffixResponse)) + resp2Dump := readDumpFileContent(t, filepath.Join(passthroughDir, "*-v1-conversations-*"+SuffixResponse)) + + require.Contains(t, resp1Dump, "200 OK") + require.Contains(t, resp1Dump, `{"call": 1}"`) + require.Contains(t, resp2Dump, "200 OK") + require.Contains(t, resp2Dump, `{"call": 2}"`) + }) +} + +type mockRoundTripper struct { + roundTrip func(*http.Request) (*http.Response, error) +} + +func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return m.roundTrip(req) +} + +// readDumpFileContent reads the content of the dump file matching the pattern. +// Expects exactly one file to match the pattern. +func readDumpFileContent(t *testing.T, pattern string) string { + t.Helper() + matches, err := filepath.Glob(pattern) + require.NoError(t, err) + require.Len(t, matches, 1, "expected exactly one match got: %v %s", len(matches), strings.Join(matches, ", "), pattern) + reqContent, readErr := os.ReadFile(matches[0]) + require.NoError(t, readErr) + return string(reqContent) +} diff --git a/aibridge/intercept/apidump/headers.go b/aibridge/intercept/apidump/headers.go new file mode 100644 index 0000000000..b6a69fa8a2 --- /dev/null +++ b/aibridge/intercept/apidump/headers.go @@ -0,0 +1,20 @@ +package apidump + +// sensitiveRequestHeaders are headers that should be redacted from request dumps. +var sensitiveRequestHeaders = map[string]struct{}{ + "Authorization": {}, + "X-Api-Key": {}, + "Api-Key": {}, + "X-Auth-Token": {}, + "Cookie": {}, + "Proxy-Authorization": {}, + "X-Amz-Security-Token": {}, +} + +// sensitiveResponseHeaders are headers that should be redacted from response dumps. +// Note: header names use Go's canonical form (http.CanonicalHeaderKey). +var sensitiveResponseHeaders = map[string]struct{}{ + "Set-Cookie": {}, + "Www-Authenticate": {}, + "Proxy-Authenticate": {}, +} diff --git a/aibridge/intercept/apidump/headers_test.go b/aibridge/intercept/apidump/headers_test.go new file mode 100644 index 0000000000..7c50b990cd --- /dev/null +++ b/aibridge/intercept/apidump/headers_test.go @@ -0,0 +1,114 @@ +package apidump //nolint:testpackage // tests unexported internals + +import ( + "bytes" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "github.com/coder/quartz" +) + +func TestSensitiveHeaderLists(t *testing.T) { + t.Parallel() + + // Verify all expected sensitive request headers are in the list + expectedRequestHeaders := []string{ + "Authorization", + "X-Api-Key", + "Api-Key", + "X-Auth-Token", + "Cookie", + "Proxy-Authorization", + "X-Amz-Security-Token", + } + for _, h := range expectedRequestHeaders { + _, ok := sensitiveRequestHeaders[h] + require.True(t, ok, "expected %q to be in sensitiveRequestHeaders", h) + } + + // Verify all expected sensitive response headers are in the list + // Note: header names use Go's canonical form (http.CanonicalHeaderKey) + expectedResponseHeaders := []string{ + "Set-Cookie", + "Www-Authenticate", + "Proxy-Authenticate", + } + for _, h := range expectedResponseHeaders { + _, ok := sensitiveResponseHeaders[h] + require.True(t, ok, "expected %q to be in sensitiveResponseHeaders", h) + } +} + +func TestWriteRedactedHeaders(t *testing.T) { + t.Parallel() + + d := &dumper{ + dumpPath: interceptDumpPath("/tmp", "test", "test", uuid.New(), quartz.NewMock(t)), + logger: slog.Make(), + } + + tests := []struct { + name string + headers http.Header + sensitive map[string]struct{} + overrides map[string]string + expected string + }{ + { + name: "empty headers", + headers: http.Header{}, + expected: "", + }, + { + name: "single header", + headers: http.Header{"Content-Type": {"application/json"}}, + expected: "Content-Type: application/json\r\n", + }, + { + name: "sorted alphabetically", + headers: http.Header{ + "Zebra": {"last"}, + "Alpha": {"first"}, + }, + expected: "Alpha: first\r\nZebra: last\r\n", + }, + { + name: "override applied", + headers: http.Header{"Content-Length": {"100"}}, + overrides: map[string]string{"Content-Length": "200"}, + expected: "Content-Length: 200\r\n", + }, + { + name: "sensitive header redacted", + headers: http.Header{"Set-Cookie": {"session=abcdefghij"}}, + sensitive: sensitiveResponseHeaders, + expected: "Set-Cookie: se...ij\r\n", + }, + { + name: "multi-value header", + headers: http.Header{ + "Accept": {"text/html", "application/json"}, + }, + expected: "Accept: text/html\r\nAccept: application/json\r\n", + }, + { + name: "override for non-existent header", + headers: http.Header{}, + overrides: map[string]string{"Host": "example.com"}, + expected: "Host: example.com\r\n", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var buf bytes.Buffer + d.writeRedactedHeaders(&buf, tc.headers, tc.sensitive, tc.overrides) + require.Equal(t, tc.expected, buf.String()) + }) + } +} diff --git a/aibridge/intercept/apidump/streaming.go b/aibridge/intercept/apidump/streaming.go new file mode 100644 index 0000000000..ef9805d86d --- /dev/null +++ b/aibridge/intercept/apidump/streaming.go @@ -0,0 +1,73 @@ +package apidump + +import ( + "io" + "os" + "path/filepath" + "sync" + + "golang.org/x/xerrors" +) + +// streamingBodyDumper wraps an io.ReadCloser and writes all data to a dump file +// as it's read, preserving streaming behavior. +type streamingBodyDumper struct { + body io.ReadCloser + dumpPath string + headerData []byte + logger func(err error) + + once sync.Once + file *os.File + initErr error +} + +func (s *streamingBodyDumper) init() { + s.once.Do(func() { + if err := os.MkdirAll(filepath.Dir(s.dumpPath), 0o755); err != nil { + s.initErr = xerrors.Errorf("create dump dir: %w", err) + return + } + f, err := os.Create(s.dumpPath) + if err != nil { + s.initErr = xerrors.Errorf("create dump file: %w", err) + return + } + s.file = f + // Write headers first. + if _, err := s.file.Write(s.headerData); err != nil { + s.initErr = xerrors.Errorf("write headers: %w", err) + _ = s.file.Close() // best-effort cleanup on header write failure + s.file = nil + } + }) +} + +func (s *streamingBodyDumper) Read(p []byte) (int, error) { + n, err := s.body.Read(p) + if n > 0 { + s.init() + if s.initErr != nil && s.logger != nil { + s.logger(s.initErr) + } + if s.file != nil { + // Write raw bytes as they stream through. + _, _ = s.file.Write(p[:n]) + } + } + return n, err +} + +func (s *streamingBodyDumper) Close() error { + // Ensure init() has completed to avoid racing with Read(). + s.init() + var closeErr error + if s.file != nil { + closeErr = s.file.Close() + } + bodyErr := s.body.Close() + if bodyErr != nil { + return bodyErr + } + return closeErr +} diff --git a/aibridge/intercept/apidump/streaming_test.go b/aibridge/intercept/apidump/streaming_test.go new file mode 100644 index 0000000000..7bdac2a96c --- /dev/null +++ b/aibridge/intercept/apidump/streaming_test.go @@ -0,0 +1,129 @@ +package apidump //nolint:testpackage // shares test helpers with apidump_test.go + +import ( + "bytes" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/quartz" +) + +func TestMiddleware_StreamingResponse(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + clk := quartz.NewMock(t) + interceptionID := uuid.New() + + middleware := NewBridgeMiddleware(tmpDir, "openai", "gpt-4", interceptionID, logger, clk) + require.NotNil(t, middleware) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://api.openai.com/v1/chat/completions", bytes.NewReader([]byte(`{}`))) + require.NoError(t, err) + + // Simulate a streaming response with multiple chunks + chunks := []string{ + "data: {\"chunk\": 1}\n\n", + "data: {\"chunk\": 2}\n\n", + "data: {\"chunk\": 3}\n\n", + "data: [DONE]\n\n", + } + + // Create a pipe to simulate streaming + pr, pw := io.Pipe() + go func() { + defer pw.Close() //nolint:revive // error handled via pipe read side + for _, chunk := range chunks { + if _, err := pw.Write([]byte(chunk)); err != nil { + return + } + } + }() + + resp, err := middleware(req, func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Proto: "HTTP/1.1", + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: pr, + }, nil + }) + require.NoError(t, err) + + // Read response in small chunks to simulate streaming consumption + var receivedData bytes.Buffer + buf := make([]byte, 16) + for { + n, err := resp.Body.Read(buf) + if n > 0 { + _, _ = receivedData.Write(buf[:n]) // bytes.Buffer.Write never fails + } + if err == io.EOF { + break + } + require.NoError(t, err) + } + require.NoError(t, resp.Body.Close()) + + // Verify we received all the data + expectedData := strings.Join(chunks, "") + require.Equal(t, expectedData, receivedData.String()) + + // Verify the dump file was created and contains all the streamed data + modelDir := filepath.Join(tmpDir, "openai", "gpt-4") + respDumpPath := findDumpFile(t, modelDir, SuffixResponse) + respContent, err := os.ReadFile(respDumpPath) + require.NoError(t, err) + + content := string(respContent) + require.Contains(t, content, "HTTP/1.1 200 OK") + require.Contains(t, content, "Content-Type: text/event-stream") + // All chunks should be in the dump + for _, chunk := range chunks { + require.Contains(t, content, chunk) + } +} + +func TestMiddleware_PreservesResponseBody(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + clk := quartz.NewMock(t) + interceptionID := uuid.New() + + middleware := NewBridgeMiddleware(tmpDir, "openai", "gpt-4", interceptionID, logger, clk) + require.NotNil(t, middleware) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "https://api.openai.com/v1/chat/completions", bytes.NewReader([]byte(`{}`))) + require.NoError(t, err) + + originalRespBody := `{"choices": [{"message": {"content": "hi"}}]}` + resp, err := middleware(req, func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Status: "200 OK", + Proto: "HTTP/1.1", + Header: http.Header{}, + Body: io.NopCloser(bytes.NewReader([]byte(originalRespBody))), + }, nil + }) + require.NoError(t, err) + defer resp.Body.Close() + + // Verify the response body is still readable after middleware + capturedBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, originalRespBody, string(capturedBody)) +} diff --git a/aibridge/intercept/chatcompletions/base.go b/aibridge/intercept/chatcompletions/base.go new file mode 100644 index 0000000000..6f6ca35261 --- /dev/null +++ b/aibridge/intercept/chatcompletions/base.go @@ -0,0 +1,272 @@ +package chatcompletions + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + "github.com/openai/openai-go/v3/shared" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/intercept/apidump" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/recorder" + "github.com/coder/coder/v2/aibridge/tracing" + "github.com/coder/quartz" +) + +type interceptionBase struct { + id uuid.UUID + providerName string + req *ChatCompletionNewParamsWrapper + cfg config.OpenAI + + // clientHeaders are the original HTTP headers from the client request. + clientHeaders http.Header + authHeaderName string + + logger slog.Logger + tracer trace.Tracer + + recorder recorder.Recorder + mcpProxy mcp.ServerProxier + credential intercept.CredentialInfo +} + +func (i *interceptionBase) newCompletionsService() openai.ChatCompletionService { + opts := []option.RequestOption{option.WithAPIKey(i.cfg.Key), option.WithBaseURL(i.cfg.BaseURL)} + if i.cfg.MaxRetries != nil { + opts = append(opts, option.WithMaxRetries(*i.cfg.MaxRetries)) + } + + // Add extra headers if configured. + // Some providers require additional headers that are not added by the SDK. + // TODO(ssncferreira): remove as part of https://github.com/coder/aibridge/issues/192 + for key, value := range i.cfg.ExtraHeaders { + opts = append(opts, option.WithHeader(key, value)) + } + + // Forward client headers to upstream. This middleware runs after the SDK + // has built the request, and replaces the outgoing headers with the sanitized + // client headers plus provider auth. + if i.clientHeaders != nil { + opts = append(opts, option.WithMiddleware(func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + req.Header = intercept.BuildUpstreamHeaders(req.Header, i.clientHeaders, i.authHeaderName) + return next(req) + })) + } + + // Add API dump middleware if configured + if mw := apidump.NewBridgeMiddleware(i.cfg.APIDumpDir, i.providerName, i.Model(), i.id, i.logger, quartz.NewReal()); mw != nil { + opts = append(opts, option.WithMiddleware(mw)) + } + + return openai.NewChatCompletionService(opts...) +} + +func (i *interceptionBase) ID() uuid.UUID { + return i.id +} + +func (i *interceptionBase) Credential() intercept.CredentialInfo { + return i.credential +} + +func (i *interceptionBase) Setup(logger slog.Logger, rec recorder.Recorder, mcpProxy mcp.ServerProxier) { + i.logger = logger + i.recorder = rec + i.mcpProxy = mcpProxy +} + +func (i *interceptionBase) CorrelatingToolCallID() *string { + if len(i.req.Messages) == 0 { + return nil + } + + // The tool result should be the last input message. + msg := i.req.Messages[len(i.req.Messages)-1] + if msg.OfTool == nil { + return nil + } + return &msg.OfTool.ToolCallID +} + +func (i *interceptionBase) baseTraceAttributes(r *http.Request, streaming bool) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String(tracing.RequestPath, r.URL.Path), + attribute.String(tracing.InterceptionID, i.id.String()), + attribute.String(tracing.InitiatorID, aibcontext.ActorIDFromContext(r.Context())), + attribute.String(tracing.Provider, i.providerName), + attribute.String(tracing.Model, i.Model()), + attribute.Bool(tracing.Streaming, streaming), + } +} + +func (i *interceptionBase) Model() string { + if i.req == nil { + return "coder-aibridge-unknown" + } + + return i.req.Model +} + +func (*interceptionBase) newErrorResponse(err error) map[string]any { + return map[string]any{ + "error": true, + "message": err.Error(), + } +} + +func (i *interceptionBase) injectTools() { + if i.req == nil || i.mcpProxy == nil || !i.hasInjectableTools() { + return + } + + // Disable parallel tool calls when injectable tools are present to simplify the inner agentic loop. + i.req.ParallelToolCalls = openai.Bool(false) + + // Inject tools. + for _, tool := range i.mcpProxy.ListTools() { + fn := openai.ChatCompletionToolUnionParam{ + OfFunction: &openai.ChatCompletionFunctionToolParam{ + Function: openai.FunctionDefinitionParam{ + Name: tool.ID, + Strict: openai.Bool(false), // TODO: configurable. + Description: openai.String(tool.Description), + Parameters: openai.FunctionParameters{ + "type": "object", + "properties": tool.Params, + // "additionalProperties": false, // Only relevant when strict=true. + }, + }, + }, + } + + // Otherwise the request fails with "None is not of type 'array'" if a nil slice is given. + if len(tool.Required) > 0 { + // Must list ALL properties when strict=true. + fn.OfFunction.Function.Parameters["required"] = tool.Required + } + + i.req.Tools = append(i.req.Tools, fn) + } +} + +func (i *interceptionBase) unmarshalArgs(in string) (args recorder.ToolArgs) { + if len(strings.TrimSpace(in)) == 0 { + return args // An empty string will fail JSON unmarshaling. + } + + if err := json.Unmarshal([]byte(in), &args); err != nil { + i.logger.Warn(context.Background(), "failed to unmarshal tool args", slog.Error(err)) + } + + return args +} + +// writeUpstreamError marshals and writes a given error. +func (i *interceptionBase) writeUpstreamError(w http.ResponseWriter, oaiErr *responseError) { + if oaiErr == nil { + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(oaiErr.StatusCode) + + out, err := json.Marshal(oaiErr) + if err != nil { + i.logger.Warn(context.Background(), "failed to marshal upstream error", slog.Error(err), slog.F("error_payload", fmt.Sprintf("%+v", oaiErr))) + // Response has to match expected format. + _, _ = w.Write([]byte(`{ + "error": { + "type": "error", + "message":"error marshaling upstream error", + "code": "server_error" + }, +}`)) + } else { + _, _ = w.Write(out) + } +} + +func (i *interceptionBase) hasInjectableTools() bool { + return i.mcpProxy != nil && len(i.mcpProxy.ListTools()) > 0 +} + +func sumUsage(ref, in openai.CompletionUsage) openai.CompletionUsage { + return openai.CompletionUsage{ + CompletionTokens: ref.CompletionTokens + in.CompletionTokens, + PromptTokens: ref.PromptTokens + in.PromptTokens, + TotalTokens: ref.TotalTokens + in.TotalTokens, + CompletionTokensDetails: openai.CompletionUsageCompletionTokensDetails{ + AcceptedPredictionTokens: ref.CompletionTokensDetails.AcceptedPredictionTokens + in.CompletionTokensDetails.AcceptedPredictionTokens, + AudioTokens: ref.CompletionTokensDetails.AudioTokens + in.CompletionTokensDetails.AudioTokens, + ReasoningTokens: ref.CompletionTokensDetails.ReasoningTokens + in.CompletionTokensDetails.ReasoningTokens, + RejectedPredictionTokens: ref.CompletionTokensDetails.RejectedPredictionTokens + in.CompletionTokensDetails.RejectedPredictionTokens, + }, + PromptTokensDetails: openai.CompletionUsagePromptTokensDetails{ + AudioTokens: ref.PromptTokensDetails.AudioTokens + in.PromptTokensDetails.AudioTokens, + CachedTokens: ref.PromptTokensDetails.CachedTokens + in.PromptTokensDetails.CachedTokens, + }, + } +} + +// calculateActualInputTokenUsage accounts for cached tokens which are included in [openai.CompletionUsage].PromptTokens. +func calculateActualInputTokenUsage(in openai.CompletionUsage) int64 { + // Input *includes* the cached tokens, so we subtract them here to reflect actual input token usage. + // The original value can be reconstructed by adding CachedTokens back to Input. + // See https://platform.openai.com/docs/api-reference/usage/completions_object#usage/completions_object-input_tokens. + return in.PromptTokens /* The aggregated number of text input tokens used, including cached tokens. */ - + in.PromptTokensDetails.CachedTokens /* The aggregated number of text input tokens that has been cached from previous requests. */ +} + +func getErrorResponse(err error) *responseError { + var apiErr *openai.Error + if !errors.As(err, &apiErr) { + return nil + } + + return &responseError{ + ErrorObject: &shared.ErrorObject{ + Code: apiErr.Code, + Message: apiErr.Message, + Type: apiErr.Type, + }, + StatusCode: apiErr.StatusCode, + } +} + +var _ error = &responseError{} + +type responseError struct { + ErrorObject *shared.ErrorObject `json:"error"` + StatusCode int `json:"-"` +} + +func newErrorResponse(msg error) *responseError { + return &responseError{ + ErrorObject: &shared.ErrorObject{ + Code: "error", + Message: msg.Error(), + Type: "error", + }, + } +} + +func (a *responseError) Error() string { + if a.ErrorObject == nil { + return "" + } + return a.ErrorObject.Message +} diff --git a/aibridge/intercept/chatcompletions/base_test.go b/aibridge/intercept/chatcompletions/base_test.go new file mode 100644 index 0000000000..67104b9085 --- /dev/null +++ b/aibridge/intercept/chatcompletions/base_test.go @@ -0,0 +1,77 @@ +package chatcompletions //nolint:testpackage // tests unexported internals + +import ( + "testing" + + "github.com/openai/openai-go/v3" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/aibridge/utils" +) + +func TestScanForCorrelatingToolCallID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + messages []openai.ChatCompletionMessageParamUnion + expected *string + }{ + { + name: "no messages", + messages: nil, + expected: nil, + }, + { + name: "no tool messages", + messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage("hello"), + openai.AssistantMessage("hi there"), + }, + expected: nil, + }, + { + name: "single tool message", + messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage("hello"), + openai.ToolMessage("result", "call_abc"), + }, + expected: utils.PtrTo("call_abc"), + }, + { + name: "multiple tool messages returns last", + messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage("hello"), + openai.ToolMessage("first result", "call_first"), + openai.AssistantMessage("thinking"), + openai.ToolMessage("second result", "call_second"), + }, + expected: utils.PtrTo("call_second"), + }, + { + name: "last message is not a tool message", + messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage("hello"), + openai.ToolMessage("first result", "call_first"), + openai.AssistantMessage("thinking"), + }, + expected: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + base := &interceptionBase{ + req: &ChatCompletionNewParamsWrapper{ + ChatCompletionNewParams: openai.ChatCompletionNewParams{ + Messages: tc.messages, + }, + }, + } + + require.Equal(t, tc.expected, base.CorrelatingToolCallID()) + }) + } +} diff --git a/aibridge/intercept/chatcompletions/blocking.go b/aibridge/intercept/chatcompletions/blocking.go new file mode 100644 index 0000000000..59c8bbb731 --- /dev/null +++ b/aibridge/intercept/chatcompletions/blocking.go @@ -0,0 +1,266 @@ +package chatcompletions + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/google/uuid" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/intercept/eventstream" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/recorder" + "github.com/coder/coder/v2/aibridge/tracing" +) + +type BlockingInterception struct { + interceptionBase +} + +func NewBlockingInterceptor( + id uuid.UUID, + req *ChatCompletionNewParamsWrapper, + providerName string, + cfg config.OpenAI, + clientHeaders http.Header, + authHeaderName string, + tracer trace.Tracer, + cred intercept.CredentialInfo, +) *BlockingInterception { + return &BlockingInterception{interceptionBase: interceptionBase{ + id: id, + providerName: providerName, + req: req, + cfg: cfg, + clientHeaders: clientHeaders, + authHeaderName: authHeaderName, + tracer: tracer, + credential: cred, + }} +} + +func (i *BlockingInterception) Setup(logger slog.Logger, rec recorder.Recorder, mcpProxy mcp.ServerProxier) { + i.interceptionBase.Setup(logger.Named("blocking"), rec, mcpProxy) +} + +func (*BlockingInterception) Streaming() bool { + return false +} + +func (i *BlockingInterception) TraceAttributes(r *http.Request) []attribute.KeyValue { + return i.interceptionBase.baseTraceAttributes(r, false) +} + +func (i *BlockingInterception) ProcessRequest(w http.ResponseWriter, r *http.Request) (outErr error) { + if i.req == nil { + return xerrors.New("developer error: req is nil") + } + + ctx, span := i.tracer.Start(r.Context(), "Intercept.ProcessRequest", trace.WithAttributes(tracing.InterceptionAttributesFromContext(r.Context())...)) + defer tracing.EndSpanErr(span, &outErr) + + svc := i.newCompletionsService() + logger := i.logger.With(slog.F("model", i.req.Model)) + + var ( + cumulativeUsage openai.CompletionUsage + completion *openai.ChatCompletion + err error + ) + + i.injectTools() + + prompt, err := i.req.lastUserPrompt() + if err != nil { + logger.Warn(ctx, "failed to retrieve last user prompt", slog.Error(err)) + } + + for { + // TODO add outer loop span (https://github.com/coder/aibridge/issues/67) + + var opts []option.RequestOption + opts = append(opts, option.WithRequestTimeout(time.Second*600)) + + // TODO(ssncferreira): inject actor headers directly in the client-header + // middleware instead of using SDK options. + if actor := aibcontext.ActorFromContext(r.Context()); actor != nil && i.cfg.SendActorHeaders { + opts = append(opts, intercept.ActorHeadersAsOpenAIOpts(actor)...) + } + + completion, err = i.newChatCompletion(ctx, svc, opts) + if err != nil { + break + } + + if prompt != nil { + _ = i.recorder.RecordPromptUsage(ctx, &recorder.PromptUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: completion.ID, + Prompt: *prompt, + }) + prompt = nil + } + + lastUsage := completion.Usage + cumulativeUsage = sumUsage(cumulativeUsage, completion.Usage) + + _ = i.recorder.RecordTokenUsage(ctx, &recorder.TokenUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: completion.ID, + Input: calculateActualInputTokenUsage(lastUsage), + Output: lastUsage.CompletionTokens, + CacheReadInputTokens: lastUsage.PromptTokensDetails.CachedTokens, + ExtraTokenTypes: map[string]int64{ + "prompt_audio": lastUsage.PromptTokensDetails.AudioTokens, + "prompt_cached": lastUsage.PromptTokensDetails.CachedTokens, // TODO: remove from ExtraTokenTypes (https://github.com/coder/aibridge/issues/243) + "completion_accepted_prediction": lastUsage.CompletionTokensDetails.AcceptedPredictionTokens, + "completion_rejected_prediction": lastUsage.CompletionTokensDetails.RejectedPredictionTokens, + "completion_audio": lastUsage.CompletionTokensDetails.AudioTokens, + "completion_reasoning": lastUsage.CompletionTokensDetails.ReasoningTokens, + }, + }) + + // Check if we have tool calls to process. + var pendingToolCalls []openai.ChatCompletionMessageToolCallUnion + if len(completion.Choices) > 0 && completion.Choices[0].Message.ToolCalls != nil { + for _, toolCall := range completion.Choices[0].Message.ToolCalls { + if i.mcpProxy != nil && i.mcpProxy.GetTool(toolCall.Function.Name) != nil { + pendingToolCalls = append(pendingToolCalls, toolCall) + } else { + _ = i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: completion.ID, + ToolCallID: toolCall.ID, + Tool: toolCall.Function.Name, + Args: i.unmarshalArgs(toolCall.Function.Arguments), + Injected: false, + }) + } + } + } + + // If no injected tool calls, we're done. + if len(pendingToolCalls) == 0 { + break + } + + appendedPrevMsg := false + for _, tc := range pendingToolCalls { + if i.mcpProxy == nil { + continue + } + + tool := i.mcpProxy.GetTool(tc.Function.Name) + if tool == nil { + // Not a known tool, don't do anything. + logger.Warn(ctx, "pending tool call for non-managed tool, skipping", slog.F("tool", tc.Function.Name)) + continue + } + // Only do this once. + if !appendedPrevMsg { + // Append the whole message from this stream as context since we'll be sending a new request with the tool results. + i.req.Messages = append(i.req.Messages, completion.Choices[0].Message.ToParam()) + appendedPrevMsg = true + } + + args := i.unmarshalArgs(tc.Function.Arguments) + res, err := tool.Call(ctx, args, i.tracer) + _ = i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: completion.ID, + ToolCallID: tc.ID, + ServerURL: &tool.ServerURL, + Tool: tool.Name, + Args: args, + Injected: true, + InvocationError: err, + }) + + if err != nil { + // Always provide a tool result even if the tool call failed + errorResponse := map[string]interface{}{ + // TODO: interception ID? + "error": true, + "message": err.Error(), + } + errorJSON, _ := json.Marshal(errorResponse) + i.req.Messages = append(i.req.Messages, openai.ToolMessage(string(errorJSON), tc.ID)) + continue + } + + var out strings.Builder + if err := json.NewEncoder(&out).Encode(res); err != nil { + logger.Warn(ctx, "failed to encode tool response", slog.Error(err)) + // Always provide a tool result even if encoding failed + errorResponse := map[string]interface{}{ + // TODO: interception ID? + "error": true, + "message": err.Error(), + } + errorJSON, _ := json.Marshal(errorResponse) + i.req.Messages = append(i.req.Messages, openai.ToolMessage(string(errorJSON), tc.ID)) + continue + } + + i.req.Messages = append(i.req.Messages, openai.ToolMessage(out.String(), tc.ID)) + } + } + + if err != nil { + if eventstream.IsConnError(err) { + http.Error(w, err.Error(), http.StatusInternalServerError) + return xerrors.Errorf("upstream connection closed: %w", err) + } + + if apiErr := getErrorResponse(err); apiErr != nil { + i.writeUpstreamError(w, apiErr) + return xerrors.Errorf("openai API error: %w", err) + } + + http.Error(w, err.Error(), http.StatusInternalServerError) + return xerrors.Errorf("chat completion failed: %w", err) + } + + if completion == nil { + return nil + } + + // Overwrite response identifier since proxy obscures injected tool call invocations. + completion.ID = i.ID().String() + + // Update the cumulative usage in the final response. + if completion.Usage.CompletionTokens > 0 { + completion.Usage = cumulativeUsage + } + + w.Header().Set("Content-Type", "application/json") + out, err := json.Marshal(completion) + if err != nil { + out, _ = json.Marshal(i.newErrorResponse(xerrors.Errorf("failed to marshal response: %w", err))) + w.WriteHeader(http.StatusInternalServerError) + } else { + w.WriteHeader(http.StatusOK) + } + + _, _ = w.Write(out) + + return nil +} + +func (i *BlockingInterception) newChatCompletion(ctx context.Context, svc openai.ChatCompletionService, opts []option.RequestOption) (_ *openai.ChatCompletion, outErr error) { + ctx, span := i.tracer.Start(ctx, "Intercept.ProcessRequest.Upstream", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...)) + defer tracing.EndSpanErr(span, &outErr) + + return svc.New(ctx, i.req.ChatCompletionNewParams, opts...) +} diff --git a/aibridge/intercept/chatcompletions/paramswrap.go b/aibridge/intercept/chatcompletions/paramswrap.go new file mode 100644 index 0000000000..8b9efbbf4f --- /dev/null +++ b/aibridge/intercept/chatcompletions/paramswrap.go @@ -0,0 +1,73 @@ +package chatcompletions + +import ( + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/packages/param" + "github.com/tidwall/gjson" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/aibridge/utils" +) + +// ChatCompletionNewParamsWrapper exists because the "stream" param is not included in openai.ChatCompletionNewParams. +type ChatCompletionNewParamsWrapper struct { + openai.ChatCompletionNewParams `json:""` + Stream bool `json:"stream,omitempty"` +} + +func (c ChatCompletionNewParamsWrapper) MarshalJSON() ([]byte, error) { + type shadow ChatCompletionNewParamsWrapper + return param.MarshalWithExtras(c, (*shadow)(&c), map[string]any{ + "stream": c.Stream, + }) +} + +func (c *ChatCompletionNewParamsWrapper) UnmarshalJSON(raw []byte) error { + err := c.ChatCompletionNewParams.UnmarshalJSON(raw) + if err != nil { + return err + } + + c.Stream = gjson.GetBytes(raw, "stream").Bool() + if c.Stream { + c.ChatCompletionNewParams.StreamOptions = openai.ChatCompletionStreamOptionsParam{ + IncludeUsage: openai.Bool(true), // Always include usage when streaming. + } + } else { + c.ChatCompletionNewParams.StreamOptions = openai.ChatCompletionStreamOptionsParam{} + } + + return nil +} + +func (c *ChatCompletionNewParamsWrapper) lastUserPrompt() (*string, error) { + if c == nil { + return nil, xerrors.New("nil struct") + } + + if len(c.Messages) == 0 { + return nil, xerrors.New("no messages") + } + + // We only care if the last message was issued by a user. + msg := c.Messages[len(c.Messages)-1] + if msg.OfUser == nil { + return nil, nil //nolint:nilnil // no user prompt found is not an error + } + + if msg.OfUser.Content.OfString.String() != "" { + return utils.PtrTo(msg.OfUser.Content.OfString.String()), nil + } + + // Walk backwards on "user"-initiated message content. Clients often inject + // content ahead of the actual prompt to provide context to the model, + // so the last item in the slice is most likely the user's prompt. + for i := len(msg.OfUser.Content.OfArrayOfContentParts) - 1; i >= 0; i-- { + // Only text content is supported currently. + if textContent := msg.OfUser.Content.OfArrayOfContentParts[i].OfText; textContent != nil { + return &textContent.Text, nil + } + } + + return nil, nil //nolint:nilnil // no text content found is not an error +} diff --git a/aibridge/intercept/chatcompletions/paramswrap_test.go b/aibridge/intercept/chatcompletions/paramswrap_test.go new file mode 100644 index 0000000000..1e7c61f3b8 --- /dev/null +++ b/aibridge/intercept/chatcompletions/paramswrap_test.go @@ -0,0 +1,174 @@ +package chatcompletions //nolint:testpackage // tests unexported internals + +import ( + "fmt" + "strings" + "testing" + + "github.com/openai/openai-go/v3" + "github.com/stretchr/testify/require" +) + +func TestOpenAILastUserPrompt(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + wrapper *ChatCompletionNewParamsWrapper + expected string + expectError bool + errorMsg string + }{ + { + name: "nil struct", + expectError: true, + errorMsg: "nil struct", + }, + { + name: "no messages", + wrapper: &ChatCompletionNewParamsWrapper{ + ChatCompletionNewParams: openai.ChatCompletionNewParams{ + Messages: []openai.ChatCompletionMessageParamUnion{}, + }, + }, + expectError: true, + errorMsg: "no messages", + }, + { + name: "last message not from user", + wrapper: &ChatCompletionNewParamsWrapper{ + ChatCompletionNewParams: openai.ChatCompletionNewParams{ + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage("user message"), + openai.AssistantMessage("assistant message"), + }, + }, + }, + }, + { + name: "user message with string content", + wrapper: &ChatCompletionNewParamsWrapper{ + ChatCompletionNewParams: openai.ChatCompletionNewParams{ + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage("Hello, world!"), + }, + }, + }, + expected: "Hello, world!", + }, + { + name: "user message with empty string", + wrapper: &ChatCompletionNewParamsWrapper{ + ChatCompletionNewParams: openai.ChatCompletionNewParams{ + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage(""), + }, + }, + }, + }, + { + name: "user message with array content - text at end", + wrapper: &ChatCompletionNewParamsWrapper{ + ChatCompletionNewParams: openai.ChatCompletionNewParams{ + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage([]openai.ChatCompletionContentPartUnionParam{ + openai.ImageContentPart(openai.ChatCompletionContentPartImageImageURLParam{ + URL: "https://example.com/image.png", + }), + openai.TextContentPart("First text"), + openai.ImageContentPart(openai.ChatCompletionContentPartImageImageURLParam{ + URL: "https://example.com/image2.png", + }), + openai.TextContentPart("Last text"), + }), + }, + }, + }, + expected: "Last text", + }, + { + name: "user message with array content - no text", + wrapper: &ChatCompletionNewParamsWrapper{ + ChatCompletionNewParams: openai.ChatCompletionNewParams{ + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage([]openai.ChatCompletionContentPartUnionParam{ + openai.ImageContentPart(openai.ChatCompletionContentPartImageImageURLParam{ + URL: "https://example.com/image.png", + }), + }), + }, + }, + }, + }, + { + name: "user message with empty array", + wrapper: &ChatCompletionNewParamsWrapper{ + ChatCompletionNewParams: openai.ChatCompletionNewParams{ + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage([]openai.ChatCompletionContentPartUnionParam{}), + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := tt.wrapper.lastUserPrompt() + + if tt.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + require.Nil(t, result) + } else { + require.NoError(t, err) + if tt.expected == "" { + require.Nil(t, result) + } else { + require.NotNil(t, result) + require.Equal(t, tt.expected, *result) + } + } + }) + } +} + +// generatePayload creates a JSON payload with the specified number of messages. +// Messages alternate between user and assistant roles to simulate a conversation. +func generatePayload(messageCount int) []byte { + var messages []string + for i := range messageCount { + role := "user" + if i%2 == 1 { + role = "assistant" + } + // Use realistic message content size + content := fmt.Sprintf("This is message number %d with some realistic content that might appear in a conversation.", i+1) + messages = append(messages, fmt.Sprintf(`{"role": %q, "content": %q}`, role, content)) + } + + return []byte(fmt.Sprintf(`{ + "model": "gpt-4", + "stream": true, + "messages": [%s] + }`, strings.Join(messages, ","))) +} + +func BenchmarkChatCompletionNewParamsWrapper_UnmarshalJSON(b *testing.B) { + messageCounts := []int{1, 10, 20, 50} + + for _, count := range messageCounts { + payload := generatePayload(count) + + b.Run(fmt.Sprintf("messages=%d", count), func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for range b.N { + var wrapper ChatCompletionNewParamsWrapper + _ = wrapper.UnmarshalJSON(payload) + } + }) + } +} diff --git a/aibridge/intercept/chatcompletions/streaming.go b/aibridge/intercept/chatcompletions/streaming.go new file mode 100644 index 0000000000..8dac47dddf --- /dev/null +++ b/aibridge/intercept/chatcompletions/streaming.go @@ -0,0 +1,549 @@ +package chatcompletions + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "slices" + "strings" + "time" + + "github.com/google/uuid" + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/option" + "github.com/openai/openai-go/v3/packages/ssestream" + "github.com/tidwall/sjson" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/intercept/eventstream" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/recorder" + "github.com/coder/coder/v2/aibridge/tracing" + "github.com/coder/quartz" +) + +type StreamingInterception struct { + interceptionBase +} + +func NewStreamingInterceptor( + id uuid.UUID, + req *ChatCompletionNewParamsWrapper, + providerName string, + cfg config.OpenAI, + clientHeaders http.Header, + authHeaderName string, + tracer trace.Tracer, + cred intercept.CredentialInfo, +) *StreamingInterception { + return &StreamingInterception{interceptionBase: interceptionBase{ + id: id, + providerName: providerName, + req: req, + cfg: cfg, + clientHeaders: clientHeaders, + authHeaderName: authHeaderName, + tracer: tracer, + credential: cred, + }} +} + +func (i *StreamingInterception) Setup(logger slog.Logger, rec recorder.Recorder, mcpProxy mcp.ServerProxier) { + i.interceptionBase.Setup(logger.Named("streaming"), rec, mcpProxy) +} + +func (*StreamingInterception) Streaming() bool { + return true +} + +func (i *StreamingInterception) TraceAttributes(r *http.Request) []attribute.KeyValue { + return i.interceptionBase.baseTraceAttributes(r, true) +} + +// ProcessRequest handles a request to /v1/chat/completions. +// See https://platform.openai.com/docs/api-reference/chat-streaming/streaming. +// +// It will inject any tools which have been provided by the [mcp.ServerProxier]. +// +// When a response from the server includes an event indicating that a tool must be invoked, a conditional +// flow takes place: +// +// a) if the tool is not injected (i.e. defined by the client), relay the event unmodified +// b) if the tool is injected, it will be invoked by the [mcp.ServerProxier] in the remote MCP server, and its +// results relayed to the SERVER. The response from the server will be handled synchronously, and this loop +// can continue until all injected tool invocations are completed and the response is relayed to the client. +func (i *StreamingInterception) ProcessRequest(w http.ResponseWriter, r *http.Request) (outErr error) { + if i.req == nil { + return xerrors.New("developer error: req is nil") + } + + ctx, span := i.tracer.Start(r.Context(), "Intercept.ProcessRequest", trace.WithAttributes(tracing.InterceptionAttributesFromContext(r.Context())...)) + defer tracing.EndSpanErr(span, &outErr) + + // Include token usage. + i.req.StreamOptions.IncludeUsage = openai.Bool(true) + + i.injectTools() + + // Allow us to interrupt watch via cancel. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + r = r.WithContext(ctx) // Rewire context for SSE cancellation. + + svc := i.newCompletionsService() + logger := i.logger.With(slog.F("model", i.req.Model)) + + streamCtx, streamCancel := context.WithCancelCause(ctx) + defer streamCancel(xerrors.New("deferred")) + + // events will either terminate when shutdown after interaction with upstream completes, or when streamCtx is done. + events := eventstream.NewEventStream(streamCtx, logger.Named("sse-sender"), nil, quartz.NewReal()) + go events.Start(w, r) + defer func() { + _ = events.Shutdown(streamCtx) // Catch-all in case it doesn't get shutdown after stream completes. + }() + + // Force responses to only have one choice. + // It's unnecessary to generate multiple responses, and would complicate our stream processing logic if + // multiple choices were returned. + i.req.N = openai.Int(1) + + prompt, err := i.req.lastUserPrompt() + if err != nil { + logger.Warn(ctx, "failed to retrieve last user prompt", slog.Error(err)) + } + + var ( + stream *ssestream.Stream[openai.ChatCompletionChunk] + lastErr error + interceptionErr error + ) + for { + // TODO add outer loop span (https://github.com/coder/aibridge/issues/67) + var opts []option.RequestOption + + // TODO(ssncferreira): inject actor headers directly in the client-header + // middleware instead of using SDK options. + if actor := aibcontext.ActorFromContext(r.Context()); actor != nil && i.cfg.SendActorHeaders { + opts = append(opts, intercept.ActorHeadersAsOpenAIOpts(actor)...) + } + + // We take control of request body here and pass it to the SDK as a raw byte slice. + // This is because the SDK's serialization applies hidden request options that result in + // unexpected, breaking behavior. See https://github.com/coder/aibridge/pull/164 + body, err := json.Marshal(i.req.ChatCompletionNewParams) + if err != nil { + return xerrors.Errorf("marshal request body: %w", err) + } + opts = append(opts, option.WithRequestBody("application/json", body)) + opts = append(opts, option.WithJSONSet("stream", true)) + + stream = i.newStream(streamCtx, svc, opts) + processor := newStreamProcessor(streamCtx, i.logger.Named("stream-processor"), i.getInjectedToolByName) + + var toolCall *openai.FinishedChatCompletionToolCall + + for stream.Next() { + chunk := stream.Current() + + canRelay := processor.process(chunk) + if toolCall == nil { + toolCall = processor.getToolCall() + } + + if !canRelay { + // The chunk must not be sent to the client because it contains an injected tool call. + continue + } + + // Marshal and relay chunk to client. + payload, err := i.marshalChunk(&chunk, i.ID(), processor) + if err != nil { + logger.Warn(ctx, "failed to marshal chunk", slog.Error(err), slog.F("chunk", chunk.RawJSON())) + lastErr = xerrors.Errorf("marshal chunk: %w", err) + break + } + if err := events.Send(ctx, payload); err != nil { + logger.Warn(ctx, "failed to relay chunk", slog.Error(err)) + lastErr = xerrors.Errorf("relay chunk: %w", err) + break + } + } + + if toolCall != nil { + // Builtin tools are not intercepted. + if i.getInjectedToolByName(toolCall.Name) == nil { + _ = i.recorder.RecordToolUsage(streamCtx, &recorder.ToolUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: processor.getMsgID(), + ToolCallID: toolCall.ID, + Tool: toolCall.Name, + Args: i.unmarshalArgs(toolCall.Arguments), + Injected: false, + }) + + toolCall = nil + } else if stream.Err() == nil { + // When the provider responds with only tool calls (no text content), + // no chunks are relayed to the client, so the stream is not yet + // initiated. Initiate it here so the SSE headers are sent and the + // ping ticker is started, preventing client timeout during tool invocation. + // Only initiate if no stream error, if there's an error, we'll return + // an HTTP error response instead of starting an SSE stream. + events.InitiateStream(w) + } + } + + if prompt != nil { + _ = i.recorder.RecordPromptUsage(streamCtx, &recorder.PromptUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: processor.getMsgID(), + Prompt: *prompt, + }) + prompt = nil + } + + if lastUsage := processor.getLastUsage(); lastUsage.CompletionTokens > 0 { + // If the usage information is set, track it. + // The API will send usage information when the response terminates, which will happen if a tool call is invoked. + _ = i.recorder.RecordTokenUsage(streamCtx, &recorder.TokenUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: processor.getMsgID(), + Input: calculateActualInputTokenUsage(lastUsage), + Output: lastUsage.CompletionTokens, + CacheReadInputTokens: lastUsage.PromptTokensDetails.CachedTokens, + ExtraTokenTypes: map[string]int64{ + "prompt_audio": lastUsage.PromptTokensDetails.AudioTokens, + "prompt_cached": lastUsage.PromptTokensDetails.CachedTokens, // TODO: remove from ExtraTokenTypes (https://github.com/coder/aibridge/issues/243) + "completion_accepted_prediction": lastUsage.CompletionTokensDetails.AcceptedPredictionTokens, + "completion_rejected_prediction": lastUsage.CompletionTokensDetails.RejectedPredictionTokens, + "completion_audio": lastUsage.CompletionTokensDetails.AudioTokens, + "completion_reasoning": lastUsage.CompletionTokensDetails.ReasoningTokens, + }, + }) + } + + if !events.IsStreaming() { + // response/downstream Stream has not started yet; write error response and exit. + i.writeUpstreamError(w, getErrorResponse(stream.Err())) + return stream.Err() + } + + // Check if the stream encountered any errors. + if streamErr := stream.Err(); streamErr != nil { + if eventstream.IsUnrecoverableError(streamErr) { + logger.Debug(ctx, "stream terminated", slog.Error(streamErr)) + // We can't reflect an error back if there's a connection error or the request context was canceled. + } else if oaiErr := getErrorResponse(streamErr); oaiErr != nil { + logger.Warn(ctx, "openai stream error", slog.Error(streamErr)) + interceptionErr = oaiErr + } else { + logger.Warn(ctx, "unknown stream error", slog.Error(streamErr)) + // Unfortunately, the OpenAI SDK does not support parsing errors received in the stream + // into known types (i.e. [shared.OverloadedError]). + // See https://github.com/openai/openai-go/blob/v2.7.0/packages/ssestream/ssestream.go#L171 + // All it does is wrap the payload in an error - which is all we can return, currently. + interceptionErr = newErrorResponse(xerrors.Errorf("unknown stream error: %w", streamErr)) + } + } else if lastErr != nil { + // Otherwise check if any logical errors occurred during processing. + logger.Warn(ctx, "stream processing failed", slog.Error(lastErr)) + interceptionErr = newErrorResponse(xerrors.Errorf("processing error: %w", lastErr)) + } + + if interceptionErr != nil { + payload, err := i.marshalErr(interceptionErr) + if err != nil { + logger.Warn(ctx, "failed to marshal error", slog.Error(err), slog.F("error_payload", fmt.Sprintf("%+v", interceptionErr))) + } else if err := events.Send(streamCtx, payload); err != nil { + logger.Warn(ctx, "failed to relay error", slog.Error(err), slog.F("payload", payload)) + } + } + + // No tool call, nothing more to do. + if toolCall == nil { + break + } + + tool := i.getInjectedToolByName(toolCall.Name) + if tool == nil { + // Not a known tool, don't do anything. + logger.Warn(streamCtx, "pending tool call for non-injected tool, this is unexpected", slog.F("tool", toolCall.Name)) + break + } + + // Invoke the injected tool, and use the tool result to make a subsequent request to the upstream. + // Append the completion from this stream as context. + // Some providers may return tool calls with non-zero starting indices, + // resulting in nil entries in the array that must be removed. + completion := processor.getLastCompletion() + if completion != nil { + compactToolCalls(completion) + i.req.Messages = append(i.req.Messages, completion.ToParam()) + } + + id := toolCall.ID + args := i.unmarshalArgs(toolCall.Arguments) + toolRes, toolErr := tool.Call(streamCtx, args, i.tracer) + _ = i.recorder.RecordToolUsage(streamCtx, &recorder.ToolUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: processor.getMsgID(), + ToolCallID: id, + ServerURL: &tool.ServerURL, + Tool: tool.Name, + Args: args, + Injected: true, + InvocationError: toolErr, + }) + + // Reset. + toolCall = nil + + if toolErr != nil { + // Always provide a tool_result even if the tool call failed. + errorJSON, _ := json.Marshal(i.newErrorResponse(toolErr)) + i.req.Messages = append(i.req.Messages, openai.ToolMessage(string(errorJSON), id)) + continue + } + + var out strings.Builder + if err := json.NewEncoder(&out).Encode(toolRes); err != nil { + logger.Warn(ctx, "failed to encode tool response", slog.Error(err)) + // Always provide a tool_result even if encoding failed. + errorJSON, _ := json.Marshal(i.newErrorResponse(err)) + i.req.Messages = append(i.req.Messages, openai.ToolMessage(string(errorJSON), id)) + continue + } + + i.req.Messages = append(i.req.Messages, openai.ToolMessage(out.String(), id)) + } + + // Send termination marker. + if err := events.SendRaw(streamCtx, i.encodeForStream([]byte("[DONE]"))); err != nil { + logger.Debug(ctx, "failed to send termination marker", slog.Error(err)) + } + + // Give the events stream 30 seconds (TODO: configurable) to gracefully shutdown. + shutdownCtx, shutdownCancel := context.WithTimeout(ctx, time.Second*30) + defer shutdownCancel() + if err = events.Shutdown(shutdownCtx); err != nil { + logger.Warn(ctx, "event stream shutdown", slog.Error(err)) + } + + if err != nil { + streamCancel(xerrors.Errorf("stream err: %w", err)) + } else { + streamCancel(xerrors.New("gracefully done")) + } + + return interceptionErr +} + +func (i *StreamingInterception) getInjectedToolByName(name string) *mcp.Tool { + if i.mcpProxy == nil { + return nil + } + + return i.mcpProxy.GetTool(name) +} + +// Mashals received stream chunk. +// Overrides id (since proxy obscures injected tool call invocations). +// If usage field was set in original chunk overrides it to culminative usage. +// +// sjson is used instead of normal struct marshaling so forwarded data +// is as close to the original as possible. Structs from openai library lack +// `omitzero/omitempty` annotations which adds additional empty fields +// when marshaling structs. Those additional empty fields can break Codex client. +func (i *StreamingInterception) marshalChunk(chunk *openai.ChatCompletionChunk, id uuid.UUID, prc *streamProcessor) ([]byte, error) { + sj, err := sjson.Set(chunk.RawJSON(), "id", id.String()) + if err != nil { + return nil, xerrors.Errorf("marshal chunk id failed: %w", err) + } + + // If usage information is available, relay the cumulative usage once all tool invocations have completed. + if chunk.JSON.Usage.Valid() { + u := prc.getCumulativeUsage() + sj, err = sjson.Set(sj, "usage", u) + if err != nil { + return nil, xerrors.Errorf("marshal chunk usage failed: %w", err) + } + } + + return i.encodeForStream([]byte(sj)), nil +} + +func (i *StreamingInterception) marshalErr(err error) ([]byte, error) { + data, err := json.Marshal(err) + if err != nil { + return nil, xerrors.Errorf("marshal error failed: %w", err) + } + + return i.encodeForStream(data), nil +} + +func (*StreamingInterception) encodeForStream(payload []byte) []byte { + // bytes.Buffer writes to in-memory storage and never return errors. + var buf bytes.Buffer + _, _ = buf.WriteString("data: ") + _, _ = buf.Write(payload) + _, _ = buf.WriteString("\n\n") + return buf.Bytes() +} + +// newStream traces svc.NewStreaming(streamCtx, i.req.ChatCompletionNewParams) call +func (i *StreamingInterception) newStream(ctx context.Context, svc openai.ChatCompletionService, opts []option.RequestOption) *ssestream.Stream[openai.ChatCompletionChunk] { + _, span := i.tracer.Start(ctx, "Intercept.ProcessRequest.Upstream", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...)) + defer span.End() + + return svc.NewStreaming(ctx, openai.ChatCompletionNewParams{}, opts...) +} + +type streamProcessor struct { + ctx context.Context + logger slog.Logger + + acc openai.ChatCompletionAccumulator + + // Tool handling. + pendingToolCall bool + getInjectedToolFunc func(string) *mcp.Tool + + // Token handling. + lastUsage openai.CompletionUsage + cumulativeUsage openai.CompletionUsage +} + +func newStreamProcessor(ctx context.Context, logger slog.Logger, isToolInjectedFunc func(string) *mcp.Tool) *streamProcessor { + return &streamProcessor{ + ctx: ctx, + logger: logger, + + getInjectedToolFunc: isToolInjectedFunc, + } +} + +// process receives a completion chunk and returns a bool indicating whether it should be +// relayed to the client. +func (s *streamProcessor) process(chunk openai.ChatCompletionChunk) bool { + if !s.acc.AddChunk(chunk) { + s.logger.Debug(s.ctx, "failed to accumulate chunk", slog.F("chunk", chunk.RawJSON())) + // Potentially not fatal, move along in best effort... + } + + // Accumulate token usage. + s.lastUsage = chunk.Usage + s.cumulativeUsage = sumUsage(s.cumulativeUsage, chunk.Usage) + + // If the stream has reached a terminal state (i.e. call a tool), and this tool is injected, + // then it must not be relayed. + if _, ok := s.acc.JustFinishedToolCall(); ok && s.pendingToolCall { + return false + } + + if len(chunk.Choices) == 0 { + // Odd, should not occur, relay it on in case. + // Nothing more to be done. + return true + } + + // We explicitly set n=1, so this shouldn't happen. + if count := len(chunk.Choices); count > 1 { + s.logger.Warn(s.ctx, "multiple choices returned, only handling first", slog.F("count", count)) + } + + // Check if we have a tool call in progress. + // + // The API will send partial tool call events like this: + // + // data: ... delta":{"tool_calls":[{"index":0,"id":"call_0TxntkwDB66KH8z4RwNqeWrZ","type":"function","function":{"name":"bmcp_coder_coder_list_workspaces","arguments":""}}]}... + // data: ... delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\""}}]}... + // data: ... delta":{"tool_calls":[{"index":0,"function":{"arguments":"owner"}}]}... + // data: ... delta":{"tool_calls":[{"index":0,"function":{"arguments":"\":\""}}]}... + // data: ... delta":{"tool_calls":[{"index":0,"function":{"arguments":"admin"}}]}... + // data: ... delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"}"}}]}... + // + // So we need to ensure that we don't relay any of the partial events to the client in the case of + // an injected tool. + // + // The first partial will tell us the tool name, and we can then decide how to proceed. + + choice := chunk.Choices[0] + if len(choice.Delta.ToolCalls) == 0 { + // No tool calls, no special handling required. + return true + } + + // If we have a pending injected tool call in progress, do not relay any subsequent partial chunks. + if s.pendingToolCall { + return false + } + + // This shouldn't happen since we have parallel tool calls disabled currently. + if count := len(choice.Delta.ToolCalls); count > 1 { + s.logger.Warn(context.Background(), "unexpected tool call count", slog.F("count", count)) + // We'll continue and just examine the first tool. + } + + toolCall := choice.Delta.ToolCalls[0] + if s.isInjected(toolCall) { + // Mark tool as pending until tool call is finished. + s.pendingToolCall = true + return false + } + + // There is a tool call, but it's not injected. + return true +} + +// getMsgID returns the ID given by the API for this (accumulated) message. +func (s *streamProcessor) getMsgID() string { + return s.acc.ID +} + +func (s *streamProcessor) isInjected(toolCall openai.ChatCompletionChunkChoiceDeltaToolCall) bool { + return s.getInjectedToolFunc(strings.TrimSpace(toolCall.Function.Name)) != nil +} + +func (s *streamProcessor) getToolCall() *openai.FinishedChatCompletionToolCall { + tc, ok := s.acc.JustFinishedToolCall() + if !ok { + return nil + } + + return &tc +} + +func (s *streamProcessor) getLastCompletion() *openai.ChatCompletionMessage { + if len(s.acc.Choices) == 0 { + return nil + } + + return &s.acc.Choices[0].Message +} + +func (s *streamProcessor) getLastUsage() openai.CompletionUsage { + return s.lastUsage +} + +func (s *streamProcessor) getCumulativeUsage() openai.CompletionUsage { + return s.cumulativeUsage +} + +// compactToolCalls removes nil/empty tool call entries (without an ID). +func compactToolCalls(msg *openai.ChatCompletionMessage) { + if msg == nil || len(msg.ToolCalls) == 0 { + return + } + msg.ToolCalls = slices.DeleteFunc(msg.ToolCalls, func(tc openai.ChatCompletionMessageToolCallUnion) bool { + return tc.ID == "" + }) +} diff --git a/aibridge/intercept/chatcompletions/streaming_test.go b/aibridge/intercept/chatcompletions/streaming_test.go new file mode 100644 index 0000000000..640ad197c5 --- /dev/null +++ b/aibridge/intercept/chatcompletions/streaming_test.go @@ -0,0 +1,112 @@ +package chatcompletions_test + +import ( + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/google/uuid" + "github.com/openai/openai-go/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/intercept/chatcompletions" + "github.com/coder/coder/v2/aibridge/internal/testutil" +) + +// Test that when the upstream provider returns an error before streaming starts, +// the error status code and body are correctly relayed to the client. +func TestStreamingInterception_RelaysUpstreamErrorToClient(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + statusCode int + responseBody string + expectedErrStr string + expectedBody string + }{ + { + name: "bad request error", + statusCode: http.StatusBadRequest, + responseBody: `{"error":{"message":"Invalid request","type":"invalid_request_error","code":"invalid_request"}}`, + expectedErrStr: strconv.Itoa(http.StatusBadRequest), + expectedBody: "invalid_request", + }, + { + name: "rate limit error", + statusCode: http.StatusTooManyRequests, + responseBody: `{"error":{"message":"Rate limit exceeded","type":"rate_limit_error","code":"rate_limit_exceeded"}}`, + expectedErrStr: strconv.Itoa(http.StatusTooManyRequests), + expectedBody: "rate_limit", + }, + { + name: "internal server error", + statusCode: http.StatusInternalServerError, + responseBody: `{"error":{"message":"Internal server error","type":"server_error","code":"internal_error"}}`, + expectedErrStr: strconv.Itoa(http.StatusInternalServerError), + expectedBody: "server_error", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Setup a mock server that returns an error immediately (before any streaming) + mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("x-should-retry", "false") + w.WriteHeader(tc.statusCode) + _, _ = w.Write([]byte(tc.responseBody)) + })) + t.Cleanup(mockServer.Close) + + // Create interceptor with mock server URL + cfg := config.OpenAI{ + BaseURL: mockServer.URL, + Key: "test-key", + } + + req := &chatcompletions.ChatCompletionNewParamsWrapper{ + ChatCompletionNewParams: openai.ChatCompletionNewParams{ + Model: "gpt-4", + Messages: []openai.ChatCompletionMessageParamUnion{ + openai.UserMessage("hello"), + }, + }, + Stream: true, + } + + // Create test request + w := httptest.NewRecorder() + httpReq := httptest.NewRequest(http.MethodPost, "/chat/completions", nil) + + tracer := otel.Tracer("test") + interceptor := chatcompletions.NewStreamingInterceptor(uuid.New(), req, config.ProviderOpenAI, cfg, httpReq.Header, "Authorization", tracer, intercept.CredentialInfo{}) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + interceptor.Setup(logger, &testutil.MockRecorder{}, nil) + + // Process the request + err := interceptor.ProcessRequest(w, httpReq) + + // Verify error was returned + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrStr) + + // Verify status code was written to response + assert.Equal(t, tc.statusCode, w.Code, "expected status code to be relayed to client") + + // Verify error body contains expected error info + body := w.Body.String() + assert.Contains(t, body, tc.expectedBody, "expected error type in response body") + }) + } +} diff --git a/aibridge/intercept/client_headers.go b/aibridge/intercept/client_headers.go new file mode 100644 index 0000000000..8d4b2def98 --- /dev/null +++ b/aibridge/intercept/client_headers.go @@ -0,0 +1,74 @@ +package intercept + +import ( + "net/http" +) + +// hopByHopHeaders are connection-level headers specific to the connection +// between client and AI Bridge, not meant for the upstream. +// See https://www.rfc-editor.org/rfc/rfc2616#section-13.5.1 +var hopByHopHeaders = []string{ + "Connection", + "Keep-Alive", + "Proxy-Authenticate", + "Proxy-Authorization", + "Te", + "Trailer", + "Transfer-Encoding", + "Upgrade", +} + +// nonForwardedHeaders are transport-level headers managed by aibridge or +// Go's HTTP transport that must not be forwarded to the upstream provider. +var nonForwardedHeaders = []string{ + "Host", + "Accept-Encoding", + "Content-Length", +} + +// authHeaders are headers that carry authentication credentials from the +// client. The upstream request is built by the SDK, which sets the correct +// provider credentials via option.WithAPIKey. Client auth headers are +// stripped here and the provider credentials are re-injected by +// BuildUpstreamHeaders from the SDK-built request. +var authHeaders = []string{ + "Authorization", + "X-Api-Key", +} + +// PrepareClientHeaders returns a copy of the client headers with hop-by-hop, +// transport, and auth headers removed. +func PrepareClientHeaders(clientHeaders http.Header) http.Header { + prepared := clientHeaders.Clone() + for _, h := range hopByHopHeaders { + prepared.Del(h) + } + for _, h := range nonForwardedHeaders { + prepared.Del(h) + } + for _, h := range authHeaders { + prepared.Del(h) + } + return prepared +} + +// BuildUpstreamHeaders produces the header set for an upstream SDK request. +// It starts from the prepared client headers, then preserves specific +// headers from the SDK-built request that must not be overwritten. +func BuildUpstreamHeaders(sdkHeader http.Header, clientHeaders http.Header, authHeaderName string) http.Header { + headers := PrepareClientHeaders(clientHeaders) + + // Preserve the auth header set by the SDK from the provider configuration. + if v := sdkHeader.Get(authHeaderName); v != "" { + headers.Set(authHeaderName, v) + } + + // Preserve actor headers injected by aibridge as per-request SDK options. + for name, values := range sdkHeader { + if IsActorHeader(name) { + headers[name] = values + } + } + + return headers +} diff --git a/aibridge/intercept/client_headers_test.go b/aibridge/intercept/client_headers_test.go new file mode 100644 index 0000000000..f811fbecb0 --- /dev/null +++ b/aibridge/intercept/client_headers_test.go @@ -0,0 +1,221 @@ +package intercept_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/aibridge/intercept" +) + +func TestPrepareClientHeaders(t *testing.T) { + t.Parallel() + + t.Run("nil input returns empty header", func(t *testing.T) { + t.Parallel() + + result := intercept.PrepareClientHeaders(nil) + require.Empty(t, result) + }) + + t.Run("hop-by-hop headers are removed", func(t *testing.T) { + t.Parallel() + + input := http.Header{ + "Connection": {"keep-alive"}, + "Keep-Alive": {"timeout=5"}, + "Transfer-Encoding": {"chunked"}, + "Upgrade": {"websocket"}, + "X-Custom": {"preserved"}, + } + + result := intercept.PrepareClientHeaders(input) + + assert.Empty(t, result.Get("Connection")) + assert.Empty(t, result.Get("Keep-Alive")) + assert.Empty(t, result.Get("Transfer-Encoding")) + assert.Empty(t, result.Get("Upgrade")) + assert.Equal(t, "preserved", result.Get("X-Custom")) + }) + + t.Run("non-forwarded headers are removed", func(t *testing.T) { + t.Parallel() + + input := http.Header{ + "Host": {"example.com"}, + "Accept-Encoding": {"gzip"}, + "Content-Length": {"42"}, + "X-Custom": {"preserved"}, + } + + result := intercept.PrepareClientHeaders(input) + + assert.Empty(t, result.Get("Host")) + assert.Empty(t, result.Get("Accept-Encoding")) + assert.Empty(t, result.Get("Content-Length")) + assert.Equal(t, "preserved", result.Get("X-Custom")) + }) + + t.Run("auth headers are removed", func(t *testing.T) { + t.Parallel() + + input := http.Header{ + "Authorization": {"Bearer coder-session-token"}, + "X-Api-Key": {"sk-client-key"}, + "X-Custom": {"preserved"}, + } + + result := intercept.PrepareClientHeaders(input) + + assert.Empty(t, result.Get("Authorization")) + assert.Empty(t, result.Get("X-Api-Key")) + assert.Equal(t, "preserved", result.Get("X-Custom")) + }) + + t.Run("multi-value headers are preserved", func(t *testing.T) { + t.Parallel() + + input := http.Header{ + "X-Custom": {"value-1", "value-2"}, + } + + result := intercept.PrepareClientHeaders(input) + + require.Equal(t, []string{"value-1", "value-2"}, result["X-Custom"]) + }) + + t.Run("input is not mutated", func(t *testing.T) { + t.Parallel() + + input := http.Header{ + "Connection": {"keep-alive"}, + "X-Custom": {"preserved"}, + } + originalCopy := input.Clone() + + _ = intercept.PrepareClientHeaders(input) + + require.Equal(t, originalCopy, input) + }) +} + +func TestBuildUpstreamHeaders(t *testing.T) { + t.Parallel() + + t.Run("preserves auth from SDK", func(t *testing.T) { + t.Parallel() + + sdkHeader := http.Header{ + "Authorization": {"Bearer sk-provider-key"}, + } + clientHeaders := http.Header{ + "Authorization": {"Bearer coder-session-token"}, + "User-Agent": {"claude-code/1.0"}, + } + + result := intercept.BuildUpstreamHeaders(sdkHeader, clientHeaders, "Authorization") + + assert.Equal(t, "Bearer sk-provider-key", result.Get("Authorization")) + assert.Equal(t, "claude-code/1.0", result.Get("User-Agent")) + }) + + t.Run("preserves X-Api-Key from SDK and strips client Authorization", func(t *testing.T) { + t.Parallel() + + sdkHeader := http.Header{ + "X-Api-Key": {"sk-ant-provider-key"}, + } + clientHeaders := http.Header{ + "X-Api-Key": {"sk-ant-client-key"}, + "Authorization": {"Bearer coder-session-token"}, + "Anthropic-Beta": {"prompt-caching-2024-07-31"}, + } + + result := intercept.BuildUpstreamHeaders(sdkHeader, clientHeaders, "X-Api-Key") + + assert.Equal(t, "sk-ant-provider-key", result.Get("X-Api-Key")) + assert.Empty(t, result.Get("Authorization")) + assert.Equal(t, "prompt-caching-2024-07-31", result.Get("Anthropic-Beta")) + }) + + t.Run("preserves actor headers from SDK", func(t *testing.T) { + t.Parallel() + + sdkHeader := http.Header{ + "Authorization": {"Bearer sk-key"}, + "X-Ai-Bridge-Actor-Id": {"user-123"}, + "X-Ai-Bridge-Actor-Metadata-Name": {"alice"}, + } + clientHeaders := http.Header{ + "Authorization": {"Bearer coder-token"}, + "User-Agent": {"claude-code/1.0"}, + } + + result := intercept.BuildUpstreamHeaders(sdkHeader, clientHeaders, "Authorization") + + assert.Equal(t, "Bearer sk-key", result.Get("Authorization")) + assert.Equal(t, "user-123", result.Get("X-Ai-Bridge-Actor-Id")) + assert.Equal(t, "alice", result.Get("X-Ai-Bridge-Actor-Metadata-Name")) + assert.Equal(t, "claude-code/1.0", result.Get("User-Agent")) + }) + + t.Run("strips hop-by-hop and transport headers", func(t *testing.T) { + t.Parallel() + + sdkHeader := http.Header{ + "Authorization": {"Bearer sk-key"}, + } + clientHeaders := http.Header{ + "Connection": {"keep-alive"}, + "Host": {"bridge.example.com"}, + "Content-Length": {"99"}, + "Accept-Encoding": {"gzip"}, + "Transfer-Encoding": {"chunked"}, + "User-Agent": {"claude-code/1.0"}, + } + + result := intercept.BuildUpstreamHeaders(sdkHeader, clientHeaders, "Authorization") + + assert.Empty(t, result.Get("Connection")) + assert.Empty(t, result.Get("Host")) + assert.Empty(t, result.Get("Content-Length")) + assert.Empty(t, result.Get("Accept-Encoding")) + assert.Empty(t, result.Get("Transfer-Encoding")) + assert.Equal(t, "claude-code/1.0", result.Get("User-Agent")) + }) + + t.Run("empty auth header in SDK is not injected", func(t *testing.T) { + t.Parallel() + + sdkHeader := http.Header{} + clientHeaders := http.Header{ + "User-Agent": {"claude-code/1.0"}, + } + + result := intercept.BuildUpstreamHeaders(sdkHeader, clientHeaders, "Authorization") + + assert.Empty(t, result.Get("Authorization")) + assert.Equal(t, "claude-code/1.0", result.Get("User-Agent")) + }) + + t.Run("does not mutate inputs", func(t *testing.T) { + t.Parallel() + + sdkHeader := http.Header{ + "Authorization": {"Bearer sk-key"}, + } + clientHeaders := http.Header{ + "Authorization": {"Bearer coder-token"}, + "Connection": {"keep-alive"}, + } + sdkCopy := sdkHeader.Clone() + clientCopy := clientHeaders.Clone() + + _ = intercept.BuildUpstreamHeaders(sdkHeader, clientHeaders, "Authorization") + + require.Equal(t, sdkCopy, sdkHeader) + require.Equal(t, clientCopy, clientHeaders) + }) +} diff --git a/aibridge/intercept/credential.go b/aibridge/intercept/credential.go new file mode 100644 index 0000000000..3343245e38 --- /dev/null +++ b/aibridge/intercept/credential.go @@ -0,0 +1,31 @@ +package intercept + +import "github.com/coder/coder/v2/aibridge/utils" + +// CredentialKind identifies how a request was authenticated. +// Keep in sync with the credential_kind enum in coderd's database. +type CredentialKind string + +// Credential kind constants for interception recording. +const ( + CredentialKindCentralized CredentialKind = "centralized" + CredentialKindBYOK CredentialKind = "byok" +) + +// CredentialInfo holds credential metadata for an interception. +type CredentialInfo struct { + Kind CredentialKind + Hint string + Length int +} + +// NewCredentialInfo creates a CredentialInfo from a raw credential. +// The credential is automatically masked before storage so that the +// original secret is never retained. +func NewCredentialInfo(kind CredentialKind, credential string) CredentialInfo { + return CredentialInfo{ + Kind: kind, + Hint: utils.MaskSecret(credential), + Length: len(credential), + } +} diff --git a/aibridge/intercept/eventstream/eventstream.go b/aibridge/intercept/eventstream/eventstream.go new file mode 100644 index 0000000000..fd15d6b4ef --- /dev/null +++ b/aibridge/intercept/eventstream/eventstream.go @@ -0,0 +1,266 @@ +package eventstream + +import ( + "context" + "errors" + "io" + "net" + "net/http" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/quartz" +) + +var ErrEventStreamClosed = xerrors.New("event stream closed") + +const ( + pingInterval = time.Second * 10 + // SlowFlushThreshold is the duration after which a flush to the client is + // considered slow and a warning is logged. + SlowFlushThreshold = time.Millisecond * 500 +) + +type event []byte + +type EventStream struct { + ctx context.Context + logger slog.Logger + clk quartz.Clock + + pingPayload []byte + + initiated atomic.Bool + initiateOnce sync.Once + + shutdownOnce sync.Once + eventsCh chan event + + // doneCh is closed when the start loop exits. + doneCh chan struct{} + + // tick sends periodic pings to keep the connection alive. + tick *time.Ticker +} + +// NewEventStream creates a new SSE stream, with an optional payload which is used to send pings every [pingInterval]. +func NewEventStream(ctx context.Context, logger slog.Logger, pingPayload []byte, clk quartz.Clock) *EventStream { + // Send periodic pings to keep connections alive. + // The upstream provider may also send their own pings, but we can't rely on this. + tick := time.NewTicker(time.Nanosecond) + tick.Stop() // Ticker will start after stream initiation. + + return &EventStream{ + ctx: ctx, + logger: logger, + clk: clk, + + pingPayload: pingPayload, + + eventsCh: make(chan event, 128), // Small buffer to unblock senders; once full, senders will block. + doneCh: make(chan struct{}), + tick: tick, + } +} + +// InitiateStream initiates the SSE stream by sending headers and starting the +// ping ticker. This is safe to call multiple times as only the first call has +// any effect. +func (s *EventStream) InitiateStream(w http.ResponseWriter) { + s.initiateOnce.Do(func() { + s.initiated.Store(true) + s.logger.Debug(s.ctx, "stream initiated") + + // Send headers for Server-Sent Event stream. + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + // Send initial flush to ensure connection is established. + if err := flush(w); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // Start ping ticker. + s.tick.Reset(pingInterval) + }) +} + +// Start handles sending Server-Sent Event to the client. +func (s *EventStream) Start(w http.ResponseWriter, r *http.Request) { + // Signal completion on exit so senders don't block indefinitely after closure. + defer close(s.doneCh) + + ctx := r.Context() + + defer s.tick.Stop() + + for { + var ( + ev event + open bool + ) + + select { + case <-s.ctx.Done(): + return + case <-ctx.Done(): + s.logger.Debug(ctx, "request context canceled", slog.Error(ctx.Err())) + return + case ev, open = <-s.eventsCh: // Once closed, the buffered channel will drain all buffered values before showing as closed. + if !open { + s.logger.Debug(ctx, "events channel closed") + return + } + + // Initiate the stream on first event (if not already initiated). + s.InitiateStream(w) + case <-s.tick.C: + ev = s.pingPayload + if ev == nil { + continue + } + } + + _, err := w.Write(ev) + if err != nil { + if IsConnError(err) { + s.logger.Debug(ctx, "client disconnected during SSE write", slog.Error(err)) + } else { + s.logger.Warn(ctx, "failed to write SSE event", slog.Error(err)) + } + return + } + flushStart := s.clk.Now() + if err := flush(w); err != nil { + s.logger.Warn(ctx, "failed to flush event stream", slog.Error(err)) + return + } + if d := s.clk.Since(flushStart); d > SlowFlushThreshold { + clientIP, _, _ := net.SplitHostPort(r.RemoteAddr) + s.logger.Warn(ctx, "slow client detected", + slog.F("flush_duration", d), + slog.F("client_ip", clientIP), + slog.F("user_agent", r.Header.Get("User-Agent")), + slog.F("payload_size", len(ev)), + ) + } + + // Reset the timer once we've flushed some data to the stream, since it's already fresh. + // No need to ping in that case. + s.tick.Reset(pingInterval) + } +} + +// Send enqueues an event in a non-blocking fashion, but if the channel is full +// then it will block. +func (s *EventStream) Send(ctx context.Context, payload []byte) error { + // Save an unnecessary marshaling if possible. + select { + case <-ctx.Done(): + return ctx.Err() + case <-s.ctx.Done(): + return s.ctx.Err() + case <-s.doneCh: + return ErrEventStreamClosed + default: + } + + return s.SendRaw(ctx, payload) +} + +func (s *EventStream) SendRaw(ctx context.Context, payload []byte) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-s.ctx.Done(): + return s.ctx.Err() + case <-s.doneCh: + return ErrEventStreamClosed + case s.eventsCh <- payload: + return nil + } +} + +// Shutdown gracefully shuts down the stream, sending any supplementary events downstream if required. +// ONLY call this once all events have been submitted. +func (s *EventStream) Shutdown(shutdownCtx context.Context) error { + s.shutdownOnce.Do(func() { + s.logger.Debug(shutdownCtx, "shutdown initiated", slog.F("outstanding_events", len(s.eventsCh))) + + // Now it is safe to close the events channel; the Start() loop will exit + // after draining remaining events and receivers will stop ranging. + close(s.eventsCh) + }) + + var err error + select { + case <-shutdownCtx.Done(): + // If shutdownCtx completes, shutdown likely exceeded its timeout. + err = xerrors.Errorf("shutdown ended prematurely with %d outstanding events: %w", len(s.eventsCh), shutdownCtx.Err()) + case <-s.ctx.Done(): + err = xerrors.Errorf("shutdown ended prematurely with %d outstanding events: %w", len(s.eventsCh), s.ctx.Err()) + case <-s.doneCh: + return nil + } + + // Even if the context is canceled, we need to wait for Start() to complete. + <-s.doneCh + return err +} + +// IsStreaming checks if the stream has been initiated, or +// when events are buffered which - when processed - will initiate the stream. +func (s *EventStream) IsStreaming() bool { + return s.initiated.Load() || len(s.eventsCh) > 0 +} + +// IsConnError checks if an error is related to client disconnection or context cancellation. +func IsConnError(err error) bool { + if err == nil { + return false + } + + if errors.Is(err, io.EOF) { + return true + } + + if errors.Is(err, syscall.ECONNRESET) || errors.Is(err, syscall.EPIPE) || errors.Is(err, net.ErrClosed) { + return true + } + + errStr := err.Error() + return strings.Contains(errStr, "broken pipe") || + strings.Contains(errStr, "connection reset by peer") +} + +func IsUnrecoverableError(err error) bool { + if errors.Is(err, context.Canceled) { + return true + } + + return IsConnError(err) +} + +func flush(w http.ResponseWriter) (err error) { + flusher, ok := w.(http.Flusher) + if !ok || flusher == nil { + return xerrors.New("SSE not supported") + } + + defer func() { + if r := recover(); r != nil { //nolint:revive,staticcheck // Intentionally swallowed; likely a broken connection. + } + }() + + flusher.Flush() + return nil +} diff --git a/aibridge/intercept/eventstream/eventstream_test.go b/aibridge/intercept/eventstream/eventstream_test.go new file mode 100644 index 0000000000..854b11eee0 --- /dev/null +++ b/aibridge/intercept/eventstream/eventstream_test.go @@ -0,0 +1,110 @@ +package eventstream_test + +import ( + "bufio" + "context" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/sloghuman" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/aibridge/intercept/eventstream" + "github.com/coder/quartz" +) + +// clockAdvancingFlusher wraps httptest.ResponseRecorder and advances the mock +// clock on each Flush call, simulating a slow client without real sleeping. +type clockAdvancingFlusher struct { + *httptest.ResponseRecorder + clk *quartz.Mock + advance time.Duration +} + +func (f *clockAdvancingFlusher) Flush() { + f.clk.Advance(f.advance) + f.ResponseRecorder.Flush() +} + +// Hijack satisfies the FullResponseWriter lint rule. +func (*clockAdvancingFlusher) Hijack() (net.Conn, *bufio.ReadWriter, error) { + return nil, nil, nil +} + +func TestEventStream_LogsWarning_WhenFlushIsSlow(t *testing.T) { + t.Parallel() + + var buf strings.Builder + logger := slogtest.Make(t, nil).AppendSinks(sloghuman.Sink(&buf)).Leveled(slog.LevelWarn) + ctx := context.Background() + clk := quartz.NewMock(t) + + stream := eventstream.NewEventStream(ctx, logger, nil, clk) + + w := &clockAdvancingFlusher{ + ResponseRecorder: httptest.NewRecorder(), + clk: clk, + advance: eventstream.SlowFlushThreshold + time.Millisecond, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/", nil) + require.NoError(t, err) + req.RemoteAddr = "192.0.2.1:12345" + req.Header.Set("User-Agent", "test-agent/1.0") + + done := make(chan struct{}) + go func() { + defer close(done) + stream.Start(w, req) + }() + + stream.InitiateStream(w) + require.NoError(t, stream.SendRaw(ctx, []byte("data: hello\n\n"))) + require.NoError(t, stream.Shutdown(ctx)) + <-done + + require.Contains(t, buf.String(), "slow client detected") + require.Contains(t, buf.String(), "192.0.2.1") + require.Contains(t, buf.String(), "test-agent/1.0") + require.Contains(t, buf.String(), "payload_size=13") +} + +func TestEventStream_NoWarning_WhenFlushIsFast(t *testing.T) { + t.Parallel() + + var buf strings.Builder + logger := slogtest.Make(t, nil).AppendSinks(sloghuman.Sink(&buf)).Leveled(slog.LevelWarn) + ctx := context.Background() + clk := quartz.NewMock(t) + + stream := eventstream.NewEventStream(ctx, logger, nil, clk) + + // No clock advance, flush duration stays at 0, below threshold. + w := &clockAdvancingFlusher{ + ResponseRecorder: httptest.NewRecorder(), + clk: clk, + advance: 0, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "/", nil) + require.NoError(t, err) + + done := make(chan struct{}) + go func() { + defer close(done) + stream.Start(w, req) + }() + + stream.InitiateStream(w) + require.NoError(t, stream.SendRaw(ctx, []byte("data: hello\n\n"))) + require.NoError(t, stream.Shutdown(ctx)) + <-done + + require.Empty(t, buf.String()) +} diff --git a/aibridge/intercept/interceptor.go b/aibridge/intercept/interceptor.go new file mode 100644 index 0000000000..33cbc51dff --- /dev/null +++ b/aibridge/intercept/interceptor.go @@ -0,0 +1,40 @@ +package intercept + +import ( + "net/http" + + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/recorder" +) + +// Interceptor describes a (potentially) stateful interaction with an AI provider. +type Interceptor interface { + // ID returns the unique identifier for this interception. + ID() uuid.UUID + // Setup injects some required dependencies. This MUST be called before using the interceptor + // to process requests. + Setup(logger slog.Logger, rec recorder.Recorder, mcpProxy mcp.ServerProxier) + // Model returns the model in use for this [Interceptor]. + Model() string + // ProcessRequest handles the HTTP request. + ProcessRequest(w http.ResponseWriter, r *http.Request) error + // Specifies whether an interceptor handles streaming or not. + Streaming() bool + // TraceAttributes returns tracing attributes for this [Interceptor] + TraceAttributes(*http.Request) []attribute.KeyValue + // Credential returns the credential metadata for this interception. + Credential() CredentialInfo + // CorrelatingToolCallID returns the ID of a tool call result submitted + // in the request, if present. This is used to correlate the current + // interception back to the previous interception that issued those tool + // calls. If multiple tool use results are present, we use the last one + // (most recent). Both Anthropic's /v1/messages and OpenAI's /v1/responses + // require that ALL tool results are submitted for tool choices returned + // by the model, so any single tool call ID is sufficient to identify the + // parent interception. + CorrelatingToolCallID() *string +} diff --git a/aibridge/intercept/messages/base.go b/aibridge/intercept/messages/base.go new file mode 100644 index 0000000000..9853f7da5e --- /dev/null +++ b/aibridge/intercept/messages/base.go @@ -0,0 +1,563 @@ +package messages + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/bedrock" + "github.com/anthropics/anthropic-sdk-go/option" + "github.com/anthropics/anthropic-sdk-go/shared" + "github.com/anthropics/anthropic-sdk-go/shared/constant" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/google/uuid" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + aibconfig "github.com/coder/coder/v2/aibridge/config" + aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/intercept/apidump" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/recorder" + "github.com/coder/coder/v2/aibridge/tracing" + "github.com/coder/coder/v2/aibridge/utils" + "github.com/coder/quartz" +) + +// bedrockSupportedBetaFlags is the set of Anthropic-Beta flags that AWS Bedrock +// accepts. Flags not in this set cause a 400 "invalid beta flag" error. +// +// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html +var bedrockSupportedBetaFlags = map[string]bool{ + // Supported on Claude 3.7 Sonnet. + "computer-use-2025-01-24": true, + // Supported on Claude 3.7 Sonnet and Claude 4+. + "token-efficient-tools-2025-02-19": true, + // Supported on Claude 4+ models. + "interleaved-thinking-2025-05-14": true, + // Supported on Claude 3.7 Sonnet. + "output-128k-2025-02-19": true, + // Supported on Claude 4+ models. Requires account team access. + "dev-full-thinking-2025-05-14": true, + // Supported on Claude Sonnet 4. + "context-1m-2025-08-07": true, + // Supported on Claude Sonnet 4.5 and Claude Haiku 4.5. + // Enables context_management body field for thinking block clearing. + "context-management-2025-06-27": true, + // Supported on Claude Opus 4.5. + // Enables output_config body field for effort control. + "effort-2025-11-24": true, + // Supported on Claude Opus 4.5. + "tool-search-tool-2025-10-19": true, + // Supported on Claude Opus 4.5. + "tool-examples-2025-10-29": true, +} + +type interceptionBase struct { + id uuid.UUID + providerName string + reqPayload RequestPayload + + cfg aibconfig.Anthropic + bedrockCfg *aibconfig.AWSBedrock + + // clientHeaders are the original HTTP headers from the client request. + clientHeaders http.Header + authHeaderName string + + tracer trace.Tracer + logger slog.Logger + + recorder recorder.Recorder + mcpProxy mcp.ServerProxier + credential intercept.CredentialInfo +} + +func (i *interceptionBase) ID() uuid.UUID { + return i.id +} + +func (i *interceptionBase) Credential() intercept.CredentialInfo { + return i.credential +} + +func (i *interceptionBase) Setup(logger slog.Logger, rec recorder.Recorder, mcpProxy mcp.ServerProxier) { + i.logger = logger + i.recorder = rec + i.mcpProxy = mcpProxy +} + +func (i *interceptionBase) CorrelatingToolCallID() *string { + return i.reqPayload.correlatingToolCallID() +} + +func (i *interceptionBase) Model() string { + if len(i.reqPayload) == 0 { + return "coder-aibridge-unknown" + } + + if i.bedrockCfg != nil { + model := i.bedrockCfg.Model + if i.isSmallFastModel() { + model = i.bedrockCfg.SmallFastModel + } + return model + } + + return i.reqPayload.model() +} + +func (i *interceptionBase) baseTraceAttributes(r *http.Request, streaming bool) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String(tracing.RequestPath, r.URL.Path), + attribute.String(tracing.InterceptionID, i.id.String()), + attribute.String(tracing.InitiatorID, aibcontext.ActorIDFromContext(r.Context())), + attribute.String(tracing.Provider, i.providerName), + attribute.String(tracing.Model, i.Model()), + attribute.Bool(tracing.Streaming, streaming), + attribute.Bool(tracing.IsBedrock, i.bedrockCfg != nil), + } +} + +func (i *interceptionBase) injectTools() { + if i.mcpProxy == nil || !i.hasInjectableTools() { + return + } + + i.disableParallelToolCalls() + + // Inject tools. + var injectedTools []anthropic.ToolUnionParam + for _, tool := range i.mcpProxy.ListTools() { + injectedTools = append(injectedTools, anthropic.ToolUnionParam{ + OfTool: &anthropic.ToolParam{ + InputSchema: anthropic.ToolInputSchemaParam{ + Properties: tool.Params, + Required: tool.Required, + }, + Name: tool.ID, + Description: anthropic.String(tool.Description), + Type: anthropic.ToolTypeCustom, + }, + }) + } + + // Prepend the injected tools in order to maintain any configured cache breakpoints. + // The order of injected tools is expected to be stable, and therefore will not cause + // any cache invalidation when prepended. + updated, err := i.reqPayload.injectTools(injectedTools) + if err != nil { + i.logger.Warn(context.Background(), "failed to set inject tools in request payload", slog.Error(err)) + return + } + i.reqPayload = updated +} + +func (i *interceptionBase) disableParallelToolCalls() { + // Note: Parallel tool calls are disabled to avoid tool_use/tool_result block mismatches. + // https://github.com/coder/aibridge/issues/2 + updated, err := i.reqPayload.disableParallelToolCalls() + if err != nil { + i.logger.Warn(context.Background(), "failed to set tool_choice in request payload", slog.Error(err)) + return + } + i.reqPayload = updated +} + +// extractModelThoughts returns any thinking blocks that were returned in the response. +func (*interceptionBase) extractModelThoughts(msg *anthropic.Message) []*recorder.ModelThoughtRecord { + if msg == nil { + return nil + } + + var thoughtRecords []*recorder.ModelThoughtRecord + for _, block := range msg.Content { + // anthropic.RedactedThinkingBlock also exists, but there's nothing useful we can capture. + variant, ok := block.AsAny().(anthropic.ThinkingBlock) + if !ok || variant.Thinking == "" { + continue + } + thoughtRecords = append(thoughtRecords, &recorder.ModelThoughtRecord{ + Content: variant.Thinking, + Metadata: recorder.Metadata{"source": recorder.ThoughtSourceThinking}, + }) + } + return thoughtRecords +} + +// IsSmallFastModel checks if the model is a small/fast model (Haiku 3.5). +// These models are optimized for tasks like code autocomplete and other small, quick operations. +// See `ANTHROPIC_SMALL_FAST_MODEL`: https://docs.anthropic.com/en/docs/claude-code/settings#environment-variables +// https://docs.claude.com/en/docs/claude-code/costs#background-token-usage +func (i *interceptionBase) isSmallFastModel() bool { + return strings.Contains(i.reqPayload.model(), "haiku") +} + +func (i *interceptionBase) newMessagesService(ctx context.Context, opts ...option.RequestOption) (anthropic.MessageService, error) { + // BYOK with access token uses Authorization: Bearer. + // Otherwise use X-Api-Key (centralized or BYOK with personal API key). + if i.cfg.BYOKBearerToken != "" { + i.logger.Debug(ctx, "using byok access token auth", + slog.F("bearer_hint", utils.MaskSecret(i.cfg.BYOKBearerToken)), + ) + opts = append(opts, option.WithAuthToken(i.cfg.BYOKBearerToken)) + } else { + i.logger.Debug(ctx, "using api key auth", + slog.F("api_key_hint", utils.MaskSecret(i.cfg.Key)), + ) + opts = append(opts, option.WithAPIKey(i.cfg.Key)) + } + opts = append(opts, option.WithBaseURL(i.cfg.BaseURL)) + if i.cfg.MaxRetries != nil { + opts = append(opts, option.WithMaxRetries(*i.cfg.MaxRetries)) + } + + // Add extra headers if configured. + // Some providers require additional headers that are not added by the SDK. + // TODO(ssncferreira): remove as part of https://github.com/coder/aibridge/issues/192 + for key, value := range i.cfg.ExtraHeaders { + opts = append(opts, option.WithHeader(key, value)) + } + + // Forward client headers to upstream. This middleware runs after the SDK + // has built the request, and replaces the outgoing headers with the sanitized + // client headers plus provider auth. + if i.clientHeaders != nil { + opts = append(opts, option.WithMiddleware(func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + req.Header = intercept.BuildUpstreamHeaders(req.Header, i.clientHeaders, i.authHeaderName) + return next(req) + })) + } + + // Add API dump middleware if configured + if mw := apidump.NewBridgeMiddleware(i.cfg.APIDumpDir, i.providerName, i.Model(), i.id, i.logger, quartz.NewReal()); mw != nil { + opts = append(opts, option.WithMiddleware(mw)) + } + + if i.bedrockCfg != nil { + ctx, cancel := context.WithTimeout(ctx, time.Second*30) + defer cancel() + bedrockOpts, err := i.withAWSBedrockOptions(ctx, i.bedrockCfg) + if err != nil { + return anthropic.MessageService{}, err + } + opts = append(opts, bedrockOpts...) + i.augmentRequestForBedrock() + } + + return anthropic.NewMessageService(opts...), nil +} + +// withBody returns a per-request option that sends the current raw request +// payload as the request body. This is called for each API request so that the +// latest payload (including any messages appended during the agentic tool loop) +// is always sent. +func (i *interceptionBase) withBody() option.RequestOption { + return option.WithRequestBody("application/json", []byte(i.reqPayload)) +} + +// withAWSBedrockOptions returns request options for authenticating with AWS Bedrock. +// +// When both AccessKey and AccessKeySecret are set in the aibridge config, they are +// used directly as static credentials. Otherwise, the AWS SDK default credential chain +// resolves credentials (environment variables, shared config/credentials files, IAM +// roles, IRSA, SSO, IMDS, etc.). +func (*interceptionBase) withAWSBedrockOptions(ctx context.Context, cfg *aibconfig.AWSBedrock) ([]option.RequestOption, error) { + if cfg == nil { + return nil, xerrors.New("nil config given") + } + if cfg.Region == "" && cfg.BaseURL == "" { + return nil, xerrors.New("region or base url required") + } + if cfg.Model == "" { + return nil, xerrors.New("model required") + } + if cfg.SmallFastModel == "" { + return nil, xerrors.New("small fast model required") + } + + loadOpts := []func(*config.LoadOptions) error{ + config.WithRegion(cfg.Region), + } + + // Use static credentials when explicitly provided, otherwise fall back to the SDK default credential chain. + switch { + // Both set: use static credentials directly. + case cfg.AccessKey != "" && cfg.AccessKeySecret != "": + loadOpts = append(loadOpts, config.WithCredentialsProvider( + credentials.NewStaticCredentialsProvider( + cfg.AccessKey, + cfg.AccessKeySecret, + "", + ), + )) + // Only one set: misconfiguration. + case cfg.AccessKey != "" || cfg.AccessKeySecret != "": + return nil, xerrors.New("both access key and access key secret must be provided together") + // Neither set: SDK default credential chain resolves credentials. + default: + } + + awsCfg, err := config.LoadDefaultConfig(ctx, loadOpts...) + if err != nil { + return nil, xerrors.Errorf("failed to load AWS Bedrock config: %w", err) + } + + // Fail fast: ensure credentials can be resolved before making any requests. + // awsCfg already carries the credentials provider, and the Bedrock middleware + // will call Retrieve on it when signing each request. + if _, err := awsCfg.Credentials.Retrieve(ctx); err != nil { + return nil, xerrors.Errorf("no AWS credentials found: %w", err) + } + + var out []option.RequestOption + out = append(out, bedrock.WithConfig(awsCfg)) + + // If a custom base URL is set, override the default endpoint constructed by the bedrock middleware. + if cfg.BaseURL != "" { + out = append(out, option.WithBaseURL(cfg.BaseURL)) + } + + return out, nil +} + +// augmentRequestForBedrock will change the model used for the request since AWS Bedrock doesn't support +// Anthropics' model names. It also converts adaptive thinking to enabled with a budget for models that +// don't support adaptive thinking natively. +func (i *interceptionBase) augmentRequestForBedrock() { + if i.bedrockCfg == nil { + return + } + + model := i.Model() + updated, err := i.reqPayload.withModel(model) + if err != nil { + i.logger.Warn(context.Background(), "failed to set model in request payload for Bedrock", slog.Error(err)) + return + } + i.reqPayload = updated + + if !bedrockModelSupportsAdaptiveThinking(model) { + updated, err = i.reqPayload.convertAdaptiveThinkingForBedrock() + if err != nil { + i.logger.Warn(context.Background(), "failed to convert adaptive thinking for Bedrock", slog.Error(err)) + return + } + i.reqPayload = updated + } + + // Filter Anthropic-Beta header to only include Bedrock-supported flags + // that the current model supports. + if i.clientHeaders != nil { + filterBedrockBetaFlags(i.clientHeaders, model) + } + + // Strip body fields that Bedrock does not accept. + updated, err = i.reqPayload.removeUnsupportedBedrockFields(i.clientHeaders) + if err != nil { + i.logger.Warn(context.Background(), "failed to remove unsupported fields for Bedrock", slog.Error(err)) + return + } + i.reqPayload = updated +} + +// bedrockModelSupportsAdaptiveThinking returns true if the given Bedrock model ID +// supports the "adaptive" thinking type natively (i.e. Claude 4.6 models). +// See https://docs.aws.amazon.com/bedrock/latest/userguide/claude-messages-adaptive-thinking.html +func bedrockModelSupportsAdaptiveThinking(model string) bool { + return strings.Contains(model, "anthropic.claude-opus-4-6") || + strings.Contains(model, "anthropic.claude-sonnet-4-6") +} + +// filterBedrockBetaFlags removes unsupported beta flags from the Anthropic-Beta +// header and also removes model-gated flags the current model doesn't support. +// https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html +func filterBedrockBetaFlags(headers http.Header, model string) { + // Collect all flags regardless of whether the client sent them as a single + // comma-separated value (eg. Claude Code sends them in that format) + // or as multiple separate header lines. + // https://httpwg.org/specs/rfc9110.html#rfc.section.5.3 + var flags []string + for _, v := range headers.Values("Anthropic-Beta") { + flags = append(flags, strings.Split(v, ",")...) + } + + if len(flags) == 0 { + return + } + + var keep []string + for _, flag := range flags { + trimmed := strings.TrimSpace(flag) + if !bedrockSupportedBetaFlags[trimmed] { + continue + } + + // effort is only supported in Opus 4.5 on Bedrock. + if trimmed == "effort-2025-11-24" && !strings.Contains(model, "anthropic.claude-opus-4-5") { + continue + } + + // context_management is only supported in Sonnet 4.5 and Haiku 4.5 models on Bedrock. + if trimmed == "context-management-2025-06-27" && + !strings.Contains(model, "anthropic.claude-sonnet-4-5") && + !strings.Contains(model, "anthropic.claude-haiku-4-5") { + continue + } + + keep = append(keep, trimmed) + } + + headers.Del("Anthropic-Beta") + for _, flag := range keep { + headers.Add("Anthropic-Beta", flag) + } +} + +// writeUpstreamError marshals and writes a given error. +func (i *interceptionBase) writeUpstreamError(w http.ResponseWriter, antErr *responseError) { + if antErr == nil { + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(antErr.StatusCode) + + out, err := json.Marshal(antErr) + if err != nil { + i.logger.Warn(context.Background(), "failed to marshal upstream error", slog.Error(err), slog.F("error_payload", fmt.Sprintf("%+v", antErr))) + // Response has to match expected format. + // See https://docs.claude.com/en/api/errors#error-shapes. + _, _ = w.Write([]byte(fmt.Sprintf(`{ + "type":"error", + "error": { + "type": "error", + "message":"error marshaling upstream error" + }, + "request_id": "%s" +}`, i.ID().String()))) + } else { + _, _ = w.Write(out) + } +} + +func (i *interceptionBase) hasInjectableTools() bool { + return i.mcpProxy != nil && len(i.mcpProxy.ListTools()) > 0 +} + +// accumulateUsage accumulates usage statistics from source into dest. +// It handles both [anthropic.Usage] and [anthropic.MessageDeltaUsage] types through [any]. +// The function uses reflection to handle the differences between the types: +// - [anthropic.Usage] has CacheCreation field with ephemeral tokens +// - [anthropic.MessageDeltaUsage] doesn't have CacheCreation field +func accumulateUsage(dest, src any) { + switch d := dest.(type) { + case *anthropic.Usage: + if d == nil { + return + } + switch s := src.(type) { + case anthropic.Usage: + // Usage -> Usage + d.CacheCreation.Ephemeral1hInputTokens += s.CacheCreation.Ephemeral1hInputTokens + d.CacheCreation.Ephemeral5mInputTokens += s.CacheCreation.Ephemeral5mInputTokens + d.CacheCreationInputTokens += s.CacheCreationInputTokens + d.CacheReadInputTokens += s.CacheReadInputTokens + d.InputTokens += s.InputTokens + d.OutputTokens += s.OutputTokens + d.ServerToolUse.WebSearchRequests += s.ServerToolUse.WebSearchRequests + case anthropic.MessageDeltaUsage: + // MessageDeltaUsage -> Usage + d.CacheCreationInputTokens += s.CacheCreationInputTokens + d.CacheReadInputTokens += s.CacheReadInputTokens + d.InputTokens += s.InputTokens + d.OutputTokens += s.OutputTokens + d.ServerToolUse.WebSearchRequests += s.ServerToolUse.WebSearchRequests + } + case *anthropic.MessageDeltaUsage: + if d == nil { + return + } + switch s := src.(type) { + case anthropic.Usage: + // Usage -> MessageDeltaUsage (only common fields) + d.CacheCreationInputTokens += s.CacheCreationInputTokens + d.CacheReadInputTokens += s.CacheReadInputTokens + d.InputTokens += s.InputTokens + d.OutputTokens += s.OutputTokens + d.ServerToolUse.WebSearchRequests += s.ServerToolUse.WebSearchRequests + case anthropic.MessageDeltaUsage: + // MessageDeltaUsage -> MessageDeltaUsage + d.CacheCreationInputTokens += s.CacheCreationInputTokens + d.CacheReadInputTokens += s.CacheReadInputTokens + d.InputTokens += s.InputTokens + d.OutputTokens += s.OutputTokens + d.ServerToolUse.WebSearchRequests += s.ServerToolUse.WebSearchRequests + } + } +} + +func getErrorResponse(err error) *responseError { + var apierr *anthropic.Error + if !errors.As(err, &apierr) { + return nil + } + + msg := apierr.Error() + typ := string(constant.ValueOf[constant.APIError]()) + + var detail *anthropic.APIErrorObject + if field, ok := apierr.JSON.ExtraFields["error"]; ok { + _ = json.Unmarshal([]byte(field.Raw()), &detail) + } + if detail != nil { + msg = detail.Message + typ = string(detail.Type) + } + + return &responseError{ + ErrorResponse: &anthropic.ErrorResponse{ + Error: anthropic.ErrorObjectUnion{ + Message: msg, + Type: typ, + }, + Type: constant.ValueOf[constant.Error](), + }, + StatusCode: apierr.StatusCode, + } +} + +var _ error = &responseError{} + +type responseError struct { + *anthropic.ErrorResponse + + StatusCode int `json:"-"` +} + +func newErrorResponse(msg error) *responseError { + return &responseError{ + ErrorResponse: &shared.ErrorResponse{ + Error: shared.ErrorObjectUnion{ + Message: msg.Error(), + Type: "error", + }, + }, + } +} + +func (a *responseError) Error() string { + if a.ErrorResponse == nil { + return "" + } + return a.ErrorResponse.Error.Message +} diff --git a/aibridge/intercept/messages/base_test.go b/aibridge/intercept/messages/base_test.go new file mode 100644 index 0000000000..148c77c3fa --- /dev/null +++ b/aibridge/intercept/messages/base_test.go @@ -0,0 +1,993 @@ +package messages //nolint:testpackage // tests unexported internals + +import ( + "context" + "net/http" + "testing" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/shared/constant" + mcpgo "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/utils" +) + +func TestScanForCorrelatingToolCallID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + requestBody string + expected *string + }{ + { + name: "no messages field", + requestBody: `{}`, + expected: nil, + }, + { + name: "messages string", + requestBody: `{"messages":"test"}`, + expected: nil, + }, + { + name: "empty messages array", + requestBody: `{"messages":[]}`, + expected: nil, + }, + { + name: "last message has no tool result blocks", + requestBody: `{"messages":[{"role":"user","content":"hello"}]}`, + expected: nil, + }, + { + name: "single tool result block", + requestBody: `{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_abc","content":"result"}]}]}`, + expected: utils.PtrTo("toolu_abc"), + }, + { + name: "multiple tool result blocks returns last", + requestBody: `{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_first","content":"first"},{"type":"text","text":"ignored"},{"type":"tool_result","tool_use_id":"toolu_second","content":"second"}]}]}`, + expected: utils.PtrTo("toolu_second"), + }, + { + name: "last message is not a tool result", + requestBody: `{"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_first","content":"first"}]},{"role":"user","content":"some text"}]}`, + expected: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + base := &interceptionBase{ + reqPayload: mustMessagesPayload(t, tc.requestBody), + } + + require.Equal(t, tc.expected, base.CorrelatingToolCallID()) + }) + } +} + +func TestAWSBedrockValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg *config.AWSBedrock + expectError bool + errorMsg string + }{ + // Valid cases: static credentials. + { + name: "static credentials with region", + cfg: &config.AWSBedrock{ + Region: "us-east-1", + AccessKey: "test-key", + AccessKeySecret: "test-secret", + Model: "test-model", + SmallFastModel: "test-small-model", + }, + }, + { + name: "static credentials with base url", + cfg: &config.AWSBedrock{ + BaseURL: "http://bedrock.internal", + AccessKey: "test-key", + AccessKeySecret: "test-secret", + Model: "test-model", + SmallFastModel: "test-small-model", + }, + }, + { + // There unfortunately isn't a way for us to determine precedence in a unit test, + // since the produced options take a `requestconfig.RequestConfig` input value + // which is internal to the anthropic SDK. + // + // See TestAWSBedrockIntegration which validates this. + name: "static credentials with base url & region", + cfg: &config.AWSBedrock{ + Region: "us-east-1", + AccessKey: "test-key", + AccessKeySecret: "test-secret", + Model: "test-model", + SmallFastModel: "test-small-model", + }, + }, + // Invalid cases. + { + name: "missing region & base url", + cfg: &config.AWSBedrock{ + Region: "", + AccessKey: "test-key", + AccessKeySecret: "test-secret", + Model: "test-model", + SmallFastModel: "test-small-model", + }, + expectError: true, + errorMsg: "region or base url required", + }, + { + name: "missing access key", + cfg: &config.AWSBedrock{ + Region: "us-east-1", + AccessKeySecret: "test-secret", + Model: "test-model", + SmallFastModel: "test-small-model", + }, + expectError: true, + errorMsg: "both access key and access key secret must be provided together", + }, + { + name: "missing access key secret", + cfg: &config.AWSBedrock{ + Region: "us-east-1", + AccessKey: "test-key", + AccessKeySecret: "", + Model: "test-model", + SmallFastModel: "test-small-model", + }, + expectError: true, + errorMsg: "both access key and access key secret must be provided together", + }, + { + name: "missing model", + cfg: &config.AWSBedrock{ + Region: "us-east-1", + AccessKey: "test-key", + AccessKeySecret: "test-secret", + Model: "", + SmallFastModel: "test-small-model", + }, + expectError: true, + errorMsg: "model required", + }, + { + name: "missing small fast model", + cfg: &config.AWSBedrock{ + Region: "us-east-1", + AccessKey: "test-key", + AccessKeySecret: "test-secret", + Model: "test-model", + SmallFastModel: "", + }, + expectError: true, + errorMsg: "small fast model required", + }, + { + name: "all fields empty", + cfg: &config.AWSBedrock{}, + expectError: true, + errorMsg: "region or base url required", + }, + { + name: "nil config", + cfg: nil, + expectError: true, + errorMsg: "nil config given", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + base := &interceptionBase{} + opts, err := base.withAWSBedrockOptions(context.Background(), tt.cfg) + + if tt.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NotEmpty(t, opts) + require.NoError(t, err) + } + }) + } +} + +// TestAWSBedrockCredentialChain tests credential resolution via the AWS SDK default credential chain. +// NOTE: Cannot use t.Parallel() here because subtests use t.Setenv which requires sequential execution. +func TestAWSBedrockCredentialChain(t *testing.T) { + tests := []struct { + name string + cfg *config.AWSBedrock + envVars map[string]string + expectError bool + errorMsg string + }{ + { + name: "temporary credentials via env", + cfg: &config.AWSBedrock{ + Region: "us-east-1", + Model: "test-model", + SmallFastModel: "test-small-model", + }, + envVars: map[string]string{ + "AWS_ACCESS_KEY_ID": "test-key", + "AWS_SECRET_ACCESS_KEY": "test-secret", + }, + }, + { + name: "temporary credentials with session token via env", + cfg: &config.AWSBedrock{ + Region: "us-east-1", + Model: "test-model", + SmallFastModel: "test-small-model", + }, + envVars: map[string]string{ + "AWS_ACCESS_KEY_ID": "test-key", + "AWS_SECRET_ACCESS_KEY": "test-secret", + "AWS_SESSION_TOKEN": "test-session-token", + }, + }, + { + // When static credentials are not provided and no environment credentials are set, + // the SDK default credential chain fails to resolve credentials. + name: "error when no credential source is configured", + cfg: &config.AWSBedrock{ + Region: "us-east-1", + Model: "test-model", + SmallFastModel: "test-small-model", + }, + envVars: map[string]string{ + "AWS_ACCESS_KEY_ID": "", + "AWS_SECRET_ACCESS_KEY": "", + "AWS_SESSION_TOKEN": "", + "AWS_PROFILE": "", + "AWS_SHARED_CREDENTIALS_FILE": "/dev/null", + "AWS_CONFIG_FILE": "/dev/null", + "AWS_WEB_IDENTITY_TOKEN_FILE": "", + "AWS_ROLE_ARN": "", + "AWS_ROLE_SESSION_NAME": "", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI": "", + "AWS_CONTAINER_CREDENTIALS_FULL_URI": "", + "AWS_CONTAINER_AUTHORIZATION_TOKEN": "", + "AWS_EC2_METADATA_DISABLED": "true", + }, + expectError: true, + errorMsg: "no AWS credentials found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for key, val := range tt.envVars { + t.Setenv(key, val) + } + base := &interceptionBase{} + opts, err := base.withAWSBedrockOptions(context.Background(), tt.cfg) + + if tt.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NotEmpty(t, opts) + require.NoError(t, err) + } + }) + } +} + +func TestAccumulateUsage(t *testing.T) { + t.Parallel() + + t.Run("Usage to Usage", func(t *testing.T) { + t.Parallel() + dest := &anthropic.Usage{ + InputTokens: 10, + OutputTokens: 20, + CacheCreationInputTokens: 5, + CacheReadInputTokens: 3, + CacheCreation: anthropic.CacheCreation{ + Ephemeral1hInputTokens: 2, + Ephemeral5mInputTokens: 1, + }, + ServerToolUse: anthropic.ServerToolUsage{ + WebSearchRequests: 1, + }, + } + + source := anthropic.Usage{ + InputTokens: 15, + OutputTokens: 25, + CacheCreationInputTokens: 8, + CacheReadInputTokens: 4, + CacheCreation: anthropic.CacheCreation{ + Ephemeral1hInputTokens: 3, + Ephemeral5mInputTokens: 2, + }, + ServerToolUse: anthropic.ServerToolUsage{ + WebSearchRequests: 2, + }, + } + + accumulateUsage(dest, source) + + require.EqualValues(t, 25, dest.InputTokens) + require.EqualValues(t, 45, dest.OutputTokens) + require.EqualValues(t, 13, dest.CacheCreationInputTokens) + require.EqualValues(t, 7, dest.CacheReadInputTokens) + require.EqualValues(t, 5, dest.CacheCreation.Ephemeral1hInputTokens) + require.EqualValues(t, 3, dest.CacheCreation.Ephemeral5mInputTokens) + require.EqualValues(t, 3, dest.ServerToolUse.WebSearchRequests) + }) + + t.Run("MessageDeltaUsage to MessageDeltaUsage", func(t *testing.T) { + t.Parallel() + + dest := &anthropic.MessageDeltaUsage{ + InputTokens: 10, + OutputTokens: 20, + CacheCreationInputTokens: 5, + CacheReadInputTokens: 3, + ServerToolUse: anthropic.ServerToolUsage{ + WebSearchRequests: 1, + }, + } + + source := anthropic.MessageDeltaUsage{ + InputTokens: 15, + OutputTokens: 25, + CacheCreationInputTokens: 8, + CacheReadInputTokens: 4, + ServerToolUse: anthropic.ServerToolUsage{ + WebSearchRequests: 2, + }, + } + + accumulateUsage(dest, source) + + require.EqualValues(t, 25, dest.InputTokens) + require.EqualValues(t, 45, dest.OutputTokens) + require.EqualValues(t, 13, dest.CacheCreationInputTokens) + require.EqualValues(t, 7, dest.CacheReadInputTokens) + require.EqualValues(t, 3, dest.ServerToolUse.WebSearchRequests) + }) + + t.Run("Usage to MessageDeltaUsage", func(t *testing.T) { + t.Parallel() + + dest := &anthropic.MessageDeltaUsage{ + InputTokens: 10, + OutputTokens: 20, + CacheCreationInputTokens: 5, + CacheReadInputTokens: 3, + ServerToolUse: anthropic.ServerToolUsage{ + WebSearchRequests: 1, + }, + } + + source := anthropic.Usage{ + InputTokens: 15, + OutputTokens: 25, + CacheCreationInputTokens: 8, + CacheReadInputTokens: 4, + CacheCreation: anthropic.CacheCreation{ + Ephemeral1hInputTokens: 3, // These won't be accumulated to MessageDeltaUsage + Ephemeral5mInputTokens: 2, + }, + ServerToolUse: anthropic.ServerToolUsage{ + WebSearchRequests: 2, + }, + } + + accumulateUsage(dest, source) + + require.EqualValues(t, 25, dest.InputTokens) + require.EqualValues(t, 45, dest.OutputTokens) + require.EqualValues(t, 13, dest.CacheCreationInputTokens) + require.EqualValues(t, 7, dest.CacheReadInputTokens) + require.EqualValues(t, 3, dest.ServerToolUse.WebSearchRequests) + }) + + t.Run("MessageDeltaUsage to Usage", func(t *testing.T) { + t.Parallel() + + dest := &anthropic.Usage{ + InputTokens: 10, + OutputTokens: 20, + CacheCreationInputTokens: 5, + CacheReadInputTokens: 3, + CacheCreation: anthropic.CacheCreation{ + Ephemeral1hInputTokens: 2, + Ephemeral5mInputTokens: 1, + }, + ServerToolUse: anthropic.ServerToolUsage{ + WebSearchRequests: 1, + }, + } + + source := anthropic.MessageDeltaUsage{ + InputTokens: 15, + OutputTokens: 25, + CacheCreationInputTokens: 8, + CacheReadInputTokens: 4, + ServerToolUse: anthropic.ServerToolUsage{ + WebSearchRequests: 2, + }, + } + + accumulateUsage(dest, source) + + require.EqualValues(t, 25, dest.InputTokens) + require.EqualValues(t, 45, dest.OutputTokens) + require.EqualValues(t, 13, dest.CacheCreationInputTokens) + require.EqualValues(t, 7, dest.CacheReadInputTokens) + // Ephemeral tokens remain unchanged since MessageDeltaUsage doesn't have them + require.EqualValues(t, 2, dest.CacheCreation.Ephemeral1hInputTokens) + require.EqualValues(t, 1, dest.CacheCreation.Ephemeral5mInputTokens) + require.EqualValues(t, 3, dest.ServerToolUse.WebSearchRequests) + }) + + t.Run("Nil or unsupported types", func(t *testing.T) { + t.Parallel() + + // Test with nil dest + var nilUsage *anthropic.Usage + source := anthropic.Usage{InputTokens: 10} + accumulateUsage(nilUsage, source) // Should not panic + + // Test with unsupported types + var unsupported string + accumulateUsage(&unsupported, source) // Should not panic, just do nothing + }) +} + +func TestInjectTools_CacheBreakpoints(t *testing.T) { + t.Parallel() + + t.Run("cache control preserved when no tools to inject", func(t *testing.T) { + t.Parallel() + + // Request has existing tool with cache control, but no tools to inject. + i := &interceptionBase{ + reqPayload: mustMessagesPayload(t, `{"tools":[`+ + `{"name":"existing_tool","type":"custom","input_schema":{"type":"object","properties":{}},"cache_control":{"type":"ephemeral"}}]}`), + mcpProxy: &mockServerProxier{tools: nil}, + logger: slog.Make(), + } + + i.injectTools() + + // Cache control should remain untouched since no tools were injected. + toolItems := gjson.GetBytes(i.reqPayload, "tools").Array() + require.Len(t, toolItems, 1) + require.Equal(t, "existing_tool", toolItems[0].Get("name").String()) + require.Equal(t, string(constant.ValueOf[constant.Ephemeral]()), toolItems[0].Get("cache_control.type").String()) + }) + + t.Run("cache control breakpoint is preserved by prepending injected tools", func(t *testing.T) { + t.Parallel() + + // Request has existing tool with cache control. + i := &interceptionBase{ + reqPayload: mustMessagesPayload(t, `{"tools":[`+ + `{"name":"existing_tool","type":"custom","input_schema":{"type":"object","properties":{}},"cache_control":{"type":"ephemeral"}}]}`), + mcpProxy: &mockServerProxier{ + tools: []*mcp.Tool{ + {ID: "injected_tool", Name: "injected", Description: "Injected tool"}, + }, + }, + logger: slog.Make(), + } + + i.injectTools() + + toolItems := gjson.GetBytes(i.reqPayload, "tools").Array() + require.Len(t, toolItems, 2) + // Injected tools are prepended. + require.Equal(t, "injected_tool", toolItems[0].Get("name").String()) + require.Empty(t, toolItems[0].Get("cache_control.type").String()) + // Original tool's cache control should be preserved at the end. + require.Equal(t, "existing_tool", toolItems[1].Get("name").String()) + require.Equal(t, string(constant.ValueOf[constant.Ephemeral]()), toolItems[1].Get("cache_control.type").String()) + }) + + // The cache breakpoint SHOULD be on the final tool, but may not be; we must preserve that intention. + t.Run("cache control breakpoint in non-standard location is preserved", func(t *testing.T) { + t.Parallel() + + // Request has multiple tools with cache control breakpoints. + i := &interceptionBase{ + reqPayload: mustMessagesPayload(t, `{"tools":[`+ + `{"name":"tool_with_cache_1","type":"custom","input_schema":{"type":"object","properties":{}},"cache_control":{"type":"ephemeral"}},`+ + `{"name":"tool_with_cache_2","type":"custom","input_schema":{"type":"object","properties":{}}}]}`), + mcpProxy: &mockServerProxier{ + tools: []*mcp.Tool{ + {ID: "injected_tool", Name: "injected", Description: "Injected tool"}, + }, + }, + logger: slog.Make(), + } + + i.injectTools() + + toolItems := gjson.GetBytes(i.reqPayload, "tools").Array() + require.Len(t, toolItems, 3) + // Injected tool is prepended without cache control. + require.Equal(t, "injected_tool", toolItems[0].Get("name").String()) + require.Empty(t, toolItems[0].Get("cache_control.type").String()) + // Both original tools' cache controls should remain. + require.Equal(t, "tool_with_cache_1", toolItems[1].Get("name").String()) + require.Equal(t, string(constant.ValueOf[constant.Ephemeral]()), toolItems[1].Get("cache_control.type").String()) + require.Equal(t, "tool_with_cache_2", toolItems[2].Get("name").String()) + require.Empty(t, toolItems[2].Get("cache_control.type").String()) + }) + + t.Run("no cache control added when none originally set", func(t *testing.T) { + t.Parallel() + + // Request has tools but none with cache control. + i := &interceptionBase{ + reqPayload: mustMessagesPayload(t, `{"tools":[`+ + `{"name":"existing_tool_no_cache","type":"custom","input_schema":{"type":"object","properties":{}}}]}`), + mcpProxy: &mockServerProxier{ + tools: []*mcp.Tool{ + {ID: "injected_tool", Name: "injected", Description: "Injected tool"}, + }, + }, + logger: slog.Make(), + } + + i.injectTools() + + toolItems := gjson.GetBytes(i.reqPayload, "tools").Array() + require.Len(t, toolItems, 2) + // Injected tool is prepended without cache control. + require.Equal(t, "injected_tool", toolItems[0].Get("name").String()) + require.Empty(t, toolItems[0].Get("cache_control.type").String()) + // Original tool remains at the end without cache control. + require.Equal(t, "existing_tool_no_cache", toolItems[1].Get("name").String()) + require.Empty(t, toolItems[1].Get("cache_control.type").String()) + }) +} + +func TestInjectTools_ParallelToolCalls(t *testing.T) { + t.Parallel() + + t.Run("does not modify tool choice when no tools to inject", func(t *testing.T) { + t.Parallel() + + i := &interceptionBase{ + reqPayload: mustMessagesPayload(t, `{"tool_choice":{"type":"auto"}}`), + mcpProxy: &mockServerProxier{tools: nil}, // No tools to inject. + logger: slog.Make(), + } + + i.injectTools() + + // Tool choice should remain unchanged - DisableParallelToolUse should not be set. + toolChoice := gjson.GetBytes(i.reqPayload, "tool_choice") + require.Equal(t, string(constant.ValueOf[constant.Auto]()), toolChoice.Get("type").String()) + require.False(t, toolChoice.Get("disable_parallel_tool_use").Exists()) + }) + + t.Run("disables parallel tool use for empty tool choice (default)", func(t *testing.T) { + t.Parallel() + + i := &interceptionBase{ + reqPayload: mustMessagesPayload(t, `{}`), + mcpProxy: &mockServerProxier{ + tools: []*mcp.Tool{{ID: "test_tool", Name: "test", Description: "Test"}}, + }, + logger: slog.Make(), + } + + i.injectTools() + + toolChoice := gjson.GetBytes(i.reqPayload, "tool_choice") + require.Equal(t, string(constant.ValueOf[constant.Auto]()), toolChoice.Get("type").String()) + require.True(t, toolChoice.Get("disable_parallel_tool_use").Exists()) + require.True(t, toolChoice.Get("disable_parallel_tool_use").Bool()) + }) + + t.Run("disables parallel tool use for explicit auto tool choice", func(t *testing.T) { + t.Parallel() + + i := &interceptionBase{ + reqPayload: mustMessagesPayload(t, `{"tool_choice":{"type":"auto"}}`), + mcpProxy: &mockServerProxier{ + tools: []*mcp.Tool{{ID: "test_tool", Name: "test", Description: "Test"}}, + }, + logger: slog.Make(), + } + + i.injectTools() + + toolChoice := gjson.GetBytes(i.reqPayload, "tool_choice") + require.Equal(t, string(constant.ValueOf[constant.Auto]()), toolChoice.Get("type").String()) + require.True(t, toolChoice.Get("disable_parallel_tool_use").Exists()) + require.True(t, toolChoice.Get("disable_parallel_tool_use").Bool()) + }) + + t.Run("disables parallel tool use for any tool choice", func(t *testing.T) { + t.Parallel() + + i := &interceptionBase{ + reqPayload: mustMessagesPayload(t, `{"tool_choice":{"type":"any"}}`), + mcpProxy: &mockServerProxier{ + tools: []*mcp.Tool{{ID: "test_tool", Name: "test", Description: "Test"}}, + }, + logger: slog.Make(), + } + + i.injectTools() + + toolChoice := gjson.GetBytes(i.reqPayload, "tool_choice") + require.Equal(t, string(constant.ValueOf[constant.Any]()), toolChoice.Get("type").String()) + require.True(t, toolChoice.Get("disable_parallel_tool_use").Exists()) + require.True(t, toolChoice.Get("disable_parallel_tool_use").Bool()) + }) + + t.Run("disables parallel tool use for tool choice type", func(t *testing.T) { + t.Parallel() + + i := &interceptionBase{ + reqPayload: mustMessagesPayload(t, `{"tool_choice":{"type":"tool","name":"specific_tool"}}`), + mcpProxy: &mockServerProxier{ + tools: []*mcp.Tool{{ID: "test_tool", Name: "test", Description: "Test"}}, + }, + logger: slog.Make(), + } + + i.injectTools() + + toolChoice := gjson.GetBytes(i.reqPayload, "tool_choice") + require.Equal(t, string(constant.ValueOf[constant.Tool]()), toolChoice.Get("type").String()) + require.True(t, toolChoice.Get("disable_parallel_tool_use").Exists()) + require.True(t, toolChoice.Get("disable_parallel_tool_use").Bool()) + }) + + t.Run("no-op for none tool choice type", func(t *testing.T) { + t.Parallel() + + i := &interceptionBase{ + reqPayload: mustMessagesPayload(t, `{"tool_choice":{"type":"none"}}`), + mcpProxy: &mockServerProxier{ + tools: []*mcp.Tool{{ID: "test_tool", Name: "test", Description: "Test"}}, + }, + logger: slog.Make(), + } + + i.injectTools() + + // Tools are still injected. + require.Len(t, gjson.GetBytes(i.reqPayload, "tools").Array(), 1) + // But no parallel tool use modification for "none" type. + toolChoice := gjson.GetBytes(i.reqPayload, "tool_choice") + require.Equal(t, string(constant.ValueOf[constant.None]()), toolChoice.Get("type").String()) + require.False(t, toolChoice.Get("disable_parallel_tool_use").Exists()) + }) +} + +func TestAugmentRequestForBedrock_AdaptiveThinking(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + + bedrockModel string + requestBody string + clientBetaFlags string + + expectThinkingType string + expectBudgetTokens int64 // 0 means budget_tokens should not be present + expectRemovedFields []string + expectKeptFields []string + expectBetaValues []string // expected separate Anthropic-Beta header values + }{ + { + name: "non_4_6_model_with_adaptive_thinking_gets_converted", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"}}`, + expectThinkingType: "enabled", + expectBudgetTokens: 8000, // 10000 * 0.8 (default/high effort) + }, + { + name: "non_4_6_model_with_adaptive_thinking_and_small_max_tokens_disables_thinking", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + requestBody: `{"max_tokens":1000,"thinking":{"type":"adaptive"}}`, + expectThinkingType: "disabled", + }, + { + name: "opus_4_6_model_with_adaptive_thinking_is_not_converted", + bedrockModel: "anthropic.claude-opus-4-6-v1", + requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"}}`, + expectThinkingType: "adaptive", + }, + { + name: "sonnet_4_6_model_with_adaptive_thinking_is_not_converted", + bedrockModel: "anthropic.claude-sonnet-4-6", + requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"}}`, + expectThinkingType: "adaptive", + }, + { + name: "non_4_6_model_with_no_thinking_field_is_unchanged", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + requestBody: `{"max_tokens":10000}`, + }, + { + name: "non_4_6_model_with_enabled_thinking_is_unchanged", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + requestBody: `{"max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000}}`, + expectThinkingType: "enabled", + expectBudgetTokens: 5000, + }, + { + name: "output_config_stripped_without_beta_flag_and_effort_used_for_budget", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"low"}}`, + expectThinkingType: "enabled", + expectBudgetTokens: 2000, // 10000 * 0.2 (low effort) + expectRemovedFields: []string{"output_config"}, + }, + { + name: "output_config_kept_when_effort_beta_flag_present_on_opus_4_5", + bedrockModel: "anthropic.claude-opus-4-5-20250929-v1:0", + clientBetaFlags: "effort-2025-11-24,interleaved-thinking-2025-05-14", + requestBody: `{"max_tokens":10000,"output_config":{"effort":"high"}}`, + expectKeptFields: []string{"output_config"}, + expectBetaValues: []string{"effort-2025-11-24", "interleaved-thinking-2025-05-14"}, + }, + { + name: "output_config_stripped_for_non_opus_4_5_even_with_effort_beta_flag", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + clientBetaFlags: "effort-2025-11-24,interleaved-thinking-2025-05-14", + requestBody: `{"max_tokens":10000,"output_config":{"effort":"high"}}`, + expectRemovedFields: []string{"output_config"}, + expectBetaValues: []string{"interleaved-thinking-2025-05-14"}, + }, + { + name: "context_management_kept_when_beta_flag_present", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + clientBetaFlags: "context-management-2025-06-27", + requestBody: `{"max_tokens":10000,"context_management":{"type":"auto"}}`, + expectKeptFields: []string{"context_management"}, + expectBetaValues: []string{"context-management-2025-06-27"}, + }, + { + name: "context_management_stripped_without_beta_flag", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + requestBody: `{"max_tokens":10000,"context_management":{"type":"auto"}}`, + expectRemovedFields: []string{"context_management"}, + }, + { + name: "context_management_stripped_for_unsupported_model_even_with_beta_flag", + bedrockModel: "anthropic.claude-opus-4-6-v1", + clientBetaFlags: "context-management-2025-06-27", + requestBody: `{"max_tokens":10000,"thinking":{"type":"adaptive"},"context_management":{"type":"auto"}}`, + expectThinkingType: "adaptive", + expectRemovedFields: []string{"context_management"}, + }, + { + name: "unsupported_beta_flags_are_filtered_out", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + clientBetaFlags: "claude-code-20250219,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05", + requestBody: `{"max_tokens":10000}`, + expectBetaValues: []string{"interleaved-thinking-2025-05-14"}, + }, + { + name: "all_unsupported_fields_stripped_and_beta_flags_filtered", + bedrockModel: "anthropic.claude-sonnet-4-5-20250929-v1:0", + clientBetaFlags: "claude-code-20250219,prompt-caching-scope-2026-01-05", + requestBody: `{"max_tokens":10000,"output_config":{"effort":"high"},"metadata":{"user_id":"u123"},"service_tier":"auto","container":"ctr_abc","inference_geo":"us","context_management":{"type":"auto"}}`, + expectRemovedFields: []string{"output_config", "metadata", "service_tier", "container", "inference_geo", "context_management"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var clientHeaders http.Header + if tc.clientBetaFlags != "" { + clientHeaders = http.Header{ + "Anthropic-Beta": {tc.clientBetaFlags}, + } + } + + i := &interceptionBase{ + reqPayload: mustMessagesPayload(t, tc.requestBody), + bedrockCfg: &config.AWSBedrock{ + Model: tc.bedrockModel, + SmallFastModel: "anthropic.claude-haiku-3-5", + }, + clientHeaders: clientHeaders, + logger: slog.Make(), + } + + i.augmentRequestForBedrock() + + thinkingType := gjson.GetBytes(i.reqPayload, "thinking.type") + if tc.expectThinkingType == "" { + require.False(t, thinkingType.Exists()) + } else { + require.Equal(t, tc.expectThinkingType, thinkingType.String()) + } + + budgetTokens := gjson.GetBytes(i.reqPayload, "thinking.budget_tokens") + if tc.expectBudgetTokens == 0 { + require.False(t, budgetTokens.Exists(), "budget_tokens should not be set") + } else { + require.Equal(t, tc.expectBudgetTokens, budgetTokens.Int()) + } + + // Model should always be set to the bedrock model. + require.Equal(t, tc.bedrockModel, gjson.GetBytes(i.reqPayload, "model").String()) + + // Verify expected fields are removed. + for _, field := range tc.expectRemovedFields { + require.False(t, gjson.GetBytes(i.reqPayload, field).Exists(), "%s should be removed", field) + } + + // Verify expected fields are kept. + for _, field := range tc.expectKeptFields { + require.True(t, gjson.GetBytes(i.reqPayload, field).Exists(), "%s should be kept", field) + } + + got := clientHeaders.Values("Anthropic-Beta") + require.Equal(t, tc.expectBetaValues, got) + }) + } +} + +func mustMessagesPayload(t *testing.T, requestBody string) RequestPayload { + t.Helper() + + payload, err := NewRequestPayload([]byte(requestBody)) + require.NoError(t, err) + + return payload +} + +// mockServerProxier is a test implementation of mcp.ServerProxier. +type mockServerProxier struct { + tools []*mcp.Tool +} + +func (*mockServerProxier) Init(context.Context) error { + return nil +} + +func (*mockServerProxier) Shutdown(context.Context) error { + return nil +} + +func (m *mockServerProxier) ListTools() []*mcp.Tool { + return m.tools +} + +func (m *mockServerProxier) GetTool(id string) *mcp.Tool { + for _, t := range m.tools { + if t.ID == id { + return t + } + } + return nil +} + +func (*mockServerProxier) CallTool(context.Context, string, any) (*mcpgo.CallToolResult, error) { + return nil, nil //nolint:nilnil // mock: no-op implementation +} + +func TestFilterBedrockBetaFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + model string + inputValues []string // header values to set (each element is a separate header value) + expectValues []string // expected separate header values after filtering + }{ + { + name: "empty header", + model: "anthropic.claude-sonnet-4-5-20250929-v1:0", + inputValues: nil, + expectValues: nil, + }, + { + name: "all supported flags kept", + model: "anthropic.claude-opus-4-5-20250929-v1:0", + inputValues: []string{"interleaved-thinking-2025-05-14,effort-2025-11-24"}, + expectValues: []string{"interleaved-thinking-2025-05-14", "effort-2025-11-24"}, + }, + { + name: "unsupported flags removed", + model: "anthropic.claude-sonnet-4-5-20250929-v1:0", + inputValues: []string{"claude-code-20250219,interleaved-thinking-2025-05-14,prompt-caching-scope-2026-01-05"}, + expectValues: []string{"interleaved-thinking-2025-05-14"}, + }, + { + name: "header removed when all flags unsupported", + model: "anthropic.claude-sonnet-4-5-20250929-v1:0", + inputValues: []string{"claude-code-20250219,prompt-caching-scope-2026-01-05"}, + expectValues: nil, + }, + { + name: "effort flag removed for non opus 4.5 model", + model: "anthropic.claude-sonnet-4-5-20250929-v1:0", + inputValues: []string{"effort-2025-11-24,interleaved-thinking-2025-05-14"}, + expectValues: []string{"interleaved-thinking-2025-05-14"}, + }, + { + name: "effort flag kept for opus 4.5 model", + model: "anthropic.claude-opus-4-5-20250929-v1:0", + inputValues: []string{"effort-2025-11-24,interleaved-thinking-2025-05-14"}, + expectValues: []string{"effort-2025-11-24", "interleaved-thinking-2025-05-14"}, + }, + { + name: "context management kept for sonnet 4.5", + model: "anthropic.claude-sonnet-4-5-20250929-v1:0", + inputValues: []string{"context-management-2025-06-27"}, + expectValues: []string{"context-management-2025-06-27"}, + }, + { + name: "context management kept for haiku 4.5", + model: "anthropic.claude-haiku-4-5-20250929-v1:0", + inputValues: []string{"context-management-2025-06-27"}, + expectValues: []string{"context-management-2025-06-27"}, + }, + { + name: "context management removed for unsupported model", + model: "anthropic.claude-opus-4-6-v1", + inputValues: []string{"context-management-2025-06-27,interleaved-thinking-2025-05-14"}, + expectValues: []string{"interleaved-thinking-2025-05-14"}, + }, + { + name: "separate header values are handled correctly", + model: "anthropic.claude-sonnet-4-5-20250929-v1:0", + inputValues: []string{"interleaved-thinking-2025-05-14", "context-management-2025-06-27"}, + expectValues: []string{"interleaved-thinking-2025-05-14", "context-management-2025-06-27"}, + }, + { + name: "mixed comma-joined and separate header values", + model: "anthropic.claude-opus-4-5-20250929-v1:0", + inputValues: []string{"interleaved-thinking-2025-05-14,effort-2025-11-24", "token-efficient-tools-2025-02-19"}, + expectValues: []string{"interleaved-thinking-2025-05-14", "effort-2025-11-24", "token-efficient-tools-2025-02-19"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + headers := http.Header{} + for _, v := range tc.inputValues { + headers.Add("Anthropic-Beta", v) + } + + filterBedrockBetaFlags(headers, tc.model) + + // Each kept flag should be a separate header value. + got := headers.Values("Anthropic-Beta") + require.Equal(t, tc.expectValues, got) + }) + } +} diff --git a/aibridge/intercept/messages/blocking.go b/aibridge/intercept/messages/blocking.go new file mode 100644 index 0000000000..610f934578 --- /dev/null +++ b/aibridge/intercept/messages/blocking.go @@ -0,0 +1,342 @@ +package messages + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + "github.com/google/uuid" + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/tidwall/sjson" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/intercept/eventstream" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/recorder" + "github.com/coder/coder/v2/aibridge/tracing" +) + +type BlockingInterception struct { + interceptionBase +} + +func NewBlockingInterceptor( + id uuid.UUID, + reqPayload RequestPayload, + providerName string, + cfg config.Anthropic, + bedrockCfg *config.AWSBedrock, + clientHeaders http.Header, + authHeaderName string, + tracer trace.Tracer, + cred intercept.CredentialInfo, +) *BlockingInterception { + return &BlockingInterception{interceptionBase: interceptionBase{ + id: id, + providerName: providerName, + reqPayload: reqPayload, + cfg: cfg, + bedrockCfg: bedrockCfg, + clientHeaders: clientHeaders, + authHeaderName: authHeaderName, + tracer: tracer, + credential: cred, + }} +} + +func (i *BlockingInterception) Setup(logger slog.Logger, rec recorder.Recorder, mcpProxy mcp.ServerProxier) { + i.interceptionBase.Setup(logger.Named("blocking"), rec, mcpProxy) +} + +func (i *BlockingInterception) TraceAttributes(r *http.Request) []attribute.KeyValue { + return i.interceptionBase.baseTraceAttributes(r, false) +} + +func (*BlockingInterception) Streaming() bool { + return false +} + +func (i *BlockingInterception) ProcessRequest(w http.ResponseWriter, r *http.Request) (outErr error) { + if len(i.reqPayload) == 0 { + return xerrors.New("developer error: request payload is empty") + } + + ctx, span := i.tracer.Start(r.Context(), "Intercept.ProcessRequest", trace.WithAttributes(tracing.InterceptionAttributesFromContext(r.Context())...)) + defer tracing.EndSpanErr(span, &outErr) + + i.injectTools() + + var prompt *string + promptText, promptFound, promptErr := i.reqPayload.lastUserPrompt() + if promptErr != nil { + i.logger.Warn(ctx, "failed to retrieve last user prompt", slog.Error(promptErr)) + } else if promptFound { + prompt = &promptText + } + + // TODO(ssncferreira): inject actor headers directly in the client-header + // middleware instead of using SDK options. + opts := []option.RequestOption{option.WithRequestTimeout(time.Second * 600)} + if actor := aibcontext.ActorFromContext(r.Context()); actor != nil && i.cfg.SendActorHeaders { + opts = append(opts, intercept.ActorHeadersAsAnthropicOpts(actor)...) + } + + svc, err := i.newMessagesService(ctx, opts...) + if err != nil { + err = xerrors.Errorf("create anthropic client: %w", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return err + } + + logger := i.logger.With(slog.F("model", i.Model())) + + var resp *anthropic.Message + // Accumulate usage across the entire streaming interaction (including tool reinvocations). + var cumulativeUsage anthropic.Usage + + for { + // TODO add outer loop span (https://github.com/coder/aibridge/issues/67) + resp, err = i.newMessage(ctx, svc) + if err != nil { + if eventstream.IsConnError(err) { + // Can't write a response, just error out. + return xerrors.Errorf("upstream connection closed: %w", err) + } + + if antErr := getErrorResponse(err); antErr != nil { + i.writeUpstreamError(w, antErr) + return xerrors.Errorf("anthropic API error: %w", err) + } + + http.Error(w, "internal error", http.StatusInternalServerError) + return xerrors.Errorf("internal error: %w", err) + } + + if prompt != nil { + _ = i.recorder.RecordPromptUsage(ctx, &recorder.PromptUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: resp.ID, + Prompt: *prompt, + }) + prompt = nil + } + + _ = i.recorder.RecordTokenUsage(ctx, &recorder.TokenUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: resp.ID, + Input: resp.Usage.InputTokens, + Output: resp.Usage.OutputTokens, + CacheReadInputTokens: resp.Usage.CacheReadInputTokens, + CacheWriteInputTokens: resp.Usage.CacheCreationInputTokens, + ExtraTokenTypes: map[string]int64{ + "web_search_requests": resp.Usage.ServerToolUse.WebSearchRequests, + "cache_creation_input": resp.Usage.CacheCreationInputTokens, // TODO: remove from ExtraTokenTypes (https://github.com/coder/aibridge/issues/243) + "cache_read_input": resp.Usage.CacheReadInputTokens, // TODO: remove from ExtraTokenTypes (https://github.com/coder/aibridge/issues/243) + "cache_ephemeral_1h_input": resp.Usage.CacheCreation.Ephemeral1hInputTokens, + "cache_ephemeral_5m_input": resp.Usage.CacheCreation.Ephemeral5mInputTokens, + }, + }) + + accumulateUsage(&cumulativeUsage, resp.Usage) + + // Capture any thinking blocks that were returned. + for _, t := range i.extractModelThoughts(resp) { + _ = i.recorder.RecordModelThought(ctx, &recorder.ModelThoughtRecord{ + InterceptionID: i.ID().String(), + Content: t.Content, + Metadata: t.Metadata, + }) + } + + // Handle tool calls. + var pendingToolCalls []anthropic.ToolUseBlock + for _, c := range resp.Content { + toolUse := c.AsToolUse() + if toolUse.ID == "" { + continue + } + + if i.mcpProxy != nil && i.mcpProxy.GetTool(toolUse.Name) != nil { + pendingToolCalls = append(pendingToolCalls, toolUse) + continue + } + + // If tool is not injected, track it since the client will be handling it. + _ = i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: resp.ID, + ToolCallID: toolUse.ID, + Tool: toolUse.Name, + Args: toolUse.Input, + Injected: false, + }) + } + + // If no injected tool calls, we're done. + if len(pendingToolCalls) == 0 { + break + } + + var loopMessages []anthropic.MessageParam + loopMessages = append(loopMessages, resp.ToParam()) + + // Process each pending tool call. + for _, tc := range pendingToolCalls { + if i.mcpProxy == nil { + continue + } + + tool := i.mcpProxy.GetTool(tc.Name) + if tool == nil { + logger.Warn(ctx, "tool not found in manager", slog.F("tool", tc.Name)) + // Continue to next tool call, but still append an error tool_result + loopMessages = append(loopMessages, + anthropic.NewUserMessage(anthropic.NewToolResultBlock(tc.ID, fmt.Sprintf("Error: tool %s not found", tc.Name), true)), + ) + continue + } + + res, err := tool.Call(ctx, tc.Input, i.tracer) + + _ = i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: resp.ID, + ToolCallID: tc.ID, + ServerURL: &tool.ServerURL, + Tool: tool.Name, + Args: tc.Input, + Injected: true, + InvocationError: err, + }) + + if err != nil { + // Always provide a tool_result even if the tool call failed + loopMessages = append(loopMessages, + anthropic.NewUserMessage(anthropic.NewToolResultBlock(tc.ID, fmt.Sprintf("Error: calling tool: %v", err), true)), + ) + continue + } + + // Process tool result + toolResult := anthropic.ContentBlockParamUnion{ + OfToolResult: &anthropic.ToolResultBlockParam{ + ToolUseID: tc.ID, + IsError: anthropic.Bool(false), + }, + } + + var hasValidResult bool + for _, content := range res.Content { + switch cb := content.(type) { + case mcplib.TextContent: + toolResult.OfToolResult.Content = append(toolResult.OfToolResult.Content, anthropic.ToolResultBlockParamContentUnion{ + OfText: &anthropic.TextBlockParam{ + Text: cb.Text, + }, + }) + hasValidResult = true + // TODO: is there a more correct way of handling these non-text content responses? + case mcplib.EmbeddedResource: + switch resource := cb.Resource.(type) { + case mcplib.TextResourceContents: + val := fmt.Sprintf("Binary resource (MIME: %s, URI: %s): %s", + resource.MIMEType, resource.URI, resource.Text) + toolResult.OfToolResult.Content = append(toolResult.OfToolResult.Content, anthropic.ToolResultBlockParamContentUnion{ + OfText: &anthropic.TextBlockParam{ + Text: val, + }, + }) + hasValidResult = true + case mcplib.BlobResourceContents: + val := fmt.Sprintf("Binary resource (MIME: %s, URI: %s): %s", + resource.MIMEType, resource.URI, resource.Blob) + toolResult.OfToolResult.Content = append(toolResult.OfToolResult.Content, anthropic.ToolResultBlockParamContentUnion{ + OfText: &anthropic.TextBlockParam{ + Text: val, + }, + }) + hasValidResult = true + default: + i.logger.Warn(ctx, "unknown embedded resource type", slog.F("type", fmt.Sprintf("%T", resource))) + toolResult.OfToolResult.Content = append(toolResult.OfToolResult.Content, anthropic.ToolResultBlockParamContentUnion{ + OfText: &anthropic.TextBlockParam{ + Text: "Error: unknown embedded resource type", + }, + }) + toolResult.OfToolResult.IsError = anthropic.Bool(true) + hasValidResult = true + } + default: + i.logger.Warn(ctx, "not handling non-text tool result", slog.F("type", fmt.Sprintf("%T", cb))) + toolResult.OfToolResult.Content = append(toolResult.OfToolResult.Content, anthropic.ToolResultBlockParamContentUnion{ + OfText: &anthropic.TextBlockParam{ + Text: "Error: unsupported tool result type", + }, + }) + toolResult.OfToolResult.IsError = anthropic.Bool(true) + hasValidResult = true + } + } + + // If no content was processed, still add a tool_result + if !hasValidResult { + i.logger.Warn(ctx, "no tool result added", slog.F("content_len", len(res.Content)), slog.F("is_error", res.IsError)) + toolResult.OfToolResult.Content = append(toolResult.OfToolResult.Content, anthropic.ToolResultBlockParamContentUnion{ + OfText: &anthropic.TextBlockParam{ + Text: "Error: no valid tool result content", + }, + }) + toolResult.OfToolResult.IsError = anthropic.Bool(true) + } + + if len(toolResult.OfToolResult.Content) > 0 { + loopMessages = append(loopMessages, anthropic.NewUserMessage(toolResult)) + } + } + + updatedPayload, rewriteErr := i.reqPayload.appendedMessages(loopMessages) + if rewriteErr != nil { + http.Error(w, rewriteErr.Error(), http.StatusInternalServerError) + return xerrors.Errorf("rewrite payload for agentic loop: %w", rewriteErr) + } + i.reqPayload = updatedPayload + } + + if resp == nil { + return nil + } + + // Overwrite response identifier since proxy obscures injected tool call invocations. + sj, err := sjson.Set(resp.RawJSON(), "id", i.ID().String()) + if err != nil { + return xerrors.Errorf("marshal response id failed: %w", err) + } + + // Overwrite the response's usage with the cumulative usage across any inner loops which invokes injected MCP tools. + sj, err = sjson.Set(sj, "usage", cumulativeUsage) + if err != nil { + return xerrors.Errorf("marshal response usage failed: %w", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(sj)) + + return nil +} + +func (i *BlockingInterception) newMessage(ctx context.Context, svc anthropic.MessageService) (_ *anthropic.Message, outErr error) { + ctx, span := i.tracer.Start(ctx, "Intercept.ProcessRequest.Upstream", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...)) + defer tracing.EndSpanErr(span, &outErr) + + return svc.New(ctx, anthropic.MessageNewParams{}, i.withBody()) +} diff --git a/aibridge/intercept/messages/reqpayload.go b/aibridge/intercept/messages/reqpayload.go new file mode 100644 index 0000000000..293dca0c7f --- /dev/null +++ b/aibridge/intercept/messages/reqpayload.go @@ -0,0 +1,412 @@ +package messages + +import ( + "bytes" + "encoding/json" + "net/http" + "slices" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/shared/constant" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "golang.org/x/xerrors" +) + +const ( + // Absolute JSON paths from the request root. + messagesReqPathMessages = "messages" + messagesReqPathMaxTokens = "max_tokens" + messagesReqPathModel = "model" + messagesReqPathOutputConfig = "output_config" + messagesReqPathOutputConfigEffort = "output_config.effort" + messagesReqPathMetadata = "metadata" + messagesReqPathServiceTier = "service_tier" + messagesReqPathContainer = "container" + messagesReqPathInferenceGeo = "inference_geo" + messagesReqPathContextManagement = "context_management" + messagesReqPathStream = "stream" + messagesReqPathThinking = "thinking" + messagesReqPathThinkingBudgetTokens = "thinking.budget_tokens" + messagesReqPathThinkingType = "thinking.type" + messagesReqPathToolChoice = "tool_choice" + messagesReqPathToolChoiceDisableParallel = "tool_choice.disable_parallel_tool_use" + messagesReqPathToolChoiceType = "tool_choice.type" + messagesReqPathTools = "tools" + + // Relative field names used within sub-objects. + messagesReqFieldContent = "content" + messagesReqFieldRole = "role" + messagesReqFieldText = "text" + messagesReqFieldToolUseID = "tool_use_id" + messagesReqFieldType = "type" +) + +const ( + constAdaptive = "adaptive" + constDisabled = "disabled" + constEnabled = "enabled" +) + +var ( + constAny = string(constant.ValueOf[constant.Any]()) + constAuto = string(constant.ValueOf[constant.Auto]()) + constNone = string(constant.ValueOf[constant.None]()) + constText = string(constant.ValueOf[constant.Text]()) + constTool = string(constant.ValueOf[constant.Tool]()) + constToolResult = string(constant.ValueOf[constant.ToolResult]()) + constUser = string(anthropic.MessageParamRoleUser) + + // bedrockUnsupportedFields are top-level fields present in the Anthropic Messages + // API that are absent from the Bedrock request body schema. Sending them results + // in a 400 "Extra inputs are not permitted" error. + // + // Anthropic API fields: https://platform.claude.com/docs/en/api/messages/create + // Bedrock request body: https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-anthropic-claude-messages-request-response.html + bedrockUnsupportedFields = []string{ + messagesReqPathMetadata, + messagesReqPathServiceTier, + messagesReqPathContainer, + messagesReqPathInferenceGeo, + } + + // bedrockBetaGatedFields maps body fields to the beta flag that enables them. + // If the beta flag is present in the (already-filtered) Anthropic-Beta header, + // the field is kept; otherwise it is stripped. Model-specific beta flags must + // be removed from the header before this check (see filterBedrockBetaFlags). + bedrockBetaGatedFields = map[string]string{ + // output_config requires the effort beta (Opus 4.5 only). + messagesReqPathOutputConfig: "effort-2025-11-24", + // context_management requires the context-management beta (Sonnet 4.5, Haiku 4.5). + messagesReqPathContextManagement: "context-management-2025-06-27", + } +) + +// RequestPayload is raw JSON bytes of an Anthropic Messages API request. +// Methods provide package-specific reads and rewrites while preserving the +// original body for upstream pass-through. +type RequestPayload []byte + +func NewRequestPayload(raw []byte) (RequestPayload, error) { + if len(bytes.TrimSpace(raw)) == 0 { + return nil, xerrors.New("messages empty request body") + } + if !json.Valid(raw) { + return nil, xerrors.New("messages invalid JSON request body") + } + + return RequestPayload(raw), nil +} + +func (p RequestPayload) Stream() bool { + v := gjson.GetBytes(p, messagesReqPathStream) + if !v.IsBool() { + return false + } + return v.Bool() +} + +func (p RequestPayload) model() string { + return gjson.GetBytes(p, messagesReqPathModel).Str +} + +func (p RequestPayload) correlatingToolCallID() *string { + messages := gjson.GetBytes(p, messagesReqPathMessages) + if !messages.IsArray() { + return nil + } + + messageItems := messages.Array() + if len(messageItems) == 0 { + return nil + } + + content := messageItems[len(messageItems)-1].Get(messagesReqFieldContent) + if !content.IsArray() { + return nil + } + + contentItems := content.Array() + for idx := len(contentItems) - 1; idx >= 0; idx-- { + contentItem := contentItems[idx] + if contentItem.Get(messagesReqFieldType).String() != constToolResult { + continue + } + + toolUseID := contentItem.Get(messagesReqFieldToolUseID).String() + if toolUseID == "" { + continue + } + + return &toolUseID + } + + return nil +} + +// lastUserPrompt returns the prompt text from the last user message. If no prompt +// is found, it returns empty string, false, nil. Unexpected shapes are treated as +// unsupported and do not fail the request path. +func (p RequestPayload) lastUserPrompt() (string, bool, error) { + messages := gjson.GetBytes(p, messagesReqPathMessages) + if !messages.Exists() || messages.Type == gjson.Null { + return "", false, nil + } + if !messages.IsArray() { + return "", false, xerrors.Errorf("unexpected messages type: %s", messages.Type) + } + + messageItems := messages.Array() + if len(messageItems) == 0 { + return "", false, nil + } + + lastMessage := messageItems[len(messageItems)-1] + if lastMessage.Get(messagesReqFieldRole).String() != constUser { + return "", false, nil + } + + content := lastMessage.Get(messagesReqFieldContent) + if !content.Exists() || content.Type == gjson.Null { + return "", false, nil + } + if content.Type == gjson.String { + return content.String(), true, nil + } + if !content.IsArray() { + return "", false, xerrors.Errorf("unexpected message content type: %s", content.Type) + } + + contentItems := content.Array() + for idx := len(contentItems) - 1; idx >= 0; idx-- { + contentItem := contentItems[idx] + if contentItem.Get(messagesReqFieldType).String() != constText { + continue + } + + text := contentItem.Get(messagesReqFieldText) + if text.Type != gjson.String { + continue + } + + return text.String(), true, nil + } + + return "", false, nil +} + +func (p RequestPayload) injectTools(injected []anthropic.ToolUnionParam) (RequestPayload, error) { + if len(injected) == 0 { + return p, nil + } + + existing, err := p.tools() + if err != nil { + return p, xerrors.Errorf("get existing tools: %w", err) + } + + // Using []json.Marshaler to merge differently-typed slices ([]anthropic.ToolUnionParam + // and []json.Marshaler containing json.RawMessage) keeps JSON re-marshalings to a minimum: + // sjson.SetBytes marshals each element exactly once, and json.RawMessage + // elements are passed through without re-serialization. + allTools := make([]json.Marshaler, 0, len(injected)+len(existing)) + for _, tool := range injected { + allTools = append(allTools, tool) + } + + for _, e := range existing { + allTools = append(allTools, e) + } + + return p.set(messagesReqPathTools, allTools) +} + +func (p RequestPayload) disableParallelToolCalls() (RequestPayload, error) { + toolChoice := gjson.GetBytes(p, messagesReqPathToolChoice) + + // If no tool_choice was defined, assume auto. + // See https://platform.claude.com/docs/en/agents-and-tools/tool-use/implement-tool-use#parallel-tool-use. + if !toolChoice.Exists() || toolChoice.Type == gjson.Null { + updated, err := p.set(messagesReqPathToolChoiceType, constAuto) + if err != nil { + return p, xerrors.Errorf("set tool choice type: %w", err) + } + return updated.set(messagesReqPathToolChoiceDisableParallel, true) + } + if !toolChoice.IsObject() { + return p, xerrors.Errorf("unsupported tool_choice type: %s", toolChoice.Type) + } + + toolChoiceType := gjson.GetBytes(p, messagesReqPathToolChoiceType) + if toolChoiceType.Exists() && toolChoiceType.Type != gjson.String { + return p, xerrors.Errorf("unsupported tool_choice.type type: %s", toolChoiceType.Type) + } + + switch toolChoiceType.String() { + case "": + updated, err := p.set(messagesReqPathToolChoiceType, constAuto) + if err != nil { + return p, xerrors.Errorf("set tool_choice.type: %w", err) + } + return updated.set(messagesReqPathToolChoiceDisableParallel, true) + case constAuto, constAny, constTool: + return p.set(messagesReqPathToolChoiceDisableParallel, true) + case constNone: + return p, nil + default: + return p, xerrors.Errorf("unsupported tool_choice.type value: %q", toolChoiceType.String()) + } +} + +func (p RequestPayload) appendedMessages(newMessages []anthropic.MessageParam) (RequestPayload, error) { + if len(newMessages) == 0 { + return p, nil + } + + existing, err := p.messages() + if err != nil { + return p, xerrors.Errorf("get existing messages: %w", err) + } + + // Using []json.Marshaler to merge differently-typed slices ([]json.Marshaler containing + // json.RawMessage and []anthropic.MessageParam) keeps JSON re-marshalings + // to a minimum: sjson.SetBytes marshals each element exactly once, and + // json.RawMessage elements are passed through without re-serialization. + allMessages := make([]json.Marshaler, 0, len(existing)+len(newMessages)) + + for _, e := range existing { + allMessages = append(allMessages, e) + } + + for _, new := range newMessages { + allMessages = append(allMessages, new) + } + + return p.set(messagesReqPathMessages, allMessages) +} + +func (p RequestPayload) withModel(model string) (RequestPayload, error) { + return p.set(messagesReqPathModel, model) +} + +func (p RequestPayload) messages() ([]json.RawMessage, error) { + messages := gjson.GetBytes(p, messagesReqPathMessages) + if !messages.Exists() || messages.Type == gjson.Null { + return nil, nil + } + if !messages.IsArray() { + return nil, xerrors.Errorf("unsupported messages type: %s", messages.Type) + } + + return p.resultToRawMessage(messages.Array()), nil +} + +func (p RequestPayload) tools() ([]json.RawMessage, error) { + tools := gjson.GetBytes(p, messagesReqPathTools) + if !tools.Exists() || tools.Type == gjson.Null { + return nil, nil + } + if !tools.IsArray() { + return nil, xerrors.Errorf("unsupported tools type: %s", tools.Type) + } + + return p.resultToRawMessage(tools.Array()), nil +} + +func (RequestPayload) resultToRawMessage(items []gjson.Result) []json.RawMessage { + // gjson.Result conversion to json.RawMessage is needed because + // gjson.Result does not implement json.Marshaler. It would + // serialize its struct fields instead of the raw JSON it represents. + rawMessages := make([]json.RawMessage, 0, len(items)) + for _, item := range items { + rawMessages = append(rawMessages, json.RawMessage(item.Raw)) + } + return rawMessages +} + +// convertAdaptiveThinkingForBedrock converts thinking.type "adaptive" to "enabled" with a calculated budget_tokens +// conversion is needed for Bedrock models that does not support the "adaptive" thinking.type +func (p RequestPayload) convertAdaptiveThinkingForBedrock() (RequestPayload, error) { + thinkingType := gjson.GetBytes(p, messagesReqPathThinkingType) + if thinkingType.String() != constAdaptive { + return p, nil + } + + maxTokens := gjson.GetBytes(p, messagesReqPathMaxTokens).Int() + if maxTokens <= 0 { + // max_tokens is required by messages API + return p, xerrors.New("max_tokens: field required") + } + + effort := gjson.GetBytes(p, messagesReqPathOutputConfigEffort).String() + + // Enabled thinking type requires budget_tokens set. + // Heuristically calculate value based on the effort level. + // Effort-to-ratio mapping adapted from OpenRouter: + // https://openrouter.ai/docs/guides/best-practices/reasoning-tokens#reasoning-effort-level + var ratio float64 + switch effort { + case "low": + ratio = 0.2 + case "medium": + ratio = 0.5 + case "max": + ratio = 0.95 + default: // "high" or absent (high is the default effort) + ratio = 0.8 + } + + // budget_tokens must be ≥ 1024 && < max_tokens. If the calculated budget + // doesn't meet the minimum, disable thinking entirely rather than forcing + // an artificially high budget that would starve the output. + // https://platform.claude.com/docs/en/api/messages/create#create.thinking + // https://platform.claude.com/docs/en/build-with-claude/extended-thinking#how-to-use-extended-thinking + budgetTokens := int64(float64(maxTokens) * ratio) + if budgetTokens < 1024 { + return p.set(messagesReqPathThinking, map[string]string{"type": constDisabled}) + } + + return p.set(messagesReqPathThinking, map[string]any{ + "type": constEnabled, + "budget_tokens": budgetTokens, + }) +} + +// removeUnsupportedBedrockFields strips top-level fields that Bedrock does not +// support from the payload. Fields that are gated behind a beta flag are only +// removed when the corresponding flag is absent from the Anthropic-Beta header. +// Model-specific beta flags must already be filtered from the header before +// calling this method (see filterBedrockBetaFlags). +func (p RequestPayload) removeUnsupportedBedrockFields(headers http.Header) (RequestPayload, error) { + var payloadMap map[string]any + if err := json.Unmarshal(p, &payloadMap); err != nil { + return p, xerrors.Errorf("failed to unmarshal request payload when removing unsupported Bedrock fields: %w", err) + } + + // Always strip unconditionally unsupported fields. + for _, field := range bedrockUnsupportedFields { + delete(payloadMap, field) + } + + // Strip beta-gated fields only when their beta flag is missing. + betaValues := headers.Values("Anthropic-Beta") + for field, requiredFlag := range bedrockBetaGatedFields { + if !slices.Contains(betaValues, requiredFlag) { + delete(payloadMap, field) + } + } + + result, err := json.Marshal(payloadMap) + if err != nil { + return p, xerrors.Errorf("failed to marshal request payload when removing unsupported Bedrock fields: %w", err) + } + return RequestPayload(result), nil +} + +func (p RequestPayload) set(path string, value any) (RequestPayload, error) { + out, err := sjson.SetBytes(p, path, value) + if err != nil { + return p, xerrors.Errorf("set %s: %w", path, err) + } + return RequestPayload(out), nil +} diff --git a/aibridge/intercept/messages/reqpayload_test.go b/aibridge/intercept/messages/reqpayload_test.go new file mode 100644 index 0000000000..d7cf8ba9b1 --- /dev/null +++ b/aibridge/intercept/messages/reqpayload_test.go @@ -0,0 +1,477 @@ +package messages //nolint:testpackage // tests unexported internals + +import ( + "testing" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/shared/constant" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/coder/coder/v2/aibridge/utils" +) + +func TestNewRequestPayload(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + + requestBody []byte + + expectError bool + }{ + { + name: "empty body", + requestBody: []byte(" \n\t "), + expectError: true, + }, + { + name: "invalid json", + requestBody: []byte(`{"model":`), + expectError: true, + }, + { + name: "valid json", + requestBody: []byte(`{"model":"claude-opus-4-5","max_tokens":1024}`), + expectError: false, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + payload, err := NewRequestPayload(testCase.requestBody) + if testCase.expectError { + require.Error(t, err) + require.Nil(t, payload) + return + } + + require.NoError(t, err) + require.Equal(t, RequestPayload(testCase.requestBody), payload) + }) + } +} + +func TestRequestPayloadStream(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + + requestBody string + + expectedStream bool + }{ + { + name: "stream true", + requestBody: `{"stream":true}`, + expectedStream: true, + }, + { + name: "stream false", + requestBody: `{"stream":false}`, + expectedStream: false, + }, + { + name: "stream missing", + requestBody: `{}`, + expectedStream: false, + }, + { + name: "stream wrong type", + requestBody: `{"stream":"true"}`, + expectedStream: false, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + payload := mustMessagesPayload(t, testCase.requestBody) + require.Equal(t, testCase.expectedStream, payload.Stream()) + }) + } +} + +func TestRequestPayloadModel(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + requestBody string + expectedModel string + }{ + { + name: "model present", + requestBody: `{"model":"claude-opus-4-5"}`, + expectedModel: "claude-opus-4-5", + }, + { + name: "model missing", + requestBody: `{}`, + expectedModel: "", + }, + { + name: "model wrong type", + requestBody: `{"model":123}`, + expectedModel: "", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + payload := mustMessagesPayload(t, testCase.requestBody) + require.Equal(t, testCase.expectedModel, payload.model()) + }) + } +} + +func TestRequestPayloadLastUserPrompt(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + + requestBody string + + expectedPrompt string + + expectedFound bool + + expectError bool + }{ + { + name: "last user message string content", + requestBody: `{"model":"claude-opus-4-5","max_tokens":1024,"messages":[{"role":"user","content":"hello"}]}`, + expectedPrompt: "hello", + expectedFound: true, + expectError: false, + }, + { + name: "last user message typed content returns last text block", + requestBody: `{"model":"claude-opus-4-5","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}},{"type":"text","text":"first"},{"type":"text","text":"last"}]}]}`, + expectedPrompt: "last", + expectedFound: true, + expectError: false, + }, + { + name: "last message not from user", + requestBody: `{"model":"claude-opus-4-5","max_tokens":1024,"messages":[{"role":"assistant","content":"hello"}]}`, + expectedPrompt: "", + expectedFound: false, + expectError: false, + }, + { + name: "no messages key", + requestBody: `{"model":"claude-opus-4-5","max_tokens":1024}`, + expectedPrompt: "", + expectedFound: false, + expectError: false, + }, + { + name: "empty messages array", + requestBody: `{"model":"claude-opus-4-5","max_tokens":1024,"messages":[]}`, + expectedPrompt: "", + expectedFound: false, + expectError: false, + }, + { + name: "last user message with empty content array", + requestBody: `{"model":"claude-opus-4-5","max_tokens":1024,"messages":[{"role":"user","content":[]}]}`, + expectedPrompt: "", + expectedFound: false, + expectError: false, + }, + { + name: "last user message with only non text content", + requestBody: `{"model":"claude-opus-4-5","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"image","source":{"type":"base64","media_type":"image/png","data":"abc"}},{"type":"image","source":{"type":"base64","media_type":"image/jpeg","data":"def"}}]}]}`, + expectedPrompt: "", + expectedFound: false, + expectError: false, + }, + { + name: "multiple messages with last being user", + requestBody: `{"model":"claude-opus-4-5","max_tokens":1024,"messages":[{"role":"user","content":"first"},{"role":"assistant","content":[{"type":"text","text":"response"}]},{"role":"user","content":"second"}]}`, + expectedPrompt: "second", + expectedFound: true, + expectError: false, + }, + { + name: "messages wrong type returns error", + requestBody: `{"model":"claude-opus-4-5","max_tokens":1024,"messages":{}}`, + expectedPrompt: "", + expectedFound: false, + expectError: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + payload := mustMessagesPayload(t, testCase.requestBody) + prompt, found, err := payload.lastUserPrompt() + if testCase.expectError { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, testCase.expectedFound, found) + require.Equal(t, testCase.expectedPrompt, prompt) + }) + } +} + +func TestRequestPayloadCorrelatingToolCallID(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + + requestBody string + + expectedToolUseID *string + }{ + { + name: "no tool result block", + requestBody: `{"model":"claude-opus-4-5","max_tokens":1024,"messages":[{"role":"user","content":"hello"}]}`, + expectedToolUseID: nil, + }, + { + name: "returns last tool result from final message", + requestBody: `{"model":"claude-opus-4-5","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_first","content":"first"},{"type":"tool_result","tool_use_id":"toolu_second","content":"second"}]}]}`, + expectedToolUseID: utils.PtrTo("toolu_second"), + }, + { + name: "ignores earlier message tool result", + requestBody: `{"model":"claude-opus-4-5","max_tokens":1024,"messages":[{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_first","content":"first"}]},{"role":"assistant","content":"done"}]}`, + expectedToolUseID: nil, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + payload := mustMessagesPayload(t, testCase.requestBody) + require.Equal(t, testCase.expectedToolUseID, payload.correlatingToolCallID()) + }) + } +} + +func TestRequestPayloadInjectTools(t *testing.T) { + t.Parallel() + + payload := mustMessagesPayload(t, `{"model":"claude-opus-4-5","max_tokens":1024,"messages":[{"role":"user","content":"hello"}],"tools":[{"name":"existing_tool","type":"custom","input_schema":{"type":"object","properties":{}},"cache_control":{"type":"ephemeral"}}]}`) + + updatedPayload, err := payload.injectTools([]anthropic.ToolUnionParam{ + { + OfTool: &anthropic.ToolParam{ + Name: "injected_tool", + Type: anthropic.ToolTypeCustom, + InputSchema: anthropic.ToolInputSchemaParam{ + Properties: map[string]interface{}{}, + }, + }, + }, + }) + require.NoError(t, err) + + toolItems := gjson.GetBytes(updatedPayload, "tools").Array() + require.Len(t, toolItems, 2) + require.Equal(t, "injected_tool", toolItems[0].Get("name").String()) + require.Equal(t, "existing_tool", toolItems[1].Get("name").String()) + require.Equal(t, "ephemeral", toolItems[1].Get("cache_control.type").String()) +} + +func TestRequestPayloadConvertAdaptiveThinkingForBedrock(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + + requestBody string + + expectedThinkingType string + expectedBudgetTokens int64 + expectError bool + }{ + { + name: "no_thinking_field_is_no_op", + requestBody: `{"model":"claude-sonnet-4-5","max_tokens":10000,"messages":[]}`, + expectedThinkingType: "", + }, + { + name: "non_adaptive_thinking_type_is_no_op", + requestBody: `{"model":"claude-sonnet-4-5","max_tokens":10000,"thinking":{"type":"enabled","budget_tokens":5000},"messages":[]}`, + expectedThinkingType: "enabled", + expectedBudgetTokens: 5000, + }, + { + name: "adaptive_with_no_effort_defaults_to_80%", + requestBody: `{"model":"claude-sonnet-4-5","max_tokens":10000,"thinking":{"type":"adaptive"},"messages":[]}`, + expectedThinkingType: "enabled", + expectedBudgetTokens: 8000, // 10000 * 0.8 (default/high effort) + }, + { + name: "adaptive_with_explicit_effort_uses_correct_percentage", + requestBody: `{"model":"claude-sonnet-4-5","max_tokens":10000,"thinking":{"type":"adaptive"},"output_config":{"effort":"low"},"messages":[]}`, + expectedThinkingType: "enabled", + expectedBudgetTokens: 2000, // 10000 * 0.2 + }, + { + name: "adaptive_disables_thinking_when_budget_below_minimum", + requestBody: `{"model":"claude-sonnet-4-5","max_tokens":512,"thinking":{"type":"adaptive"},"messages":[]}`, + expectedThinkingType: "disabled", // 512 * 0.8 = 409, below 1024 minimum + }, + { + name: "adaptive_without_max_tokens_returns_error", + requestBody: `{"model":"claude-sonnet-4-5","thinking":{"type":"adaptive"},"messages":[]}`, + expectError: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + payload := mustMessagesPayload(t, tc.requestBody) + updatedPayload, err := payload.convertAdaptiveThinkingForBedrock() + if tc.expectError { + require.Error(t, err) + return + } + require.NoError(t, err) + + thinking := gjson.GetBytes(updatedPayload, messagesReqPathThinking) + require.NotEqual(t, tc.expectedThinkingType == "", thinking.Exists(), "thinking should not be set") + require.Equal(t, tc.expectedThinkingType, gjson.GetBytes(updatedPayload, messagesReqPathThinkingType).String()) // non existing field returns zero value + + budgetTokens := gjson.GetBytes(updatedPayload, messagesReqPathThinkingBudgetTokens) + require.NotEqual(t, tc.expectedBudgetTokens == 0, budgetTokens.Exists(), "budget_tokens should not be set") + require.Equal(t, tc.expectedBudgetTokens, budgetTokens.Int()) // non existing field returns zero value + }) + } +} + +func TestRequestPayloadDisableParallelToolCalls(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + requestBody string + expectError string + expectedType string + expectedDisableParallel *bool + }{ + { + name: "defaults to auto when missing", + requestBody: `{"model":"claude-opus-4-5","max_tokens":1024}`, + expectedType: string(constant.ValueOf[constant.Auto]()), + expectedDisableParallel: utils.PtrTo(true), + }, + { + name: "auto gets disabled", + requestBody: `{"tool_choice":{"type":"auto"}}`, + expectedType: string(constant.ValueOf[constant.Auto]()), + expectedDisableParallel: utils.PtrTo(true), + }, + { + name: "any gets disabled", + requestBody: `{"tool_choice":{"type":"any"}}`, + expectedType: string(constant.ValueOf[constant.Any]()), + expectedDisableParallel: utils.PtrTo(true), + }, + { + name: "tool gets disabled", + requestBody: `{"tool_choice":{"type":"tool","name":"abc"}}`, + expectedType: string(constant.ValueOf[constant.Tool]()), + expectedDisableParallel: utils.PtrTo(true), + }, + { + name: "none remains unchanged", + requestBody: `{"tool_choice":{"type":"none"}}`, + expectedType: string(constant.ValueOf[constant.None]()), + expectedDisableParallel: nil, + }, + { + name: "empty type defaults to auto", + requestBody: `{"tool_choice":{}}`, + expectedType: string(constant.ValueOf[constant.Auto]()), + expectedDisableParallel: utils.PtrTo(true), + }, + { + name: "non-object tool_choice returns error", + requestBody: `{"tool_choice":"auto"}`, + expectError: "unsupported tool_choice type", + }, + { + name: "non-string tool_choice type returns error", + requestBody: `{"tool_choice":{"type":123}}`, + expectError: "unsupported tool_choice.type type", + }, + { + name: "unsupported tool_choice type returns error", + requestBody: `{"tool_choice":{"type":"unknown"}}`, + expectError: "unsupported tool_choice.type value", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + payload := mustMessagesPayload(t, testCase.requestBody) + updatedPayload, err := payload.disableParallelToolCalls() + if testCase.expectError != "" { + require.ErrorContains(t, err, testCase.expectError) + return + } + require.NoError(t, err) + + toolChoice := gjson.GetBytes(updatedPayload, "tool_choice") + require.Equal(t, testCase.expectedType, toolChoice.Get("type").String()) + + disableParallelResult := toolChoice.Get("disable_parallel_tool_use") + if testCase.expectedDisableParallel == nil { + require.False(t, disableParallelResult.Exists()) + return + } + + require.True(t, disableParallelResult.Exists()) + require.Equal(t, *testCase.expectedDisableParallel, disableParallelResult.Bool()) + }) + } +} + +func TestRequestPayloadAppendedMessages(t *testing.T) { + t.Parallel() + + payload := mustMessagesPayload(t, `{"model":"claude-opus-4-5","max_tokens":1024,"messages":[{"role":"user","content":"hello"}]}`) + + updatedPayload, err := payload.appendedMessages([]anthropic.MessageParam{ + { + Role: anthropic.MessageParamRoleAssistant, + Content: []anthropic.ContentBlockParamUnion{ + anthropic.NewTextBlock("assistant response"), + }, + }, + anthropic.NewUserMessage(anthropic.NewToolResultBlock("toolu_123", "tool output", false)), + }) + require.NoError(t, err) + + messageItems := gjson.GetBytes(updatedPayload, "messages").Array() + require.Len(t, messageItems, 3) + require.Equal(t, "hello", messageItems[0].Get("content").String()) + require.Equal(t, "assistant", messageItems[1].Get("role").String()) + require.Equal(t, "assistant response", messageItems[1].Get("content.0.text").String()) + require.Equal(t, "tool_result", messageItems[2].Get("content.0.type").String()) + require.Equal(t, "toolu_123", messageItems[2].Get("content.0.tool_use_id").String()) +} diff --git a/aibridge/intercept/messages/streaming.go b/aibridge/intercept/messages/streaming.go new file mode 100644 index 0000000000..881e62dad5 --- /dev/null +++ b/aibridge/intercept/messages/streaming.go @@ -0,0 +1,593 @@ +package messages + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" + "github.com/anthropics/anthropic-sdk-go/packages/ssestream" + "github.com/anthropics/anthropic-sdk-go/shared/constant" + "github.com/google/uuid" + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/tidwall/sjson" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/intercept/eventstream" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/recorder" + "github.com/coder/coder/v2/aibridge/tracing" + "github.com/coder/quartz" +) + +type StreamingInterception struct { + interceptionBase +} + +func NewStreamingInterceptor( + id uuid.UUID, + reqPayload RequestPayload, + providerName string, + cfg config.Anthropic, + bedrockCfg *config.AWSBedrock, + clientHeaders http.Header, + authHeaderName string, + tracer trace.Tracer, + cred intercept.CredentialInfo, +) *StreamingInterception { + return &StreamingInterception{interceptionBase: interceptionBase{ + id: id, + providerName: providerName, + reqPayload: reqPayload, + cfg: cfg, + bedrockCfg: bedrockCfg, + clientHeaders: clientHeaders, + authHeaderName: authHeaderName, + tracer: tracer, + credential: cred, + }} +} + +func (i *StreamingInterception) Setup(logger slog.Logger, rec recorder.Recorder, mcpProxy mcp.ServerProxier) { + i.interceptionBase.Setup(logger.Named("streaming"), rec, mcpProxy) +} + +func (*StreamingInterception) Streaming() bool { + return true +} + +func (i *StreamingInterception) TraceAttributes(r *http.Request) []attribute.KeyValue { + return i.interceptionBase.baseTraceAttributes(r, true) +} + +// ProcessRequest handles a request to /v1/messages. +// This API has a state-machine behind it, which is described in https://docs.claude.com/en/docs/build-with-claude/streaming#event-types. +// +// Each stream uses the following event flow: +// - `message_start`: contains a Message object with empty content. +// - A series of content blocks, each of which have a `content_block_start`, one or more `content_block_delta` events, and a `content_block_stop` event. +// - Each content block will have an index that corresponds to its index in the final Message content array. +// - One or more `message_delta` events, indicating top-level changes to the final Message object. +// - A final `message_stop` event. +// +// It will inject any tools which have been provided by the [mcp.ServerProxier]. +// +// When a response from the server includes an event indicating that a tool must be invoked, a conditional +// flow takes place: +// +// a) if the tool is not injected (i.e. defined by the client), relay the event unmodified +// b) if the tool is injected, it will be invoked by the [mcp.ServerProxier] in the remote MCP server, and its +// results relayed to the SERVER. The response from the server will be handled synchronously, and this loop +// can continue until all injected tool invocations are completed and the response is relayed to the client. +func (i *StreamingInterception) ProcessRequest(w http.ResponseWriter, r *http.Request) (outErr error) { + if len(i.reqPayload) == 0 { + return xerrors.New("developer error: request payload is empty") + } + + ctx, span := i.tracer.Start(r.Context(), "Intercept.ProcessRequest", trace.WithAttributes(tracing.InterceptionAttributesFromContext(r.Context())...)) + defer tracing.EndSpanErr(span, &outErr) + + // Allow us to interrupt watch via cancel. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + r = r.WithContext(ctx) // Rewire context for SSE cancellation. + + logger := i.logger.With(slog.F("model", i.Model())) + + var ( + prompt string + promptFound bool + err error + ) + + prompt, promptFound, err = i.reqPayload.lastUserPrompt() + if err != nil { + logger.Warn(ctx, "failed to determine last user prompt", slog.Error(err)) + } + + // Claude Code uses a "small/fast model" for certain tasks. + if !i.isSmallFastModel() { + // Only inject tools into "actual" request. + i.injectTools() + } + + streamCtx, streamCancel := context.WithCancelCause(ctx) + defer streamCancel(xerrors.New("deferred")) + + // TODO(ssncferreira): inject actor headers directly in the client-header + // middleware instead of using SDK options. + var opts []option.RequestOption + if actor := aibcontext.ActorFromContext(ctx); actor != nil && i.cfg.SendActorHeaders { + opts = append(opts, intercept.ActorHeadersAsAnthropicOpts(actor)...) + } + + svc, err := i.newMessagesService(streamCtx, opts...) + if err != nil { + err = xerrors.Errorf("create anthropic client: %w", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return err + } + + // events will either terminate when shutdown after interaction with upstream completes, or when streamCtx is done. + events := eventstream.NewEventStream(streamCtx, logger.Named("sse-sender"), i.pingPayload(), quartz.NewReal()) + go events.Start(w, r) + defer func() { + _ = events.Shutdown(streamCtx) // Catch-all in case it doesn't get shutdown after stream completes. + }() + + // Accumulate usage across the entire streaming interaction (including tool reinvocations). + var cumulativeUsage anthropic.Usage + + var lastErr error + var interceptionErr error + + isFirst := true +newStream: + for { + // TODO add outer loop span (https://github.com/coder/aibridge/issues/67) + if err := streamCtx.Err(); err != nil { + interceptionErr = xerrors.Errorf("stream exit: %w", err) + break + } + + stream := i.newStream(streamCtx, svc) + + var message anthropic.Message + var lastToolName string + + pendingToolCalls := make(map[string]string) + + for stream.Next() { + event := stream.Current() + if err := message.Accumulate(event); err != nil { + logger.Warn(ctx, "failed to accumulate streaming events", slog.Error(err), slog.F("event", event), slog.F("msg", message.RawJSON())) + lastErr = xerrors.Errorf("accumulate event: %w", err) + break + } + + // Tool-related handling. + switch event.Type { + case string(constant.ValueOf[constant.ContentBlockStart]()): + if block, ok := event.AsContentBlockStart().ContentBlock.AsAny().(anthropic.ToolUseBlock); ok { + lastToolName = block.Name + + if i.mcpProxy != nil && i.mcpProxy.GetTool(block.Name) != nil { + pendingToolCalls[block.Name] = block.ID + // Don't relay this event back, otherwise the client will try invoke the tool as well. + continue + } + } + case string(constant.ValueOf[constant.ContentBlockDelta]()): + if len(pendingToolCalls) > 0 && i.mcpProxy != nil && i.mcpProxy.GetTool(lastToolName) != nil { + // We're busy with a tool call, don't relay this event back. + continue + } + case string(constant.ValueOf[constant.ContentBlockStop]()): + // Reset the tool name + isInjected := i.mcpProxy != nil && i.mcpProxy.GetTool(lastToolName) != nil + lastToolName = "" + + if len(pendingToolCalls) > 0 && isInjected { + // We're busy with a tool call, don't relay this event back. + continue + } + case string(constant.ValueOf[constant.MessageStart]()): + start := event.AsMessageStart() + accumulateUsage(&cumulativeUsage, start.Message.Usage) + + _ = i.recorder.RecordTokenUsage(streamCtx, &recorder.TokenUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: message.ID, + Input: start.Message.Usage.InputTokens, + Output: start.Message.Usage.OutputTokens, + CacheReadInputTokens: start.Message.Usage.CacheReadInputTokens, + CacheWriteInputTokens: start.Message.Usage.CacheCreationInputTokens, + ExtraTokenTypes: map[string]int64{ + "web_search_requests": start.Message.Usage.ServerToolUse.WebSearchRequests, + "cache_creation_input": start.Message.Usage.CacheCreationInputTokens, // TODO: remove from ExtraTokenTypes (https://github.com/coder/aibridge/issues/243) + "cache_read_input": start.Message.Usage.CacheReadInputTokens, // TODO: remove from ExtraTokenTypes (https://github.com/coder/aibridge/issues/243) + "cache_ephemeral_1h_input": start.Message.Usage.CacheCreation.Ephemeral1hInputTokens, + "cache_ephemeral_5m_input": start.Message.Usage.CacheCreation.Ephemeral5mInputTokens, + }, + }) + + if !isFirst { + // Don't send message_start unless first message! + // We're sending multiple messages back and forth with the API, but from the client's perspective + // they're just expecting a single message. + continue + } + case string(constant.ValueOf[constant.MessageDelta]()): + delta := event.AsMessageDelta() + accumulateUsage(&cumulativeUsage, delta.Usage) + + // Only output tokens should change in message_delta. + _ = i.recorder.RecordTokenUsage(streamCtx, &recorder.TokenUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: message.ID, + Output: delta.Usage.OutputTokens, + }) + + // Don't relay message_delta events which indicate injected tool use. + if len(pendingToolCalls) > 0 && i.mcpProxy != nil && i.mcpProxy.GetTool(lastToolName) != nil { + continue + } + + // If currently calling a tool. + if len(message.Content) > 0 && message.Content[len(message.Content)-1].Type == string(constant.ValueOf[constant.ToolUse]()) { + toolName := message.Content[len(message.Content)-1].AsToolUse().Name + if len(pendingToolCalls) > 0 && i.mcpProxy != nil && i.mcpProxy.GetTool(toolName) != nil { + continue + } + } + + // We should be updating the event's usage to the calculated cumulative usage. However... + // the SDK only accumulates output tokens on message_delta, since that's all that *should* change. + // + // Backstory: the API reports tokens during message_start AND message_delta. message_start reports the input + // tokens and others, while the delta should only report changes to output tokens. + // HOWEVER, when we invoke injected tools we're starting a whole new message (and subsequently receive + // message_start and message_delta events), and the previous message_start has already been relayed, so in effect + // we can't really modify anything other than output tokens here according to the SDK. + // This will affect how the client reports token usage for input tokens, for example. + // For our purposes, the server (aibridge) is authoritative anyway so it's not a big deal, but this is something to note. + // + // See https://github.com/anthropics/anthropic-sdk-go/blob/v1.12.0/message.go#L2619-L2622 + event.Usage.OutputTokens = cumulativeUsage.OutputTokens + + // Don't send message_stop until all tools have been called. + case string(constant.ValueOf[constant.MessageStop]()): + + // Capture any thinking blocks that were returned. + for _, t := range i.extractModelThoughts(&message) { + _ = i.recorder.RecordModelThought(ctx, &recorder.ModelThoughtRecord{ + InterceptionID: i.ID().String(), + Content: t.Content, + Metadata: t.Metadata, + }) + } + + // Process injected tools. + if len(pendingToolCalls) > 0 { + // Append the whole message from this stream as context since we'll be sending a new request with the tool results. + var loopMessages []anthropic.MessageParam + loopMessages = append(loopMessages, message.ToParam()) + + for name, id := range pendingToolCalls { + if i.mcpProxy == nil { + continue + } + + if i.mcpProxy.GetTool(name) == nil { + // Not an MCP proxy call, don't do anything. + continue + } + + tool := i.mcpProxy.GetTool(name) + if tool == nil { + logger.Warn(ctx, "tool not found in manager", slog.F("tool_name", name)) + continue + } + + var ( + input json.RawMessage + foundTool bool + foundTools int + ) + for _, block := range message.Content { + if variant, ok := block.AsAny().(anthropic.ToolUseBlock); ok { + foundTools++ + if variant.Name == name { + input = variant.Input + foundTool = true + } + } + } + + if !foundTool { + logger.Warn(ctx, "failed to find tool input", slog.F("tool_name", name), slog.F("found_tools", foundTools)) + continue + } + + res, err := tool.Call(streamCtx, input, i.tracer) + + _ = i.recorder.RecordToolUsage(streamCtx, &recorder.ToolUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: message.ID, + ToolCallID: id, + ServerURL: &tool.ServerURL, + Tool: tool.Name, + Args: input, + Injected: true, + InvocationError: err, + }) + + if err != nil { + // Always provide a tool_result even if the tool call failed + loopMessages = append(loopMessages, + anthropic.NewUserMessage(anthropic.NewToolResultBlock(id, fmt.Sprintf("Error calling tool: %v", err), true)), + ) + continue + } + + // Process tool result + toolResult := anthropic.ContentBlockParamUnion{ + OfToolResult: &anthropic.ToolResultBlockParam{ + ToolUseID: id, + IsError: anthropic.Bool(false), + }, + } + + var hasValidResult bool + for _, content := range res.Content { + switch cb := content.(type) { + case mcplib.TextContent: + toolResult.OfToolResult.Content = append(toolResult.OfToolResult.Content, anthropic.ToolResultBlockParamContentUnion{ + OfText: &anthropic.TextBlockParam{ + Text: cb.Text, + }, + }) + hasValidResult = true + case mcplib.EmbeddedResource: + switch resource := cb.Resource.(type) { + case mcplib.TextResourceContents: + val := fmt.Sprintf("Binary resource (MIME: %s, URI: %s): %s", + resource.MIMEType, resource.URI, resource.Text) + toolResult.OfToolResult.Content = append(toolResult.OfToolResult.Content, anthropic.ToolResultBlockParamContentUnion{ + OfText: &anthropic.TextBlockParam{ + Text: val, + }, + }) + hasValidResult = true + case mcplib.BlobResourceContents: + val := fmt.Sprintf("Binary resource (MIME: %s, URI: %s): %s", + resource.MIMEType, resource.URI, resource.Blob) + toolResult.OfToolResult.Content = append(toolResult.OfToolResult.Content, anthropic.ToolResultBlockParamContentUnion{ + OfText: &anthropic.TextBlockParam{ + Text: val, + }, + }) + hasValidResult = true + default: + logger.Warn(ctx, "unknown embedded resource type", slog.F("type", fmt.Sprintf("%T", resource))) + toolResult.OfToolResult.Content = append(toolResult.OfToolResult.Content, anthropic.ToolResultBlockParamContentUnion{ + OfText: &anthropic.TextBlockParam{ + Text: "Error: unknown embedded resource type", + }, + }) + toolResult.OfToolResult.IsError = anthropic.Bool(true) + hasValidResult = true + } + default: + logger.Warn(ctx, "not handling non-text tool result", slog.F("type", fmt.Sprintf("%T", cb))) + toolResult.OfToolResult.Content = append(toolResult.OfToolResult.Content, anthropic.ToolResultBlockParamContentUnion{ + OfText: &anthropic.TextBlockParam{ + Text: "Error: unsupported tool result type", + }, + }) + toolResult.OfToolResult.IsError = anthropic.Bool(true) + hasValidResult = true + } + } + + // If no content was processed, still add a tool_result + if !hasValidResult { + logger.Warn(ctx, "no tool result added", slog.F("content_len", len(res.Content)), slog.F("is_error", res.IsError)) + toolResult.OfToolResult.Content = append(toolResult.OfToolResult.Content, anthropic.ToolResultBlockParamContentUnion{ + OfText: &anthropic.TextBlockParam{ + Text: "Error: no valid tool result content", + }, + }) + toolResult.OfToolResult.IsError = anthropic.Bool(true) + } + + if len(toolResult.OfToolResult.Content) > 0 { + loopMessages = append(loopMessages, anthropic.NewUserMessage(toolResult)) + } + } + + // Sync the raw payload with updated messages so that withBody() + // sends the updated payload on the next iteration. + updatedPayload, syncErr := i.reqPayload.appendedMessages(loopMessages) + if syncErr != nil { + lastErr = xerrors.Errorf("sync payload for agentic loop: %w", syncErr) + break + } + i.reqPayload = updatedPayload + + // Causes a new stream to be run with updated messages. + isFirst = false + continue newStream + } + + // Find all the non-injected tools and track their uses. + for _, block := range message.Content { + if variant, ok := block.AsAny().(anthropic.ToolUseBlock); ok { + if i.mcpProxy != nil && i.mcpProxy.GetTool(variant.Name) != nil { + continue + } + + _ = i.recorder.RecordToolUsage(streamCtx, &recorder.ToolUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: message.ID, + ToolCallID: variant.ID, + Tool: variant.Name, + Args: variant.Input, + Injected: false, + }) + } + } + } + + // Overwrite response identifier since proxy obscures injected tool call invocations. + payload, err := i.marshalEvent(event) + if err != nil { + logger.Warn(ctx, "failed to marshal event", slog.Error(err), slog.F("event", event.RawJSON())) + lastErr = xerrors.Errorf("marshal event: %w", err) + break + } + if err := events.Send(streamCtx, payload); err != nil { + if eventstream.IsUnrecoverableError(err) { + logger.Debug(ctx, "processing terminated", slog.Error(err)) + break // Stop processing if client disconnected or context canceled. + } + logger.Warn(ctx, "failed to relay event", slog.Error(err)) + lastErr = xerrors.Errorf("relay event: %w", err) + break + } + } + + if promptFound { + _ = i.recorder.RecordPromptUsage(ctx, &recorder.PromptUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: message.ID, + Prompt: prompt, + }) + prompt = "" //nolint:ineffassign // reset to prevent double-recording across newStream iterations + promptFound = false //nolint:ineffassign // reset to prevent double-recording across newStream iterations + } + + if events.IsStreaming() { + // Check if the stream encountered any errors. + if streamErr := stream.Err(); streamErr != nil { + if eventstream.IsUnrecoverableError(streamErr) { + logger.Debug(ctx, "stream terminated", slog.Error(streamErr)) + // We can't reflect an error back if there's a connection error or the request context was canceled. + } else if antErr := getErrorResponse(streamErr); antErr != nil { + logger.Warn(ctx, "anthropic stream error", slog.Error(streamErr)) + interceptionErr = antErr + } else { + logger.Warn(ctx, "unknown stream error", slog.Error(streamErr)) + // Unfortunately, the Anthropic SDK does not support parsing errors received in the stream + // into known types (i.e. [shared.OverloadedError]). + // See https://github.com/anthropics/anthropic-sdk-go/blob/v1.12.0/packages/ssestream/ssestream.go#L172-L174 + // All it does is wrap the payload in an error - which is all we can return, currently. + interceptionErr = newErrorResponse(xerrors.Errorf("unknown stream error: %w", streamErr)) + } + } else if lastErr != nil { + // Otherwise check if any logical errors occurred during processing. + logger.Warn(ctx, "stream processing failed", slog.Error(lastErr)) + interceptionErr = newErrorResponse(xerrors.Errorf("processing error: %w", lastErr)) + } + + if interceptionErr != nil { + payload, err := i.marshal(interceptionErr) + if err != nil { + logger.Warn(ctx, "failed to marshal error", slog.Error(err), slog.F("error_payload", fmt.Sprintf("%+v", interceptionErr))) + } else if err := events.Send(streamCtx, payload); err != nil { + logger.Warn(ctx, "failed to relay error", slog.Error(err), slog.F("payload", payload)) + } + } + } else { + // Stream has not started yet; write to response if present. + i.writeUpstreamError(w, getErrorResponse(stream.Err())) + } + + shutdownCtx, shutdownCancel := context.WithTimeout(ctx, time.Second*30) + // Give the events stream 30 seconds (TODO: configurable) to gracefully shutdown. + if err := events.Shutdown(shutdownCtx); err != nil { + logger.Warn(ctx, "event stream shutdown", slog.Error(err)) + } + shutdownCancel() + + // Cancel the stream context, we're now done. + if interceptionErr != nil { + streamCancel(interceptionErr) + } else { + streamCancel(xerrors.New("gracefully done")) + } + + break + } + + return interceptionErr +} + +func (i *StreamingInterception) marshalEvent(event anthropic.MessageStreamEventUnion) ([]byte, error) { + sj, err := sjson.Set(event.RawJSON(), "message.id", i.ID().String()) + if err != nil { + return nil, xerrors.Errorf("marshal event id failed: %w", err) + } + + sj, err = sjson.Set(sj, "usage.output_tokens", event.Usage.OutputTokens) + if err != nil { + return nil, xerrors.Errorf("marshal event usage failed: %w", err) + } + + return i.encodeForStream([]byte(sj), event.Type), nil +} + +func (i *StreamingInterception) marshal(payload any) ([]byte, error) { + data, err := json.Marshal(payload) + if err != nil { + return nil, xerrors.Errorf("marshal payload: %w", err) + } + + var parsed map[string]any + if err := json.Unmarshal(data, &parsed); err != nil { + return nil, xerrors.Errorf("unmarshal payload: %w", err) + } + + eventType, ok := parsed["type"].(string) + if !ok || strings.TrimSpace(eventType) == "" { + return nil, xerrors.Errorf("could not determine type from payload %q", data) + } + + return i.encodeForStream(data, eventType), nil +} + +// https://docs.anthropic.com/en/docs/build-with-claude/streaming#basic-streaming-request +func (i *StreamingInterception) pingPayload() []byte { + return i.encodeForStream([]byte(`{"type": "ping"}`), "ping") +} + +func (*StreamingInterception) encodeForStream(payload []byte, typ string) []byte { + // bytes.Buffer writes to in-memory storage and never return errors. + var buf bytes.Buffer + _, _ = buf.WriteString("event: ") + _, _ = buf.WriteString(typ) + _, _ = buf.WriteString("\n") + _, _ = buf.WriteString("data: ") + _, _ = buf.Write(payload) + _, _ = buf.WriteString("\n\n") + return buf.Bytes() +} + +// newStream traces svc.NewStreaming() call. +func (i *StreamingInterception) newStream(ctx context.Context, svc anthropic.MessageService) *ssestream.Stream[anthropic.MessageStreamEventUnion] { + _, span := i.tracer.Start(ctx, "Intercept.ProcessRequest.Upstream", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...)) + defer span.End() + + return svc.NewStreaming(ctx, anthropic.MessageNewParams{}, i.withBody()) +} diff --git a/aibridge/intercept/responses/base.go b/aibridge/intercept/responses/base.go new file mode 100644 index 0000000000..0107d2328d --- /dev/null +++ b/aibridge/intercept/responses/base.go @@ -0,0 +1,416 @@ +package responses + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" + "github.com/openai/openai-go/v3/option" + "github.com/openai/openai-go/v3/responses" + "github.com/openai/openai-go/v3/shared/constant" + "github.com/tidwall/gjson" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/intercept/apidump" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/recorder" + "github.com/coder/coder/v2/aibridge/tracing" + "github.com/coder/quartz" +) + +const ( + requestTimeout = time.Second * 600 +) + +type responsesInterceptionBase struct { + id uuid.UUID + providerName string + // clientHeaders are the original HTTP headers from the client request. + clientHeaders http.Header + authHeaderName string + reqPayload RequestPayload + + cfg config.OpenAI + recorder recorder.Recorder + mcpProxy mcp.ServerProxier + + logger slog.Logger + tracer trace.Tracer + credential intercept.CredentialInfo +} + +func (i *responsesInterceptionBase) newResponsesService() responses.ResponseService { + opts := []option.RequestOption{option.WithBaseURL(i.cfg.BaseURL), option.WithAPIKey(i.cfg.Key)} + if i.cfg.MaxRetries != nil { + opts = append(opts, option.WithMaxRetries(*i.cfg.MaxRetries)) + } + + // Add extra headers if configured. + // Some providers require additional headers that are not added by the SDK. + // TODO(ssncferreira): remove as part of https://github.com/coder/aibridge/issues/192 + for key, value := range i.cfg.ExtraHeaders { + opts = append(opts, option.WithHeader(key, value)) + } + + // Forward client headers to upstream. This middleware runs after the SDK + // has built the request, and replaces the outgoing headers with the sanitized + // client headers plus provider auth. + if i.clientHeaders != nil { + opts = append(opts, option.WithMiddleware(func(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + req.Header = intercept.BuildUpstreamHeaders(req.Header, i.clientHeaders, i.authHeaderName) + return next(req) + })) + } + + // Add API dump middleware if configured + if mw := apidump.NewBridgeMiddleware(i.cfg.APIDumpDir, i.providerName, i.Model(), i.id, i.logger, quartz.NewReal()); mw != nil { + opts = append(opts, option.WithMiddleware(mw)) + } + + return responses.NewResponseService(opts...) +} + +func (i *responsesInterceptionBase) ID() uuid.UUID { + return i.id +} + +func (i *responsesInterceptionBase) Credential() intercept.CredentialInfo { + return i.credential +} + +func (i *responsesInterceptionBase) Setup(logger slog.Logger, rec recorder.Recorder, mcpProxy mcp.ServerProxier) { + i.logger = logger.With(slog.F("model", i.Model())) + i.recorder = rec + i.mcpProxy = mcpProxy +} + +func (i *responsesInterceptionBase) Model() string { + return i.reqPayload.model() +} + +func (i *responsesInterceptionBase) CorrelatingToolCallID() *string { + return i.reqPayload.correlatingToolCallID() +} + +func (i *responsesInterceptionBase) baseTraceAttributes(r *http.Request, streaming bool) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String(tracing.RequestPath, r.URL.Path), + attribute.String(tracing.InterceptionID, i.id.String()), + attribute.String(tracing.InitiatorID, aibcontext.ActorIDFromContext(r.Context())), + attribute.String(tracing.Provider, i.providerName), + attribute.String(tracing.Model, i.Model()), + attribute.Bool(tracing.Streaming, streaming), + } +} + +func (i *responsesInterceptionBase) validateRequest(ctx context.Context, w http.ResponseWriter) error { + if i.reqPayload.background() { + err := xerrors.New("background requests are currently not supported by AI Bridge") + i.sendCustomErr(ctx, w, http.StatusNotImplemented, err) + return err + } + + return nil +} + +// sendCustomErr sends custom responses.Error error to the client +// it should only be called before any data is sent back to the client +func (i *responsesInterceptionBase) sendCustomErr(ctx context.Context, w http.ResponseWriter, code int, err error) { + // Same JSON shape as responses.Error but using a plain struct because + // responses.Error embeds *http.Request whose GetBody func field + // is not JSON-marshalable (SA1026). + respErr := struct { + Code string `json:"code"` + Message string `json:"message"` + }{ + Code: strconv.Itoa(code), + Message: err.Error(), + } + if b, err := json.Marshal(respErr); err != nil { + i.logger.Warn(ctx, "failed to marshal custom error: ", slog.Error(err)) + } else { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + if _, err := w.Write(b); err != nil { + i.logger.Warn(ctx, "failed to send custom error: ", slog.Error(err)) + } + } +} + +func (i *responsesInterceptionBase) requestOptions(respCopy *responseCopier) []option.RequestOption { + opts := []option.RequestOption{ + // Sends original payload to solve json re-encoding issues + // eg. Codex CLI produces requests without ID set in reasoning items: https://platform.openai.com/docs/api-reference/responses/create#responses_create-input-input_item_list-item-reasoning-id + // when re-encoded, ID field is set to empty string which results + // in bad request while not sending ID field at all somehow works. + option.WithRequestBody("application/json", []byte(i.reqPayload)), + + // copyMiddleware copies body of original response body to the buffer in responseCopier, + // also reference to headers and status code is kept responseCopier. + // responseCopier is used by interceptors to forward response as it was received, + // eliminating any possibility of JSON re-encoding issues. + option.WithMiddleware(respCopy.copyMiddleware), + } + if !i.reqPayload.Stream() { + opts = append(opts, option.WithRequestTimeout(requestTimeout)) + } + return opts +} + +func (i *responsesInterceptionBase) recordUserPrompt(ctx context.Context, responseID string, prompt string) { + if responseID == "" { + i.logger.Warn(ctx, "got empty response ID, skipping prompt recording") + return + } + + promptUsage := &recorder.PromptUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: responseID, + Prompt: prompt, + } + if err := i.recorder.RecordPromptUsage(ctx, promptUsage); err != nil { + i.logger.Warn(ctx, "failed to record prompt usage", slog.Error(err)) + } +} + +func (i *responsesInterceptionBase) recordModelThoughts(ctx context.Context, response *responses.Response) { + for _, t := range i.extractModelThoughts(response) { + _ = i.recorder.RecordModelThought(ctx, &recorder.ModelThoughtRecord{ + InterceptionID: i.ID().String(), + Content: t.Content, + Metadata: t.Metadata, + }) + } +} + +func (i *responsesInterceptionBase) recordNonInjectedToolUsage(ctx context.Context, response *responses.Response) { + if response == nil { + i.logger.Warn(ctx, "got empty response, skipping tool usage recording") + return + } + + for _, item := range response.Output { + var args recorder.ToolArgs + + // recording other function types to be considered: https://github.com/coder/aibridge/issues/121 + switch item.Type { + case string(constant.ValueOf[constant.FunctionCall]()): + args = i.parseFunctionCallJSONArgs(ctx, item.Arguments) + case string(constant.ValueOf[constant.CustomToolCall]()): + args = item.Input + default: + continue + } + + if err := i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: response.ID, + ToolCallID: item.CallID, + Tool: item.Name, + Args: args, + Injected: false, + }); err != nil { + i.logger.Warn(ctx, "failed to record tool usage", slog.Error(err), slog.F("tool", item.Name)) + } + } +} + +func (i *responsesInterceptionBase) parseFunctionCallJSONArgs(ctx context.Context, raw string) recorder.ToolArgs { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return trimmed + } + var args recorder.ToolArgs + if err := json.Unmarshal([]byte(trimmed), &args); err != nil { + i.logger.Warn(ctx, "failed to unmarshal tool args", slog.Error(err)) + return trimmed + } + return args +} + +func (i *responsesInterceptionBase) recordTokenUsage(ctx context.Context, response *responses.Response) { + if response == nil { + i.logger.Warn(ctx, "got empty response, skipping token usage recording") + return + } + + usage := response.Usage + + // Keeping logic consistent with chat completions + // Input *includes* the cached tokens, so we subtract them here to reflect actual input token usage. + inputNonCacheTokens := usage.InputTokens - usage.InputTokensDetails.CachedTokens + + if err := i.recorder.RecordTokenUsage(ctx, &recorder.TokenUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: response.ID, + Input: inputNonCacheTokens, + Output: usage.OutputTokens, + CacheReadInputTokens: usage.InputTokensDetails.CachedTokens, + ExtraTokenTypes: map[string]int64{ + "input_cached": usage.InputTokensDetails.CachedTokens, // TODO: remove from ExtraTokenTypes (https://github.com/coder/aibridge/issues/243) + "output_reasoning": usage.OutputTokensDetails.ReasoningTokens, + "total_tokens": usage.TotalTokens, + }, + }); err != nil { + i.logger.Warn(ctx, "failed to record token usage", slog.Error(err)) + } +} + +// extractModelThoughts extracts model thoughts from response output items. +// It captures both reasoning summary items and commentary messages (message +// output items with "phase": "commentary") as model thoughts. +func (*responsesInterceptionBase) extractModelThoughts(response *responses.Response) []*recorder.ModelThoughtRecord { + if response == nil { + return nil + } + + var thoughts []*recorder.ModelThoughtRecord + for _, item := range response.Output { + switch item.Type { + case string(constant.ValueOf[constant.Reasoning]()): + reasoning := item.AsReasoning() + for _, summary := range reasoning.Summary { + if summary.Text == "" { + continue + } + thoughts = append(thoughts, &recorder.ModelThoughtRecord{ + Content: summary.Text, + Metadata: recorder.Metadata{"source": recorder.ThoughtSourceReasoningSummary}, + }) + } + + case string(constant.ValueOf[constant.Message]()): + // The API sometimes returns commentary messages instead of reasoning + // summaries. These are assistant message output items with "phase": "commentary". + // The SDK doesn't expose a Phase field, so we extract it from raw JSON. + // TODO: revisit when the OpenAI SDK adds a proper Phase field. + raw := item.RawJSON() + if gjson.Get(raw, "role").String() != string(constant.ValueOf[constant.Assistant]()) || + gjson.Get(raw, "phase").String() != "commentary" { + continue + } + msg := item.AsMessage() + for _, part := range msg.Content { + if part.Type != string(constant.ValueOf[constant.OutputText]()) { + continue + } + if part.Text == "" { + continue + } + thoughts = append(thoughts, &recorder.ModelThoughtRecord{ + Content: part.Text, + Metadata: recorder.Metadata{"source": recorder.ThoughtSourceCommentary}, + }) + } + } + } + + return thoughts +} + +func (i *responsesInterceptionBase) hasInjectableTools() bool { + return i.mcpProxy != nil && len(i.mcpProxy.ListTools()) > 0 +} + +// responseCopier helper struct to send original response to the client +type responseCopier struct { + buff deltaBuffer + responseStatus int + responseHeaders http.Header + + // responseBody keeps reference to original ReadCloser. + // TeeReader in copyMiddleware copies read bytes from + // response body (read by SDK) to the buffer. In case + // SDK doesns't read everything readAll method reads from + // this closer to makes sure whole response body is in the buffer. + responseBody io.ReadCloser + + // responseReceived flag is used to determine if AI Bridge needs to write custom error: + // - If responseReceived is true, the upstream response is forwarded as-is. + // - If responseReceived is false, no response was returned and there is nothing to forward (eg. connection/client error). Custom error will be returned. + responseReceived atomic.Bool +} + +func (r *responseCopier) copyMiddleware(req *http.Request, next option.MiddlewareNext) (*http.Response, error) { + resp, err := next(req) + if err != nil || resp == nil { + return resp, err + } + + r.responseReceived.Store(true) + r.responseStatus = resp.StatusCode + r.responseHeaders = resp.Header + resp.Body = io.NopCloser(io.TeeReader(resp.Body, &r.buff)) + r.responseBody = resp.Body + return resp, nil +} + +// readAll reads all data from resp.Body returned by so TeeReader +// so it appends all read data to the buffer and returns buffer contents. +func (r *responseCopier) readAll() ([]byte, error) { + if r.responseBody == nil { + return []byte{}, nil + } + + _, err := io.ReadAll(r.responseBody) + return r.buff.readDelta(), err +} + +// forwardResp writes whole response as received to ResponseWriter +func (r *responseCopier) forwardResp(w http.ResponseWriter) error { + // no response was received, nothing to forward + if !r.responseReceived.Load() { + return nil + } + + w.Header().Set("Content-Type", r.responseHeaders.Get("Content-Type")) + w.WriteHeader(r.responseStatus) + + b, err := r.readAll() + if err != nil { + return xerrors.Errorf("failed to read response body: %w", err) + } + + if _, err := w.Write(b); err != nil { + return xerrors.Errorf("failed to write response body: %w", err) + } + return nil +} + +// deltaBuffer is a thread safe byte buffer +// supports reading incremental data (added after last read) +type deltaBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (d *deltaBuffer) Write(p []byte) (int, error) { + d.mu.Lock() + defer d.mu.Unlock() + return d.buf.Write(p) +} + +// readDelta returns only the bytes appended +// after the last readDelta call. +func (d *deltaBuffer) readDelta() []byte { + d.mu.Lock() + defer d.mu.Unlock() + + b := bytes.Clone(d.buf.Bytes()) + d.buf.Reset() + return b +} diff --git a/aibridge/intercept/responses/base_test.go b/aibridge/intercept/responses/base_test.go new file mode 100644 index 0000000000..bf1fa198c8 --- /dev/null +++ b/aibridge/intercept/responses/base_test.go @@ -0,0 +1,384 @@ +package responses //nolint:testpackage // tests unexported internals + +import ( + "net/http" + "testing" + "time" + + "github.com/google/uuid" + oairesponses "github.com/openai/openai-go/v3/responses" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/internal/testutil" + "github.com/coder/coder/v2/aibridge/recorder" +) + +func TestRecordPrompt(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + promptWasRecorded bool + prompt string + responseID string + wantRecorded bool + wantPrompt string + }{ + { + name: "records_prompt_successfully", + prompt: "tell me a joke", + responseID: "resp_123", + wantRecorded: true, + wantPrompt: "tell me a joke", + }, + { + name: "records_empty_prompt_successfully", + prompt: "", + responseID: "resp_123", + wantRecorded: true, + wantPrompt: "", + }, + { + name: "skips_recording_on_empty_response_id", + prompt: "tell me a joke", + responseID: "", + wantRecorded: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + rec := &testutil.MockRecorder{} + id := uuid.New() + base := &responsesInterceptionBase{ + id: id, + recorder: rec, + logger: slog.Make(), + } + + base.recordUserPrompt(t.Context(), tc.responseID, tc.prompt) + + prompts := rec.RecordedPromptUsages() + if tc.wantRecorded { + require.Len(t, prompts, 1) + require.Equal(t, id.String(), prompts[0].InterceptionID) + require.Equal(t, tc.responseID, prompts[0].MsgID) + require.Equal(t, tc.wantPrompt, prompts[0].Prompt) + } else { + require.Empty(t, prompts) + } + }) + } +} + +func TestRecordToolUsage(t *testing.T) { + t.Parallel() + + id := uuid.MustParse("11111111-1111-1111-1111-111111111111") + + tests := []struct { + name string + response *oairesponses.Response + expected []*recorder.ToolUsageRecord + }{ + { + name: "nil_response", + response: nil, + expected: nil, + }, + { + name: "empty_output", + response: &oairesponses.Response{ + ID: "resp_123", + }, + expected: nil, + }, + { + name: "empty_tool_args", + response: &oairesponses.Response{ + ID: "resp_456", + Output: []oairesponses.ResponseOutputItemUnion{ + { + Type: "function_call", + CallID: "call_abc", + Name: "get_weather", + Arguments: "", + }, + }, + }, + expected: []*recorder.ToolUsageRecord{ + { + InterceptionID: id.String(), + MsgID: "resp_456", + ToolCallID: "call_abc", + Tool: "get_weather", + Args: "", + Injected: false, + }, + }, + }, + { + name: "multiple_tool_calls", + response: &oairesponses.Response{ + ID: "resp_789", + Output: []oairesponses.ResponseOutputItemUnion{ + { + Type: "function_call", + CallID: "call_1", + Name: "get_weather", + Arguments: `{"location": "NYC"}`, + }, + { + Type: "function_call", + CallID: "call_2", + Name: "bad_json_args", + Arguments: `{"bad": args`, + }, + { + Type: "message", + ID: "msg_1", + Role: "assistant", + }, + { + Type: "custom_tool_call", + CallID: "call_3", + Name: "search", + Input: `{\"query\": \"test\"}`, + }, + { + Type: "function_call", + CallID: "call_4", + Name: "calculate", + Arguments: `{"a": 1, "b": 2}`, + }, + }, + }, + expected: []*recorder.ToolUsageRecord{ + { + InterceptionID: id.String(), + MsgID: "resp_789", + ToolCallID: "call_1", + Tool: "get_weather", + Args: map[string]any{"location": "NYC"}, + Injected: false, + }, + { + InterceptionID: id.String(), + MsgID: "resp_789", + ToolCallID: "call_2", + Tool: "bad_json_args", + Args: `{"bad": args`, + Injected: false, + }, + { + InterceptionID: id.String(), + MsgID: "resp_789", + ToolCallID: "call_3", + Tool: "search", + Args: `{\"query\": \"test\"}`, + Injected: false, + }, + { + InterceptionID: id.String(), + MsgID: "resp_789", + ToolCallID: "call_4", + Tool: "calculate", + Args: map[string]any{"a": float64(1), "b": float64(2)}, + Injected: false, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + rec := &testutil.MockRecorder{} + base := &responsesInterceptionBase{ + id: id, + recorder: rec, + logger: slog.Make(), + } + + base.recordNonInjectedToolUsage(t.Context(), tc.response) + + tools := rec.RecordedToolUsages() + require.Len(t, tools, len(tc.expected)) + for i, got := range tools { + got.CreatedAt = time.Time{} + require.Equal(t, tc.expected[i], got) + } + }) + } +} + +func TestParseJSONArgs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw string + expected recorder.ToolArgs + }{ + { + name: "empty_string", + raw: "", + expected: "", + }, + { + name: "whitespace_only", + raw: " \t\n ", + expected: "", + }, + { + name: "invalid_json", + raw: "{not valid json}", + expected: "{not valid json}", + }, + { + name: "nested_object_with_trailing_spaces", + raw: ` {"user": {"name": "alice", "settings": {"theme": "dark", "notifications": true}}, "count": 42} `, + expected: map[string]any{ + "user": map[string]any{ + "name": "alice", + "settings": map[string]any{ + "theme": "dark", + "notifications": true, + }, + }, + "count": float64(42), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + base := &responsesInterceptionBase{} + result := base.parseFunctionCallJSONArgs(t.Context(), tc.raw) + require.Equal(t, tc.expected, result) + }) + } +} + +func TestRecordTokenUsage(t *testing.T) { + t.Parallel() + + id := uuid.MustParse("22222222-2222-2222-2222-222222222222") + + tests := []struct { + name string + response *oairesponses.Response + expected *recorder.TokenUsageRecord + }{ + { + name: "nil_response", + response: nil, + expected: nil, + }, + { + name: "with_all_token_details", + response: &oairesponses.Response{ + ID: "resp_full", + Usage: oairesponses.ResponseUsage{ + InputTokens: 10, + OutputTokens: 20, + TotalTokens: 30, + InputTokensDetails: oairesponses.ResponseUsageInputTokensDetails{ + CachedTokens: 5, + }, + OutputTokensDetails: oairesponses.ResponseUsageOutputTokensDetails{ + ReasoningTokens: 5, + }, + }, + }, + expected: &recorder.TokenUsageRecord{ + InterceptionID: id.String(), + MsgID: "resp_full", + Input: 5, // 10 input - 5 cached + Output: 20, + CacheReadInputTokens: 5, + ExtraTokenTypes: map[string]int64{ + "input_cached": 5, + "output_reasoning": 5, + "total_tokens": 30, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + rec := &testutil.MockRecorder{} + base := &responsesInterceptionBase{ + id: id, + recorder: rec, + logger: slog.Make(), + } + + base.recordTokenUsage(t.Context(), tc.response) + + tokens := rec.RecordedTokenUsages() + if tc.expected == nil { + require.Empty(t, tokens) + } else { + require.Len(t, tokens, 1) + got := tokens[0] + got.CreatedAt = time.Time{} // ignore time + require.Equal(t, tc.expected, got) + } + }) + } +} + +type mockResponseWriter struct { + headerCalled bool + writeCalled bool + writeHeaderCalled bool +} + +func (mrw *mockResponseWriter) Header() http.Header { + mrw.headerCalled = true + return http.Header{} +} + +func (mrw *mockResponseWriter) Write([]byte) (int, error) { + mrw.writeCalled = true + return 0, nil +} + +func (mrw *mockResponseWriter) WriteHeader(statusCode int) { + mrw.writeHeaderCalled = true +} + +func TestResponseCopierDoesntSendIfNoResponseReceived(t *testing.T) { + t.Parallel() + + mrw := mockResponseWriter{} + + respCopy := responseCopier{} + body := "test_body" + _, _ = respCopy.buff.Write([]byte(body)) // bytes.Buffer.Write never fails + + err := respCopy.forwardResp(&mrw) + require.NoError(t, err) + require.False(t, mrw.headerCalled) + require.False(t, mrw.writeCalled) + require.False(t, mrw.writeHeaderCalled) + + // after response is received data is forwarded + respCopy.responseReceived.Store(true) + + err = respCopy.forwardResp(&mrw) + require.NoError(t, err) + require.True(t, mrw.headerCalled) + require.True(t, mrw.writeCalled) + require.True(t, mrw.writeHeaderCalled) +} diff --git a/aibridge/intercept/responses/blocking.go b/aibridge/intercept/responses/blocking.go new file mode 100644 index 0000000000..ce98219fc3 --- /dev/null +++ b/aibridge/intercept/responses/blocking.go @@ -0,0 +1,144 @@ +package responses + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/openai/openai-go/v3/option" + "github.com/openai/openai-go/v3/responses" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/recorder" + "github.com/coder/coder/v2/aibridge/tracing" +) + +type BlockingResponsesInterceptor struct { + responsesInterceptionBase +} + +func NewBlockingInterceptor( + id uuid.UUID, + reqPayload RequestPayload, + providerName string, + cfg config.OpenAI, + clientHeaders http.Header, + authHeaderName string, + tracer trace.Tracer, + cred intercept.CredentialInfo, +) *BlockingResponsesInterceptor { + return &BlockingResponsesInterceptor{ + responsesInterceptionBase: responsesInterceptionBase{ + id: id, + providerName: providerName, + reqPayload: reqPayload, + cfg: cfg, + clientHeaders: clientHeaders, + authHeaderName: authHeaderName, + tracer: tracer, + credential: cred, + }, + } +} + +func (i *BlockingResponsesInterceptor) Setup(logger slog.Logger, rec recorder.Recorder, mcpProxy mcp.ServerProxier) { + i.responsesInterceptionBase.Setup(logger.Named("blocking"), rec, mcpProxy) +} + +func (*BlockingResponsesInterceptor) Streaming() bool { + return false +} + +func (i *BlockingResponsesInterceptor) TraceAttributes(r *http.Request) []attribute.KeyValue { + return i.responsesInterceptionBase.baseTraceAttributes(r, false) +} + +func (i *BlockingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r *http.Request) (outErr error) { + ctx, span := i.tracer.Start(r.Context(), "Intercept.ProcessRequest", trace.WithAttributes(tracing.InterceptionAttributesFromContext(r.Context())...)) + defer tracing.EndSpanErr(span, &outErr) + + if err := i.validateRequest(ctx, w); err != nil { + return err + } + + i.injectTools() + + var ( + response *responses.Response + upstreamErr error + respCopy responseCopier + firstResponseID string + ) + + prompt, promptFound, err := i.reqPayload.lastUserPrompt(ctx, i.logger) + if err != nil { + i.logger.Warn(ctx, "failed to get user prompt", slog.Error(err)) + } + shouldLoop := true + + for shouldLoop { + srv := i.newResponsesService() + respCopy = responseCopier{} + + opts := i.requestOptions(&respCopy) + opts = append(opts, option.WithRequestTimeout(time.Second*600)) + + // TODO(ssncferreira): inject actor headers directly in the client-header + // middleware instead of using SDK options. + if actor := aibcontext.ActorFromContext(r.Context()); actor != nil && i.cfg.SendActorHeaders { + opts = append(opts, intercept.ActorHeadersAsOpenAIOpts(actor)...) + } + + response, upstreamErr = i.newResponse(ctx, srv, opts) + + if upstreamErr != nil || response == nil { + break + } + + if firstResponseID == "" { + firstResponseID = response.ID + } + + i.recordTokenUsage(ctx, response) + i.recordModelThoughts(ctx, response) + + // Check if there any injected tools to invoke. + pending := i.getPendingInjectedToolCalls(response) + shouldLoop, err = i.handleInnerAgenticLoop(ctx, pending, response) + if err != nil { + i.sendCustomErr(ctx, w, http.StatusInternalServerError, err) + shouldLoop = false + } + } + + if promptFound { + i.recordUserPrompt(ctx, firstResponseID, prompt) + } + i.recordNonInjectedToolUsage(ctx, response) + + if upstreamErr != nil && !respCopy.responseReceived.Load() { + // no response received from upstream, return custom error + i.sendCustomErr(ctx, w, http.StatusInternalServerError, upstreamErr) + return xerrors.Errorf("failed to connect to upstream: %w", upstreamErr) + } + + err = respCopy.forwardResp(w) + return errors.Join(upstreamErr, err) +} + +func (i *BlockingResponsesInterceptor) newResponse(ctx context.Context, srv responses.ResponseService, opts []option.RequestOption) (_ *responses.Response, outErr error) { + ctx, span := i.tracer.Start(ctx, "Intercept.ProcessRequest.Upstream", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...)) + defer tracing.EndSpanErr(span, &outErr) + + // The body is overridden by option.WithRequestBody(reqPayload) in requestOptions + return srv.New(ctx, responses.ResponseNewParams{}, opts...) +} diff --git a/aibridge/intercept/responses/injected_tools.go b/aibridge/intercept/responses/injected_tools.go new file mode 100644 index 0000000000..e9b8e2ee67 --- /dev/null +++ b/aibridge/intercept/responses/injected_tools.go @@ -0,0 +1,268 @@ +package responses + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/responses" + "github.com/openai/openai-go/v3/shared/constant" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/recorder" +) + +func (i *responsesInterceptionBase) injectTools() { + if i.mcpProxy == nil || !i.hasInjectableTools() { + return + } + + i.disableParallelToolCalls() + + // Inject tools. + var injected []responses.ToolUnionParam + for _, tool := range i.mcpProxy.ListTools() { + var params map[string]any + + if tool.Params != nil { + params = map[string]any{ + "type": "object", + "properties": tool.Params, + // "additionalProperties": false, // Only relevant when strict=true. + } + } + + // Otherwise the request fails with "None is not of type 'array'" if a nil slice is given. + if len(tool.Required) > 0 { + // Must list ALL properties when strict=true. + params["required"] = tool.Required + } + + injected = append(injected, responses.ToolUnionParam{ + OfFunction: &responses.FunctionToolParam{ + Name: tool.ID, + Strict: openai.Bool(false), // TODO: configurable. + Description: openai.String(tool.Description), + Parameters: params, + }, + }) + } + + updated, err := i.reqPayload.injectTools(injected) + if err != nil { + i.logger.Warn(context.Background(), "failed to inject tools", slog.Error(err)) + return + } + i.reqPayload = updated +} + +// disableParallelToolCalls disables parallel tool calls, to simplify the inner agentic loop. +// This is best-effort, and failing to set this flag does not fail the request. +// TODO: implement parallel tool calls. +func (i *responsesInterceptionBase) disableParallelToolCalls() { + updated, err := i.reqPayload.disableParallelToolCalls() + if err != nil { + i.logger.Warn(context.Background(), "failed to disable parallel_tool_calls", slog.Error(err)) + return + } + i.reqPayload = updated +} + +// handleInnerAgenticLoop orchestrates the inner agentic loop whereby injected tools +// are invoked and their results are sent back to the model. +// This is in contrast to regular tool calls which will be handled by the client +// in its own agentic loop. +func (i *responsesInterceptionBase) handleInnerAgenticLoop(ctx context.Context, pending []responses.ResponseFunctionToolCall, response *responses.Response) (bool, error) { + // Invoke any injected function calls. + // The Responses API refers to what we call "tools" as "functions", so we keep the terminology + // consistent in this package. + // See https://platform.openai.com/docs/guides/function-calling + results, err := i.handleInjectedToolCalls(ctx, pending, response) + if err != nil { + return false, xerrors.Errorf("failed to handle injected tool calls: %w", err) + } + + // No tool results means no tools were invocable, so the flow is complete. + if len(results) == 0 { + return false, nil + } + + // We'll use the tool results to issue another request to provide the model with. + err = i.prepareRequestForAgenticLoop(ctx, response, results) + + return true, err +} + +// handleInjectedToolCalls checks for function calls that we need to handle in our inner agentic loop. +// These are functions injected by the MCP proxy. +// Returns a list of tool call results. +func (i *responsesInterceptionBase) handleInjectedToolCalls(ctx context.Context, pending []responses.ResponseFunctionToolCall, response *responses.Response) ([]responses.ResponseInputItemUnionParam, error) { + if response == nil { + return nil, xerrors.New("empty response") + } + + // MCP proxy has not been configured; no way to handle injected functions. + if i.mcpProxy == nil { + return nil, nil + } + + var results []responses.ResponseInputItemUnionParam + for _, fc := range pending { + results = append(results, i.invokeInjectedTool(ctx, response.ID, fc)) + } + + return results, nil +} + +// prepareRequestForAgenticLoop prepares the request by setting the output of the given +// response as input to the next request, in order for the tool call result(s) to make function correctly. +func (i *responsesInterceptionBase) prepareRequestForAgenticLoop(ctx context.Context, response *responses.Response, toolResults []responses.ResponseInputItemUnionParam) error { + // Collect new items to add: response outputs converted to input format + tool results. + var newItems []responses.ResponseInputItemUnionParam + + // OutputText is also available, but by definition the trigger for a function call is not a simple + // text response from the model. + for _, output := range response.Output { + if inputItem := i.convertOutputToInput(output); inputItem != nil { + newItems = append(newItems, *inputItem) + } + } + newItems = append(newItems, toolResults...) + + updated, err := i.reqPayload.appendInputItems(newItems) + if err != nil { + i.logger.Error(ctx, "failed to rewrite input in inner agentic loop", slog.Error(err)) + return xerrors.Errorf("failed to rewrite input: %w", err) + } + i.reqPayload = updated + + return nil +} + +// getPendingInjectedToolCalls extracts function calls from the response that are managed by MCP proxy. +func (i *responsesInterceptionBase) getPendingInjectedToolCalls(response *responses.Response) []responses.ResponseFunctionToolCall { + var calls []responses.ResponseFunctionToolCall + + for _, item := range response.Output { + if item.Type != string(constant.ValueOf[constant.FunctionCall]()) { + continue + } + + // Injected functions are defined by MCP, and MCP tools have to have a schema + // for their inputs. The Responses API also supports "Custom Tools": + // https://platform.openai.com/docs/guides/function-calling#custom-tools + // These are like regular functions but their inputs are not schematized. + // As such, custom tools are not considered here. + fc := item.AsFunctionCall() + + // Check if this is a tool managed by our MCP proxy + if i.mcpProxy != nil && i.mcpProxy.GetTool(fc.Name) != nil { + calls = append(calls, fc) + } + } + + return calls +} + +func (i *responsesInterceptionBase) invokeInjectedTool(ctx context.Context, responseID string, fc responses.ResponseFunctionToolCall) responses.ResponseInputItemUnionParam { + tool := i.mcpProxy.GetTool(fc.Name) + if tool == nil { + return responses.ResponseInputItemParamOfFunctionCallOutput(fc.CallID, fmt.Sprintf("error: unknown injected function %q", fc.ID)) + } + + args := i.parseFunctionCallJSONArgs(ctx, fc.Arguments) + res, err := tool.Call(ctx, args, i.tracer) + _ = i.recorder.RecordToolUsage(ctx, &recorder.ToolUsageRecord{ + InterceptionID: i.ID().String(), + MsgID: responseID, + ToolCallID: fc.CallID, + ServerURL: &tool.ServerURL, + Tool: tool.Name, + Args: args, + Injected: true, + InvocationError: err, + }) + + var output string + if err != nil { + // Results have no fixed structure; if an error occurs, we can just pass back the error. + // https://platform.openai.com/docs/guides/function-calling?strict-mode=enabled#formatting-results + output = fmt.Sprintf("invocation error: %q", err.Error()) + } else { + var out strings.Builder + if encErr := json.NewEncoder(&out).Encode(res); encErr != nil { + i.logger.Warn(ctx, "failed to encode tool response", slog.Error(encErr)) + output = fmt.Sprintf("result encode error: %q", encErr.Error()) + } else { + output = out.String() + } + } + + return responses.ResponseInputItemParamOfFunctionCallOutput(fc.CallID, output) +} + +// convertOutputToInput converts a response output item to an input item and appends it to the +// request's input list. This is used in agentic loops where we need to feed the model's output +// back as input for the next iteration (e.g., when processing tool call results). +// +// The conversion uses the openai-go library's ToParam() methods where available, which leverage +// param.Override() with raw JSON to preserve all fields. For types without ToParam(), we use +// the ResponseInputItemParamOf* helper functions. +func (i *responsesInterceptionBase) convertOutputToInput(item responses.ResponseOutputItemUnion) *responses.ResponseInputItemUnionParam { + var inputItem responses.ResponseInputItemUnionParam + + switch item.Type { + case string(constant.ValueOf[constant.Message]()): + p := item.AsMessage().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfOutputMessage: &p} + + case string(constant.ValueOf[constant.FileSearchCall]()): + p := item.AsFileSearchCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfFileSearchCall: &p} + + case string(constant.ValueOf[constant.FunctionCall]()): + p := item.AsFunctionCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfFunctionCall: &p} + + case string(constant.ValueOf[constant.WebSearchCall]()): + p := item.AsWebSearchCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfWebSearchCall: &p} + + case "computer_call": // No constant.ComputerCall type exists + p := item.AsComputerCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfComputerCall: &p} + + case string(constant.ValueOf[constant.Reasoning]()): + p := item.AsReasoning().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfReasoning: &p} + + case string(constant.ValueOf[constant.Compaction]()): + c := item.AsCompaction() + inputItem = responses.ResponseInputItemParamOfCompaction(c.EncryptedContent) + + case string(constant.ValueOf[constant.ImageGenerationCall]()): + c := item.AsImageGenerationCall() + inputItem = responses.ResponseInputItemParamOfImageGenerationCall(c.ID, c.Result, c.Status) + + case string(constant.ValueOf[constant.CodeInterpreterCall]()): + p := item.AsCodeInterpreterCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfCodeInterpreterCall: &p} + + case "custom_tool_call": // No constant.CustomToolCall type exists + p := item.AsCustomToolCall().ToParam() + inputItem = responses.ResponseInputItemUnionParam{OfCustomToolCall: &p} + + // Output-only types that don't have direct input equivalents or are handled separately: + // - local_shell_call, shell_call, shell_call_output: Shell tool outputs + // - apply_patch_call, apply_patch_call_output: Apply patch outputs + // - mcp_call, mcp_list_tools, mcp_approval_request: MCP-specific outputs + default: + i.logger.Debug(context.Background(), "skipping output item type for input", slog.F("type", item.Type)) + return nil + } + + return &inputItem +} diff --git a/aibridge/intercept/responses/reqpayload.go b/aibridge/intercept/responses/reqpayload.go new file mode 100644 index 0000000000..600402d0ec --- /dev/null +++ b/aibridge/intercept/responses/reqpayload.go @@ -0,0 +1,262 @@ +package responses + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/openai/openai-go/v3/responses" + "github.com/openai/openai-go/v3/shared/constant" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" +) + +const ( + reqPathBackground = "background" + reqPathCallID = "call_id" + reqPathRole = "role" + reqPathInput = "input" + reqPathParallelToolCalls = "parallel_tool_calls" + reqPathStream = "stream" + reqPathTools = "tools" +) + +var ( + constFunctionCallOutput = string(constant.ValueOf[constant.FunctionCallOutput]()) + constInputText = string(constant.ValueOf[constant.InputText]()) + constUser = string(constant.ValueOf[constant.User]()) + + reqPathContent = string(constant.ValueOf[constant.Content]()) + reqPathModel = string(constant.ValueOf[constant.Model]()) + reqPathText = string(constant.ValueOf[constant.Text]()) + reqPathType = string(constant.ValueOf[constant.Type]()) +) + +// RequestPayload is raw JSON bytes of a Responses API request. +// Methods provide package-specific reads and rewrites while preserving the +// original body for upstream pass-through. +// Note: No changes are made on schema error. +type RequestPayload []byte + +func NewRequestPayload(raw []byte) (RequestPayload, error) { + if len(bytes.TrimSpace(raw)) == 0 { + return nil, xerrors.New("empty request body") + } + if !json.Valid(raw) { + return nil, xerrors.New("invalid JSON payload") + } + + return RequestPayload(raw), nil +} + +func (p RequestPayload) Stream() bool { + return gjson.GetBytes(p, reqPathStream).Bool() +} + +func (p RequestPayload) model() string { + return gjson.GetBytes(p, reqPathModel).String() +} + +func (p RequestPayload) background() bool { + return gjson.GetBytes(p, reqPathBackground).Bool() +} + +func (p RequestPayload) correlatingToolCallID() *string { + items := gjson.GetBytes(p, reqPathInput) + if !items.IsArray() { + return nil + } + + arr := items.Array() + if len(arr) == 0 { + return nil + } + + last := arr[len(arr)-1] + if last.Get(reqPathType).String() != constFunctionCallOutput { + return nil + } + + callID := last.Get(reqPathCallID).String() + if callID == "" { + return nil + } + + return &callID +} + +// LastUserPrompt returns input text with the "user" role from the last input +// item, or the string input value if present. If no prompt is found, it returns +// empty string, false, nil. Unexpected shapes are treated as unsupported and do +// not fail the request path. +func (p RequestPayload) lastUserPrompt(ctx context.Context, logger slog.Logger) (string, bool, error) { + inputItems := gjson.GetBytes(p, reqPathInput) + if !inputItems.Exists() || inputItems.Type == gjson.Null { + return "", false, nil + } + + // 'input' can be either a string or an array of input items: + // https://platform.openai.com/docs/api-reference/responses/create#responses_create-input + + // String variant: treat the whole input as the user prompt. + if inputItems.Type == gjson.String { + return inputItems.String(), true, nil + } + + // Array variant: checking only the last input item + if !inputItems.IsArray() { + return "", false, xerrors.Errorf("unexpected input type: %s", inputItems.Type) + } + + inputItemsArr := inputItems.Array() + if len(inputItemsArr) == 0 { + return "", false, nil + } + + lastItem := inputItemsArr[len(inputItemsArr)-1] + if lastItem.Get(reqPathRole).Str != constUser { + // Request was likely not initiated by a prompt but is an iteration of agentic loop. + return "", false, nil + } + + // Message content can be either a string or an array of typed content items: + // https://platform.openai.com/docs/api-reference/responses/create#responses_create-input-input_item_list-input_message-content + content := lastItem.Get(reqPathContent) + if !content.Exists() || content.Type == gjson.Null { + return "", false, nil + } + + // String variant: use it directly as the prompt. + if content.Type == gjson.String { + return content.Str, true, nil + } + + if !content.IsArray() { + return "", false, xerrors.Errorf("unexpected input content type: %s", content.Type) + } + + var sb strings.Builder + promptExists := false + for _, c := range content.Array() { + // Ignore non-text content blocks such as images or files. + if c.Get(reqPathType).Str != constInputText { + continue + } + + text := c.Get(reqPathText) + if text.Type != gjson.String { + logger.Warn(ctx, fmt.Sprintf("unexpected input content array element text type: %v", text.Type)) + continue + } + + if promptExists { + _ = sb.WriteByte('\n') // strings.Builder.WriteByte never fails + } + promptExists = true + _, _ = sb.WriteString(text.Str) // strings.Builder.WriteString never fails + } + + if !promptExists { + return "", false, nil + } + + return sb.String(), true, nil +} + +func (p RequestPayload) injectTools(injected []responses.ToolUnionParam) (RequestPayload, error) { + if len(injected) == 0 { + return p, nil + } + + existing, err := p.toolItems() + if err != nil { + return p, xerrors.Errorf("failed to get existing tools: %w", err) + } + + allTools := make([]any, 0, len(existing)+len(injected)) + for _, item := range existing { + allTools = append(allTools, item) + } + for _, tool := range injected { + allTools = append(allTools, tool) + } + + return p.set(reqPathTools, allTools) +} + +func (p RequestPayload) disableParallelToolCalls() (RequestPayload, error) { + return p.set(reqPathParallelToolCalls, false) +} + +func (p RequestPayload) appendInputItems(items []responses.ResponseInputItemUnionParam) (RequestPayload, error) { + if len(items) == 0 { + return p, nil + } + + existing, err := p.inputItems() + if err != nil { + return p, xerrors.Errorf("failed to get existing 'input' items: %w", err) + } + + allInput := make([]any, 0, len(existing)+len(items)) + allInput = append(allInput, existing...) + for _, item := range items { + allInput = append(allInput, item) + } + + return p.set(reqPathInput, allInput) +} + +func (p RequestPayload) inputItems() ([]any, error) { + input := gjson.GetBytes(p, reqPathInput) + if !input.Exists() || input.Type == gjson.Null { + return []any{}, nil + } + + if input.Type == gjson.String { + return []any{responses.ResponseInputItemParamOfMessage(input.String(), responses.EasyInputMessageRoleUser)}, nil + } + + if !input.IsArray() { + return nil, xerrors.Errorf("unsupported 'input' type: %s", input.Type) + } + + items := input.Array() + existing := make([]any, 0, len(items)) + for _, item := range items { + existing = append(existing, json.RawMessage(item.Raw)) + } + + return existing, nil +} + +func (p RequestPayload) toolItems() ([]json.RawMessage, error) { + tools := gjson.GetBytes(p, reqPathTools) + if !tools.Exists() { + return nil, nil + } + if !tools.IsArray() { + return nil, xerrors.Errorf("unsupported 'tools' type: %s", tools.Type) + } + + items := tools.Array() + existing := make([]json.RawMessage, 0, len(items)) + for _, item := range items { + existing = append(existing, json.RawMessage(item.Raw)) + } + + return existing, nil +} + +func (p RequestPayload) set(path string, value any) (RequestPayload, error) { + updated, err := sjson.SetBytes(p, path, value) + if err != nil { + return p, xerrors.Errorf("failed to set value at path %s: %w", path, err) + } + return updated, nil +} diff --git a/aibridge/intercept/responses/reqpayload_test.go b/aibridge/intercept/responses/reqpayload_test.go new file mode 100644 index 0000000000..15f84183d3 --- /dev/null +++ b/aibridge/intercept/responses/reqpayload_test.go @@ -0,0 +1,527 @@ +package responses //nolint:testpackage // tests unexported internals + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/openai/openai-go/v3" + "github.com/openai/openai-go/v3/responses" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/fixtures" + "github.com/coder/coder/v2/aibridge/utils" +) + +func TestNewRequestPayload(t *testing.T) { + t.Parallel() + + payloadWithWrongTypes := []byte(`{"model":123,"stream":"yes","input":42,"background":"nope"}`) + tests := []struct { + name string + raw []byte + want []byte + model string + stream bool + background bool + err string + }{ + { + name: "empty payload", + raw: nil, + want: nil, + err: "empty request body", + }, + { + name: "invalid json", + raw: []byte(`{broken`), + want: nil, + err: "invalid JSON payload", + }, + { + // RequestPayload just checks for JSON validity, + // schema errors are not surfaced here and + // the original body is preserved for upstream handling + // similar to how reverse proxy would behave. + name: "wrong field types still wrap", + raw: payloadWithWrongTypes, + want: payloadWithWrongTypes, + model: "123", + stream: false, + background: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + payload, err := NewRequestPayload(tc.raw) + + if tc.err != "" { + require.ErrorContains(t, err, tc.err) + assert.Nil(t, payload) + return + } + + require.NoError(t, err) + require.NotNil(t, payload) + assert.EqualValues(t, tc.want, payload) + assert.Equal(t, tc.model, payload.model()) + assert.Equal(t, tc.stream, payload.Stream()) + assert.Equal(t, tc.background, payload.background()) + }) + } +} + +func TestCorrelatingToolCallID(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + payload []byte + wantCall *string + }{ + { + name: "no input items", + payload: []byte(`{"model":"gpt-4o"}`), + }, + { + name: "empty input array", + payload: []byte(`{"model":"gpt-4o","input":[]}`), + }, + { + name: "no function_call_output items", + payload: []byte(`{"model":"gpt-4o","input":[{"role":"user","content":"hi"}]}`), + }, + { + name: "single function_call_output", + payload: []byte(`{"model":"gpt-4o","input":[{"role":"user","content":"hi"},{"type":"function_call_output","call_id":"call_abc","output":"result"}]}`), + wantCall: utils.PtrTo("call_abc"), + }, + { + name: "multiple function_call_outputs returns last", + payload: []byte(`{"model":"gpt-4o","input":[{"type":"function_call_output","call_id":"call_first","output":"r1"},{"role":"user","content":"hi"},{"type":"function_call_output","call_id":"call_second","output":"r2"}]}`), + wantCall: utils.PtrTo("call_second"), + }, + { + name: "last input is not a tool result", + payload: []byte(`{"model":"gpt-4o","input":[{"type":"function_call_output","call_id":"call_first","output":"r1"},{"role":"user","content":"hi"}]}`), + }, + { + name: "missing call id", + payload: []byte(`{"input":[{"type":"function_call_output","output":"ok"}]}`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + callID := mustPayload(t, tc.payload).correlatingToolCallID() + assert.Equal(t, tc.wantCall, callID) + }) + } +} + +func TestLastUserPrompt(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + reqPayload []byte + expect string + found bool + expectErr string + }{ + { + name: "no input", + reqPayload: []byte(`{}`), + found: false, + }, + { + name: "input null", + reqPayload: []byte(`{"input": null}`), + found: false, + }, + { + name: "empty input array", + reqPayload: []byte(`{"input": []}`), + found: false, + }, + { + name: "input empty string", + reqPayload: []byte(`{"input": ""}`), + expect: "", + found: true, + }, + { + name: "input array content empty string", + reqPayload: []byte(`{"input": [{"role": "user", "content": ""}]}`), + expect: "", + found: true, + }, + { + name: "input array content array empty string", + reqPayload: []byte(`{"input": [ { "role": "user", "content": [{"type": "input_text", "text": ""}] } ] }`), + expect: "", + found: true, + }, + { + name: "input array content array multiple inputs", + reqPayload: []byte(`{"input": [ { "role": "user", "content": [{"type": "input_text", "text": "a"}, {"type": "input_text", "text": "b"}] } ] }`), + expect: "a\nb", + found: true, + }, + { + name: "simple string input", + reqPayload: fixtures.Request(t, fixtures.OaiResponsesBlockingSimple), + expect: "tell me a joke", + found: true, + }, + { + name: "array single input string", + reqPayload: fixtures.Request(t, fixtures.OaiResponsesBlockingSingleBuiltinTool), + expect: "Is 3 + 5 a prime number? Use the add function to calculate the sum.", + found: true, + }, + { + name: "array multiple items content objects", + reqPayload: fixtures.Request(t, fixtures.OaiResponsesStreamingCodex), + expect: "hello", + found: true, + }, + { + name: "input integer", + reqPayload: []byte(`{"input": 123}`), + expectErr: "unexpected input type", + }, + { + name: "no user role", + reqPayload: []byte(`{"input": [{"role": "assistant", "content": "hello"}]}`), + found: false, + }, + { + name: "user with empty content array", + reqPayload: []byte(`{"input": [{"role": "user", "content": []}]}`), + found: false, + }, + { + name: "user content missing", + reqPayload: []byte(`{"input": [{"role": "user"}]}`), + found: false, + }, + { + name: "user content null", + reqPayload: []byte(`{"input": [{"role": "user", "content": null}]}`), + found: false, + }, + { + name: "input array integer", + reqPayload: []byte(`{"input": [{"role": "user", "content": 123}]}`), + expectErr: "unexpected input content type", + }, + { + name: "user with non input_text content", + reqPayload: []byte(`{"input": [{"role": "user", "content": [{"type": "input_image", "url": "http://example.com/img.png"}]}]}`), + found: false, + }, + { + name: "user content not last", + reqPayload: []byte(`{"input": [ {"role": "user", "content":"input"}, {"role": "assistant", "content": "hello"} ]}`), + found: false, + }, + { + name: "input array content array integer", + reqPayload: []byte(`{"input": [ { "role": "user", "content": [{"type": "input_text", "text": 123}] } ] }`), + found: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + prompt, promptFound, err := mustPayload(t, tc.reqPayload).lastUserPrompt(t.Context(), slog.Make()) + if tc.expectErr != "" { + require.ErrorContains(t, err, tc.expectErr) + return + } + require.NoError(t, err) + require.Equal(t, tc.expect, prompt) + require.Equal(t, tc.found, promptFound) + }) + } +} + +func TestInjectTools(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw []byte + injected []responses.ToolUnionParam + wantNames []string + wantErr string + wantSame bool + }{ + { + name: "appends to existing tools", + raw: []byte(`{"model":"gpt-4o","input":"hello","tools":[{"type":"function","name":"existing"}]}`), + injected: []responses.ToolUnionParam{injectedFunctionTool("injected")}, + wantNames: []string{"existing", "injected"}, + }, + { + name: "adds tools when none exist", + raw: []byte(`{"model":"gpt-4o","input":"hello"}`), + injected: []responses.ToolUnionParam{injectedFunctionTool("injected")}, + wantNames: []string{"injected"}, + }, + { + name: "adds to empty tools array", + raw: []byte(`{"model":"gpt-4o","input":"hello","tools":[]}`), + injected: []responses.ToolUnionParam{injectedFunctionTool("injected")}, + wantNames: []string{"injected"}, + }, + { + name: "appends multiple injected tools", + raw: []byte(`{"model":"gpt-4o","input":"hello","tools":[{"type":"function","name":"existing"}]}`), + injected: []responses.ToolUnionParam{ + injectedFunctionTool("injected-one"), + injectedFunctionTool("injected-two"), + }, + wantNames: []string{"existing", "injected-one", "injected-two"}, + }, + { + name: "empty injected tools is no op", + raw: []byte(`{"model":"gpt-4o","input":"hello","tools":[{"type":"function","name":"existing"}]}`), + wantSame: true, + }, + { + name: "errors on unsupported tools shape", + raw: []byte(`{"model":"gpt-4o","input":"hello","tools":"bad"}`), + injected: []responses.ToolUnionParam{injectedFunctionTool("injected")}, + wantErr: "failed to get existing tools: unsupported 'tools' type: String", + wantSame: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := mustPayload(t, tc.raw) + updated, err := p.injectTools(tc.injected) + if tc.wantErr != "" { + require.EqualError(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + + if tc.wantSame { + require.EqualValues(t, tc.raw, updated) + } + for i, wantName := range tc.wantNames { + path := fmt.Sprintf("tools.%d.name", i) // name of the i-th element in tools array + require.Equal(t, wantName, gjson.GetBytes(updated, path).String()) + } + }) + } +} + +func TestDisableParallelToolCalls(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw []byte + }{ + { + name: "sets flag when not present", + raw: []byte(`{"model":"gpt-4o"}`), + }, + { + name: "overrides when already true", + raw: []byte(`{"model":"gpt-4o","parallel_tool_calls":true}`), + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := mustPayload(t, tc.raw) + updated, err := p.disableParallelToolCalls() + require.NoError(t, err) + assert.False(t, gjson.GetBytes(updated, "parallel_tool_calls").Bool()) + }) + } +} + +func TestAppendInputItems(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + raw []byte + items []responses.ResponseInputItemUnionParam + wantErr string + wantSame bool + wantPaths map[string]string + }{ + { + name: "string input becomes user message", + raw: []byte(`{"model":"gpt-4o","input":"hello"}`), + items: []responses.ResponseInputItemUnionParam{responses.ResponseInputItemParamOfFunctionCallOutput("call_123", "done")}, + wantPaths: map[string]string{ + "input.0.role": "user", + "input.0.content": "hello", + "input.1.type": "function_call_output", + "input.1.call_id": "call_123", + }, + }, + { + name: "array input is preserved and appended", + raw: []byte(`{"model":"gpt-4o","input":[{"role":"user","content":"hello"}]}`), + items: []responses.ResponseInputItemUnionParam{responses.ResponseInputItemParamOfFunctionCallOutput("call_123", "done")}, + wantPaths: map[string]string{ + "input.0.content": "hello", + "input.1.call_id": "call_123", + }, + }, + { + name: "unsupported input shape errors during rewrite", + raw: []byte(`{"model":"gpt-4o","input":123}`), + items: []responses.ResponseInputItemUnionParam{responses.ResponseInputItemParamOfFunctionCallOutput("call_123", "done")}, + wantErr: "failed to get existing 'input' items: unsupported 'input' type: Number", + wantSame: true, + }, + { + name: "missing input creates appended input", + raw: []byte(`{"model":"gpt-4o"}`), + items: []responses.ResponseInputItemUnionParam{responses.ResponseInputItemParamOfFunctionCallOutput("call_123", "done")}, + wantPaths: map[string]string{ + "input.0.type": "function_call_output", + "input.0.call_id": "call_123", + }, + }, + { + name: "null input creates appended input", + raw: []byte(`{"model":"gpt-4o","input":null}`), + items: []responses.ResponseInputItemUnionParam{responses.ResponseInputItemParamOfFunctionCallOutput("call_123", "done")}, + wantPaths: map[string]string{ + "input.0.type": "function_call_output", + "input.0.call_id": "call_123", + }, + }, + { + name: "multiple output item types are appended in order", + raw: []byte(`{"model":"gpt-4o","input":[{"role":"user","content":"hello"}]}`), + items: []responses.ResponseInputItemUnionParam{ + responses.ResponseInputItemParamOfCompaction("encrypted-content"), + responses.ResponseInputItemParamOfOutputMessage([]responses.ResponseOutputMessageContentUnionParam{ + { + OfOutputText: &responses.ResponseOutputTextParam{ + Annotations: []responses.ResponseOutputTextAnnotationUnionParam{}, + Text: "assistant text", + }, + }, + }, "msg_123", responses.ResponseOutputMessageStatusCompleted), + responses.ResponseInputItemParamOfFileSearchCall("fs_123", []string{"hello"}, "completed"), + responses.ResponseInputItemParamOfImageGenerationCall("img_123", "base64-image", "completed"), + }, + wantPaths: map[string]string{ + "input.0.content": "hello", + "input.1.type": "compaction", + "input.2.type": "message", + "input.2.id": "msg_123", + "input.2.content.0.type": "output_text", + "input.2.content.0.text": "assistant text", + "input.3.type": "file_search_call", + "input.3.id": "fs_123", + "input.4.type": "image_generation_call", + "input.4.id": "img_123", + }, + }, + { + name: "empty appended items is no op", + raw: []byte(`{"model":"gpt-4o","input":"hello"}`), + wantSame: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := mustPayload(t, tc.raw) + updated, err := p.appendInputItems(tc.items) + + if tc.wantErr != "" { + require.EqualError(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + + if tc.wantSame { + require.EqualValues(t, tc.raw, updated) + } + + for path, want := range tc.wantPaths { + require.Equal(t, want, gjson.GetBytes(updated, path).String()) + } + }) + } +} + +func TestChainedRewritesProduceValidJSON(t *testing.T) { + t.Parallel() + + p := mustPayload(t, []byte(`{"model":"gpt-4o","input":"hello"}`)) + p, err := p.injectTools([]responses.ToolUnionParam{{ + OfFunction: &responses.FunctionToolParam{ + Name: "tool_a", + Description: openai.String("tool"), + Strict: openai.Bool(false), + Parameters: map[string]any{ + "type": "object", + }, + }, + }}) + require.NoError(t, err) + p, err = p.disableParallelToolCalls() + require.NoError(t, err) + p, err = p.appendInputItems([]responses.ResponseInputItemUnionParam{ + responses.ResponseInputItemParamOfFunctionCallOutput("call_123", "done"), + }) + require.NoError(t, err) + + assert.True(t, json.Valid(p), "chained rewrites should produce valid JSON") + assert.Equal(t, "tool_a", gjson.GetBytes(p, "tools.0.name").String()) + assert.Equal(t, "call_123", gjson.GetBytes(p, "input.1.call_id").String()) + assert.False(t, gjson.GetBytes(p, "parallel_tool_calls").Bool()) +} + +func injectedFunctionTool(name string) responses.ToolUnionParam { + return responses.ToolUnionParam{ + OfFunction: &responses.FunctionToolParam{ + Name: name, + Description: openai.String("tool"), + Strict: openai.Bool(false), + Parameters: map[string]any{ + "type": "object", + }, + }, + } +} + +func mustPayload(t *testing.T, raw []byte) RequestPayload { + t.Helper() + + payload, err := NewRequestPayload(raw) + require.NoError(t, err) + return payload +} diff --git a/aibridge/intercept/responses/streaming.go b/aibridge/intercept/responses/streaming.go new file mode 100644 index 0000000000..15847fb4d6 --- /dev/null +++ b/aibridge/intercept/responses/streaming.go @@ -0,0 +1,221 @@ +package responses + +import ( + "context" + "errors" + "net/http" + "time" + + "github.com/google/uuid" + "github.com/openai/openai-go/v3/option" + "github.com/openai/openai-go/v3/packages/ssestream" + "github.com/openai/openai-go/v3/responses" + oaiconst "github.com/openai/openai-go/v3/shared/constant" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/intercept/eventstream" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/recorder" + "github.com/coder/coder/v2/aibridge/tracing" + "github.com/coder/quartz" +) + +const ( + streamShutdownTimeout = time.Second * 30 // TODO: configurable +) + +type StreamingResponsesInterceptor struct { + responsesInterceptionBase +} + +func NewStreamingInterceptor( + id uuid.UUID, + reqPayload RequestPayload, + providerName string, + cfg config.OpenAI, + clientHeaders http.Header, + authHeaderName string, + tracer trace.Tracer, + cred intercept.CredentialInfo, +) *StreamingResponsesInterceptor { + return &StreamingResponsesInterceptor{ + responsesInterceptionBase: responsesInterceptionBase{ + id: id, + providerName: providerName, + reqPayload: reqPayload, + cfg: cfg, + clientHeaders: clientHeaders, + authHeaderName: authHeaderName, + tracer: tracer, + credential: cred, + }, + } +} + +func (i *StreamingResponsesInterceptor) Setup(logger slog.Logger, rec recorder.Recorder, mcpProxy mcp.ServerProxier) { + i.responsesInterceptionBase.Setup(logger.Named("streaming"), rec, mcpProxy) +} + +func (*StreamingResponsesInterceptor) Streaming() bool { + return true +} + +func (i *StreamingResponsesInterceptor) TraceAttributes(r *http.Request) []attribute.KeyValue { + return i.responsesInterceptionBase.baseTraceAttributes(r, true) +} + +func (i *StreamingResponsesInterceptor) ProcessRequest(w http.ResponseWriter, r *http.Request) (outErr error) { + ctx, span := i.tracer.Start(r.Context(), "Intercept.ProcessRequest", trace.WithAttributes(tracing.InterceptionAttributesFromContext(r.Context())...)) + defer tracing.EndSpanErr(span, &outErr) + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + r = r.WithContext(ctx) // Rewire context for SSE cancellation. + + if err := i.validateRequest(ctx, w); err != nil { + return err + } + + i.injectTools() + + events := eventstream.NewEventStream(ctx, i.logger.Named("sse-sender"), nil, quartz.NewReal()) + go events.Start(w, r) + defer func() { + shutdownCtx, shutdownCancel := context.WithTimeout(ctx, streamShutdownTimeout) + defer shutdownCancel() + _ = events.Shutdown(shutdownCtx) + }() + + var respCopy responseCopier + var firstResponseID string + var completedResponse *responses.Response + var innerLoopErr error + var streamErr error + + prompt, promptFound, err := i.reqPayload.lastUserPrompt(ctx, i.logger) + if err != nil { + i.logger.Warn(ctx, "failed to get user prompt", slog.Error(err)) + } + shouldLoop := true + srv := i.newResponsesService() + + for shouldLoop { + shouldLoop = false + + respCopy = responseCopier{} + opts := i.requestOptions(&respCopy) + + // TODO(ssncferreira): inject actor headers directly in the client-header + // middleware instead of using SDK options. + if actor := aibcontext.ActorFromContext(r.Context()); actor != nil && i.cfg.SendActorHeaders { + opts = append(opts, intercept.ActorHeadersAsOpenAIOpts(actor)...) + } + stream := i.newStream(ctx, srv, opts) + + // func scope to defer steam.Close() + err := func() error { + defer stream.Close() + + if upstreamErr := stream.Err(); upstreamErr != nil { + // events stream should never be initialized + if events.IsStreaming() { + i.logger.Warn(ctx, "event stream was initialized when no response was received from upstream") + return upstreamErr + } + + // no response received from upstream (eg. client/connection error), return custom error + if !respCopy.responseReceived.Load() { + i.sendCustomErr(ctx, w, http.StatusInternalServerError, upstreamErr) + return upstreamErr + } + + // forward received response as-is + err := respCopy.forwardResp(w) + return errors.Join(upstreamErr, err) + } + + for stream.Next() { + ev := stream.Current() + + // Not every event has response.id set (eg: fixtures/openai/responses/streaming/simple.txtar). + // First event should be of 'response.created' type and have response.id set. + // Set responseID to the first response.id that is set. + if firstResponseID == "" && ev.Response.ID != "" { + firstResponseID = ev.Response.ID + } + + // Capture the response from the response.completed event. + // Only response.completed event type have 'usage' field set. + if ev.Type == string(oaiconst.ValueOf[oaiconst.ResponseCompleted]()) { + completedEvent := ev.AsResponseCompleted() + completedResponse = &completedEvent.Response + } + + // If no MCP proxy is provided then no tools are injected. + // Inner loop will never iterate more than once, so events can be forwarded as soon as received. + // + // Otherwise inner loop could iterate. Only last response should be forwarded. + // This is needed to keep consistency between response.id and response.previous_response_id fields. + if i.mcpProxy == nil { + if err := events.Send(ctx, respCopy.buff.readDelta()); err != nil { + err = xerrors.Errorf("failed to relay chunk: %w", err) + return err + } + } + } + + streamErr = stream.Err() + return nil + }() + if err != nil { + return err + } + + if i.mcpProxy != nil && completedResponse != nil { + pending := i.getPendingInjectedToolCalls(completedResponse) + shouldLoop, innerLoopErr = i.handleInnerAgenticLoop(ctx, pending, completedResponse) + if innerLoopErr != nil { + i.sendCustomErr(ctx, w, http.StatusInternalServerError, innerLoopErr) + shouldLoop = false + } + + // Record token usage for each inner loop iteration + i.recordTokenUsage(ctx, completedResponse) + } + + i.recordModelThoughts(ctx, completedResponse) + } + + if promptFound { + i.recordUserPrompt(ctx, firstResponseID, prompt) + } + i.recordNonInjectedToolUsage(ctx, completedResponse) + + // On innerLoop error custom error has been already sent, + // exit without emptying respCopy buffer. + if innerLoopErr != nil { + return innerLoopErr + } + + b, err := respCopy.readAll() + if err != nil { + return xerrors.Errorf("failed to read response body: %w", err) + } + + err = events.Send(ctx, b) + return errors.Join(err, streamErr) +} + +func (i *StreamingResponsesInterceptor) newStream(ctx context.Context, srv responses.ResponseService, opts []option.RequestOption) *ssestream.Stream[responses.ResponseStreamEventUnion] { + ctx, span := i.tracer.Start(ctx, "Intercept.ProcessRequest.Upstream", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...)) + defer span.End() + + // The body is overridden by option.WithRequestBody(reqPayload) in requestOptions + return srv.NewStreaming(ctx, responses.ResponseNewParams{}, opts...) +} diff --git a/aibridge/internal/integrationtest/apidump_test.go b/aibridge/internal/integrationtest/apidump_test.go new file mode 100644 index 0000000000..8ec62297b0 --- /dev/null +++ b/aibridge/internal/integrationtest/apidump_test.go @@ -0,0 +1,314 @@ +package integrationtest //nolint:testpackage // tests unexported internals + +import ( + "bufio" + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/fixtures" + "github.com/coder/coder/v2/aibridge/intercept/apidump" + "github.com/coder/coder/v2/aibridge/internal/testutil" + "github.com/coder/coder/v2/aibridge/provider" +) + +func TestAPIDump(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + fixture []byte + providerFunc func(addr, dumpDir string) aibridge.Provider + path string + headers http.Header + expectProviderDir string + }{ + { + name: "anthropic", + fixture: fixtures.AntSimple, + providerFunc: func(addr, dumpDir string) aibridge.Provider { + return provider.NewAnthropic(anthropicCfgWithAPIDump(addr, apiKey, dumpDir), nil) + }, + path: pathAnthropicMessages, + expectProviderDir: config.ProviderAnthropic, + }, + { + name: "openai_chat_completions", + fixture: fixtures.OaiChatSimple, + providerFunc: func(addr, dumpDir string) aibridge.Provider { + return provider.NewOpenAI(openaiCfgWithAPIDump(addr, apiKey, dumpDir)) + }, + path: pathOpenAIChatCompletions, + expectProviderDir: config.ProviderOpenAI, + }, + { + name: "openai_responses", + fixture: fixtures.OaiResponsesBlockingSimple, + providerFunc: func(addr, dumpDir string) aibridge.Provider { + return provider.NewOpenAI(openaiCfgWithAPIDump(addr, apiKey, dumpDir)) + }, + path: pathOpenAIResponses, + expectProviderDir: config.ProviderOpenAI, + }, + { + name: "copilot_chat_completions", + fixture: fixtures.OaiChatSimple, + providerFunc: func(addr, dumpDir string) aibridge.Provider { + return provider.NewCopilot(config.Copilot{BaseURL: addr, APIDumpDir: dumpDir}) + }, + path: pathCopilotChatCompletions, + headers: http.Header{"Authorization": {"Bearer test-copilot-token"}}, + expectProviderDir: config.ProviderCopilot, + }, + { + name: "copilot_responses", + fixture: fixtures.OaiResponsesBlockingSimple, + providerFunc: func(addr, dumpDir string) aibridge.Provider { + return provider.NewCopilot(config.Copilot{BaseURL: addr, APIDumpDir: dumpDir}) + }, + path: pathCopilotResponses, + headers: http.Header{"Authorization": {"Bearer test-copilot-token"}}, + expectProviderDir: config.ProviderCopilot, + }, + { + name: "copilot_custom_name_chat_completions", + fixture: fixtures.OaiChatSimple, + providerFunc: func(addr, dumpDir string) aibridge.Provider { + return provider.NewCopilot(config.Copilot{ + Name: "copilot-business", + BaseURL: addr, + APIDumpDir: dumpDir, + }) + }, + path: "/copilot-business/chat/completions", + headers: http.Header{"Authorization": {"Bearer test-copilot-token"}}, + expectProviderDir: "copilot-business", + }, + { + name: "copilot_custom_name_responses", + fixture: fixtures.OaiChatSimple, + providerFunc: func(addr, dumpDir string) aibridge.Provider { + return provider.NewCopilot(config.Copilot{ + Name: "copilot-enterprise", + BaseURL: addr, + APIDumpDir: dumpDir, + }) + }, + path: "/copilot-enterprise/chat/completions", + headers: http.Header{"Authorization": {"Bearer test-copilot-token"}}, + expectProviderDir: "copilot-enterprise", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + // Setup mock upstream server. + fix := fixtures.Parse(t, tc.fixture) + srv := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + // Create temp dir for API dumps. + dumpDir := t.TempDir() + + bridgeServer := newBridgeTestServer(ctx, t, srv.URL, + withCustomProvider(tc.providerFunc(srv.URL, dumpDir)), + ) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, fix.Request(), tc.headers) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + // Verify dump files were created. + interceptions := bridgeServer.Recorder.RecordedInterceptions() + require.Len(t, interceptions, 1) + interceptionID := interceptions[0].ID + + // Find dump files for this interception by walking the dump directory. + var reqDumpFile, respDumpFile string + err = filepath.Walk(dumpDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + // Files are named: {timestamp}-{interceptionID}.{req|resp}.txt + if strings.Contains(path, interceptionID) { + if strings.HasSuffix(path, apidump.SuffixRequest) { + reqDumpFile = path + } else if strings.HasSuffix(path, apidump.SuffixResponse) { + respDumpFile = path + } + } + return nil + }) + require.NoError(t, err) + require.NotEmpty(t, reqDumpFile, "request dump file should exist") + require.NotEmpty(t, respDumpFile, "response dump file should exist") + + // Verify dump files are in the correct provider subdirectory. + require.Contains(t, reqDumpFile, filepath.Join(dumpDir, tc.expectProviderDir)+"/", + "request dump should be in the %s provider directory", tc.expectProviderDir) + require.Contains(t, respDumpFile, filepath.Join(dumpDir, tc.expectProviderDir)+"/", + "response dump should be in the %s provider directory", tc.expectProviderDir) + + // Verify request dump contains expected HTTP request format. + reqDumpData, err := os.ReadFile(reqDumpFile) + require.NoError(t, err) + + // Parse the dumped HTTP request. + dumpReq, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(reqDumpData))) + require.NoError(t, err) + dumpBody, err := io.ReadAll(dumpReq.Body) + require.NoError(t, err) + + // Compare requests semantically (key order may differ). + require.JSONEq(t, string(dumpBody), string(fix.Request()), "request body JSON should match semantically") + + // Verify response dump contains expected HTTP response format. + respDumpData, err := os.ReadFile(respDumpFile) + require.NoError(t, err) + + // Parse the dumped HTTP response. + dumpResp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(respDumpData)), nil) + require.NoError(t, err) + defer dumpResp.Body.Close() + require.Equal(t, http.StatusOK, dumpResp.StatusCode) + dumpRespBody, err := io.ReadAll(dumpResp.Body) + require.NoError(t, err) + + // Compare responses semantically (key order may differ). + expectedRespBody := fix.NonStreaming() + require.JSONEq(t, string(expectedRespBody), string(dumpRespBody), "response body JSON should match semantically") + + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) + }) + } +} + +func TestAPIDumpPassthrough(t *testing.T) { + t.Parallel() + + const responseBody = `{"object":"list","data":[{"id":"gpt-4","object":"model"}]}` + + cases := []struct { + name string + providerFunc func(addr string, dumpDir string) aibridge.Provider + requestPath string + expectDumpName string + }{ + { + name: "anthropic", + providerFunc: func(addr string, dumpDir string) aibridge.Provider { + return provider.NewAnthropic(anthropicCfgWithAPIDump(addr, apiKey, dumpDir), nil) + }, + requestPath: "/anthropic/v1/models", + expectDumpName: "-v1-models-", + }, + { + name: "openai", + providerFunc: func(addr string, dumpDir string) aibridge.Provider { + return provider.NewOpenAI(openaiCfgWithAPIDump(addr, apiKey, dumpDir)) + }, + requestPath: "/openai/v1/models", + expectDumpName: "-models-", + }, + { + name: "copilot", + providerFunc: func(addr string, dumpDir string) aibridge.Provider { + return provider.NewCopilot(config.Copilot{BaseURL: addr, APIDumpDir: dumpDir}) + }, + requestPath: "/copilot/models", + expectDumpName: "-models-", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(responseBody)) + })) + t.Cleanup(upstream.Close) + + dumpDir := t.TempDir() + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, + withCustomProvider(tc.providerFunc(upstream.URL, dumpDir)), + ) + + resp, err := bridgeServer.makeRequest(t, http.MethodGet, tc.requestPath, nil) + require.NoError(t, err) + defer resp.Body.Close() + + // Find dump files in the passthrough directory. + passthroughDir := filepath.Join(dumpDir, tc.name, "passthrough") + var reqDumpFile, respDumpFile string + err = filepath.Walk(passthroughDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if strings.HasSuffix(path, apidump.SuffixRequest) { + reqDumpFile = path + } else if strings.HasSuffix(path, apidump.SuffixResponse) { + respDumpFile = path + } + return nil + }) + require.NoError(t, err, "walking failed: %v", err) + + require.NotEmpty(t, reqDumpFile, "request dump file should exist") + require.FileExists(t, reqDumpFile) + require.Contains(t, reqDumpFile, "/passthrough/") + require.Contains(t, reqDumpFile, tc.expectDumpName) + + require.NotEmpty(t, respDumpFile, "response dump file should exist") + require.FileExists(t, respDumpFile) + require.Contains(t, respDumpFile, "/passthrough/") + require.Contains(t, respDumpFile, tc.expectDumpName) + + // Verify request dump. + reqDumpData, err := os.ReadFile(reqDumpFile) + require.NoError(t, err) + dumpReq, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(reqDumpData))) + require.NoError(t, err) + require.Equal(t, http.MethodGet, dumpReq.Method) + + // Verify response dump. + respDumpData, err := os.ReadFile(respDumpFile) + require.NoError(t, err) + dumpResp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(respDumpData)), nil) + require.NoError(t, err) + defer dumpResp.Body.Close() + require.Equal(t, http.StatusOK, dumpResp.StatusCode) + dumpRespBody, err := io.ReadAll(dumpResp.Body) + require.NoError(t, err) + require.JSONEq(t, responseBody, string(dumpRespBody)) + }) + } +} diff --git a/aibridge/internal/integrationtest/bridge_test.go b/aibridge/internal/integrationtest/bridge_test.go new file mode 100644 index 0000000000..80ebf49915 --- /dev/null +++ b/aibridge/internal/integrationtest/bridge_test.go @@ -0,0 +1,2134 @@ +package integrationtest //nolint:testpackage // tests unexported internals + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "slices" + "strings" + "testing" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/packages/ssestream" + "github.com/anthropics/anthropic-sdk-go/shared/constant" + "github.com/google/uuid" + "github.com/openai/openai-go/v3" + oaissestream "github.com/openai/openai-go/v3/packages/ssestream" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "go.uber.org/goleak" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/fixtures" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/internal/testutil" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/provider" + "github.com/coder/coder/v2/aibridge/recorder" + "github.com/coder/coder/v2/aibridge/utils" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestAnthropicMessages(t *testing.T) { + t.Parallel() + + t.Run("single builtin tool", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + streaming bool + expectedInputTokens int + expectedOutputTokens int + expectedCacheReadInputTokens int + expectedCacheWriteInputTokens int + expectedToolCallID string + }{ + { + name: "streaming", + streaming: true, + expectedInputTokens: 2, + expectedOutputTokens: 66, + expectedCacheReadInputTokens: 13993, + expectedCacheWriteInputTokens: 22, + expectedToolCallID: "toolu_01RX68weRSquLx6HUTj65iBo", + }, + { + name: "non-streaming", + streaming: false, + expectedInputTokens: 5, + expectedOutputTokens: 84, + expectedCacheReadInputTokens: 23490, + expectedCacheWriteInputTokens: 0, + expectedToolCallID: "toolu_01AusGgY5aKFhzWrFBv9JfHq", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, fixtures.AntSingleBuiltinTool) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL) + + // Make API call to aibridge for Anthropic /v1/messages + reqBody, err := sjson.SetBytes(fix.Request(), "stream", tc.streaming) + require.NoError(t, err) + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Response-specific checks. + if tc.streaming { + sp := aibridge.NewSSEParser() + require.NoError(t, sp.Parse(resp.Body)) + + // Ensure the message starts and completes, at a minimum. + assert.Contains(t, sp.AllEvents(), "message_start") + assert.Contains(t, sp.AllEvents(), "message_stop") + } + + expectedTokenRecordings := 1 + if tc.streaming { + // One for message_start, one for message_delta. + expectedTokenRecordings = 2 + } + tokenUsages := bridgeServer.Recorder.RecordedTokenUsages() + require.Len(t, tokenUsages, expectedTokenRecordings) + + assert.EqualValues(t, tc.expectedInputTokens, bridgeServer.Recorder.TotalInputTokens(), "input tokens miscalculated") + assert.EqualValues(t, tc.expectedOutputTokens, bridgeServer.Recorder.TotalOutputTokens(), "output tokens miscalculated") + assert.EqualValues(t, tc.expectedCacheReadInputTokens, bridgeServer.Recorder.TotalCacheReadInputTokens(), "cache read input tokens miscalculated") + assert.EqualValues(t, tc.expectedCacheWriteInputTokens, bridgeServer.Recorder.TotalCacheWriteInputTokens(), "cache write input tokens miscalculated") + + toolUsages := bridgeServer.Recorder.RecordedToolUsages() + require.Len(t, toolUsages, 1) + assert.Equal(t, "Read", toolUsages[0].Tool) + assert.Equal(t, tc.expectedToolCallID, toolUsages[0].ToolCallID) + require.IsType(t, json.RawMessage{}, toolUsages[0].Args) + var args map[string]any + require.NoError(t, json.Unmarshal(toolUsages[0].Args.(json.RawMessage), &args)) + require.Contains(t, args, "file_path") + assert.Equal(t, "/tmp/blah/foo", args["file_path"]) + + promptUsages := bridgeServer.Recorder.RecordedPromptUsages() + require.Len(t, promptUsages, 1) + assert.Equal(t, "read the foo file", promptUsages[0].Prompt) + + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) + }) + } + }) +} + +func TestAnthropicMessagesModelThoughts(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + streaming bool + fixture []byte + expectedThoughts []recorder.ModelThoughtRecord // nil means no model thoughts expected + }{ + { + name: "single thinking block/streaming", + streaming: true, + fixture: fixtures.AntSingleBuiltinTool, + expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("The user wants me to read", recorder.ThoughtSourceThinking)}, + }, + { + name: "single thinking block/blocking", + streaming: false, + fixture: fixtures.AntSingleBuiltinTool, + expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("The user wants me to read", recorder.ThoughtSourceThinking)}, + }, + { + name: "multiple thinking blocks/streaming", + streaming: true, + fixture: fixtures.AntMultiThinkingBuiltinTool, + expectedThoughts: []recorder.ModelThoughtRecord{ + newModelThought("The user wants me to read", recorder.ThoughtSourceThinking), + newModelThought("I should use the Read tool", recorder.ThoughtSourceThinking), + }, + }, + { + name: "multiple thinking blocks/blocking", + streaming: false, + fixture: fixtures.AntMultiThinkingBuiltinTool, + expectedThoughts: []recorder.ModelThoughtRecord{ + newModelThought("The user wants me to read", recorder.ThoughtSourceThinking), + newModelThought("I should use the Read tool", recorder.ThoughtSourceThinking), + }, + }, + { + name: "parallel tool calls/streaming", + streaming: true, + fixture: fixtures.AntSingleBuiltinToolParallel, + expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("The user wants me to read two files", recorder.ThoughtSourceThinking)}, + }, + { + name: "parallel tool calls/blocking", + streaming: false, + fixture: fixtures.AntSingleBuiltinToolParallel, + expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("The user wants me to read two files", recorder.ThoughtSourceThinking)}, + }, + { + name: "thoughts without tool calls/streaming", + streaming: true, + fixture: fixtures.AntSimple, + expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("This is a classic philosophical question about medieval scholasticism", recorder.ThoughtSourceThinking)}, + }, + { + name: "thoughts without tool calls/blocking", + streaming: false, + fixture: fixtures.AntSimple, + expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("This is a classic philosophical question about medieval scholasticism", recorder.ThoughtSourceThinking)}, + }, + { + name: "no thoughts captured", + streaming: false, + fixture: fixtures.AntSingleInjectedTool, + expectedThoughts: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL) + + reqBody, err := sjson.SetBytes(fix.Request(), "stream", tc.streaming) + require.NoError(t, err) + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + if tc.streaming { + sp := aibridge.NewSSEParser() + require.NoError(t, sp.Parse(resp.Body)) + assert.Contains(t, sp.AllEvents(), "message_start") + assert.Contains(t, sp.AllEvents(), "message_stop") + } + + bridgeServer.Recorder.VerifyModelThoughtsRecorded(t, tc.expectedThoughts) + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) + }) + } +} + +func TestAWSBedrockIntegration(t *testing.T) { + t.Parallel() + + t.Run("invalid config", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + // Invalid bedrock config - missing region & base url + bedrockCfg := &config.AWSBedrock{ + Region: "", + AccessKey: "test-key", + AccessKeySecret: "test-secret", + Model: "test-model", + SmallFastModel: "test-haiku", + } + + bridgeServer := newBridgeTestServer(ctx, t, "http://unused", + withCustomProvider(provider.NewAnthropic(anthropicCfg("http://unused", apiKey), bedrockCfg)), + ) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, fixtures.Request(t, fixtures.AntSingleBuiltinTool)) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusInternalServerError, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, string(body), "create anthropic client") + require.Contains(t, string(body), "region or base url required") + }) + + t.Run("/v1/messages", func(t *testing.T) { + for _, streaming := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/streaming=%v", t.Name(), streaming), func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, fixtures.AntSingleBuiltinTool) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + // We define region here to validate that with Region & BaseURL defined, the latter takes precedence. + bedrockCfg := &config.AWSBedrock{ + Region: "us-west-2", + AccessKey: "test-access-key", + AccessKeySecret: "test-secret-key", + Model: "danthropic", // This model should override the request's given one. + SmallFastModel: "danthropic-mini", // Unused but needed for validation. + BaseURL: upstream.URL, // Use the mock server. + } + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, + withCustomProvider(provider.NewAnthropic(anthropicCfg(upstream.URL, apiKey), bedrockCfg)), + ) + + // Make API call to aibridge for Anthropic /v1/messages, which will be routed via AWS Bedrock. + // We override the AWS Bedrock client to route requests through our mock server. + reqBody, err := sjson.SetBytes(fix.Request(), "stream", streaming) + require.NoError(t, err) + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody) + require.NoError(t, err) + defer resp.Body.Close() + + // For streaming responses, consume the body to allow the stream to complete. + if streaming { + // Read the streaming response. + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + } + + // Verify that Bedrock-specific model name was used in the request to the mock server + // and the interception data. + received := upstream.receivedRequests() + require.Len(t, received, 1) + + // The Anthropic SDK's Bedrock middleware extracts "model" and "stream" + // from the JSON body and encodes them in the URL path. + // See: https://github.com/anthropics/anthropic-sdk-go/blob/4d669338f2041f3c60640b6dd317c4895dc71cd4/bedrock/bedrock.go#L247-L248 + pathParts := strings.Split(received[0].Path, "/") + require.True(t, len(pathParts) >= 3 && pathParts[1] == "model", "unexpected path: %s", received[0].Path) + require.Equal(t, bedrockCfg.Model, pathParts[2]) + require.False(t, gjson.GetBytes(received[0].Body, "model").Exists(), "model should be stripped from body") + require.False(t, gjson.GetBytes(received[0].Body, "stream").Exists(), "stream should be stripped from body") + + interceptions := bridgeServer.Recorder.RecordedInterceptions() + require.Len(t, interceptions, 1) + require.Equal(t, interceptions[0].Model, bedrockCfg.Model) + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) + }) + } + }) + + // Tests that Bedrock-incompatible fields are stripped and adaptive thinking + // is handled correctly per model. Different Bedrock model names trigger + // different behavior for beta flag filtering and field stripping. + t.Run("unsupported fields removed", func(t *testing.T) { + t.Parallel() + + // All fields in the fixture request that Bedrock may strip. Fields + // listed in a test case's expectKeptFields survive; all others must + // be absent from the forwarded body. + strippableFields := []string{ + "metadata", "service_tier", "container", "inference_geo", // always stripped + "output_config", "context_management", // stripped unless their beta flag survives + } + + cases := []struct { + name string + model string + smallFastModel string + expectThinkingType string + expectBudgetTokens int64 // 0 means budget_tokens should not be present + expectKeptFields []string // fields from strippableFields expected to survive + expectedBetaFlags []string // values expected in the anthropic_beta array in the forwarded body + }{ + // "beddel" matches no model prefix, so adaptive thinking is converted + // to enabled with budget, and all model-gated beta flags are stripped. + { + name: "beddel", + model: "beddel", + smallFastModel: "modrock", + expectThinkingType: "enabled", + expectBudgetTokens: 16000, // 32000 * 0.5 (medium effort) + expectedBetaFlags: []string{"interleaved-thinking-2025-05-14"}, + }, + // Opus 4.5 supports the effort beta, so output_config is kept. + { + name: "opus-4.5", + model: "anthropic.claude-opus-4-5-20250514-v1:0", + smallFastModel: "anthropic.claude-haiku-4-5-20241022-v1:0", + expectThinkingType: "enabled", + expectBudgetTokens: 16000, + expectKeptFields: []string{"output_config"}, + expectedBetaFlags: []string{"interleaved-thinking-2025-05-14", "effort-2025-11-24"}, + }, + // Sonnet 4.5 supports context-management beta, so context_management is kept. + { + name: "sonnet-4.5", + model: "anthropic.claude-sonnet-4-5-20241022-v2:0", + smallFastModel: "anthropic.claude-haiku-4-5-20241022-v1:0", + expectThinkingType: "enabled", + expectBudgetTokens: 16000, + expectKeptFields: []string{"context_management"}, + expectedBetaFlags: []string{"interleaved-thinking-2025-05-14", "context-management-2025-06-27"}, + }, + // Opus 4.6 supports adaptive thinking natively, so it is kept as-is. + // Neither effort nor context-management betas apply to this model. + { + name: "opus-4.6", + model: "anthropic.claude-opus-4-6-20260619-v1:0", + smallFastModel: "anthropic.claude-haiku-4-5-20241022-v1:0", + expectThinkingType: "adaptive", + expectedBetaFlags: []string{"interleaved-thinking-2025-05-14"}, + }, + } + + for _, tc := range cases { + for _, streaming := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/streaming=%v", tc.name, streaming), func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, fixtures.AntSimpleBedrock) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + bCfg := &config.AWSBedrock{ + Region: "us-west-2", + AccessKey: "test-access-key", + AccessKeySecret: "test-secret-key", + Model: tc.model, + SmallFastModel: tc.smallFastModel, + BaseURL: upstream.URL, + } + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, + withCustomProvider(provider.NewAnthropic(anthropicCfg(upstream.URL, apiKey), bCfg)), + ) + + reqBody, err := sjson.SetBytes(fix.Request(), "stream", streaming) + require.NoError(t, err) + + // Send with Anthropic-Beta header containing flags that should be filtered. + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody, http.Header{ + "Anthropic-Beta": {"interleaved-thinking-2025-05-14,effort-2025-11-24,context-management-2025-06-27,prompt-caching-scope-2026-01-05"}, + }) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + received := upstream.receivedRequests() + require.Len(t, received, 1) + body := received[0].Body + + // Verify strippable fields: kept only if listed in expectKeptFields. + for _, field := range strippableFields { + assert.Equal(t, slices.Contains(tc.expectKeptFields, field), gjson.GetBytes(body, field).Exists(), "field %s", field) + } + + // Verify thinking behavior. + assert.Equal(t, tc.expectThinkingType, gjson.GetBytes(body, "thinking.type").String(), "thinking type mismatch") + if tc.expectBudgetTokens > 0 { + assert.Equal(t, tc.expectBudgetTokens, gjson.GetBytes(body, "thinking.budget_tokens").Int(), "budget_tokens mismatch") + } else { + assert.False(t, gjson.GetBytes(body, "thinking.budget_tokens").Exists(), "budget_tokens should not be present") + } + + // The Bedrock SDK middleware moves Anthropic-Beta from the header + // into the body as "anthropic_beta". + betaArr := gjson.GetBytes(body, "anthropic_beta").Array() + var gotFlags []string + for _, v := range betaArr { + gotFlags = append(gotFlags, v.String()) + } + assert.Equal(t, tc.expectedBetaFlags, gotFlags, "beta flags mismatch") + + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) + }) + } + } + }) +} + +func TestOpenAIChatCompletions(t *testing.T) { + t.Parallel() + + t.Run("single builtin tool", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + streaming bool + expectedInputTokens, expectedOutputTokens int + expectedToolCallID string + }{ + { + name: "streaming", + streaming: true, + expectedInputTokens: 60, + expectedOutputTokens: 15, + expectedToolCallID: "call_HjeqP7YeRkoNj0de9e3U4X4B", + }, + { + name: "non-streaming", + streaming: false, + expectedInputTokens: 60, + expectedOutputTokens: 15, + expectedToolCallID: "call_KjzAbhiZC6nk81tQzL7pwlpc", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, fixtures.OaiChatSingleBuiltinTool) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL) + + // Make API call to aibridge for OpenAI /v1/chat/completions + reqBody, err := sjson.SetBytes(fix.Request(), "stream", tc.streaming) + require.NoError(t, err) + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathOpenAIChatCompletions, reqBody) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Response-specific checks. + if tc.streaming { + sp := aibridge.NewSSEParser() + require.NoError(t, sp.Parse(resp.Body)) + + // OpenAI sends all events under the same type. + messageEvents := sp.MessageEvents() + assert.NotEmpty(t, messageEvents) + + // OpenAI streaming ends with [DONE] + lastEvent := messageEvents[len(messageEvents)-1] + assert.Equal(t, "[DONE]", lastEvent.Data) + } + + tokenUsages := bridgeServer.Recorder.RecordedTokenUsages() + require.Len(t, tokenUsages, 1) + assert.EqualValues(t, tc.expectedInputTokens, bridgeServer.Recorder.TotalInputTokens(), "input tokens miscalculated") + assert.EqualValues(t, tc.expectedOutputTokens, bridgeServer.Recorder.TotalOutputTokens(), "output tokens miscalculated") + + toolUsages := bridgeServer.Recorder.RecordedToolUsages() + require.Len(t, toolUsages, 1) + assert.Equal(t, "read_file", toolUsages[0].Tool) + assert.Equal(t, tc.expectedToolCallID, toolUsages[0].ToolCallID) + require.IsType(t, map[string]any{}, toolUsages[0].Args) + require.Contains(t, toolUsages[0].Args, "path") + assert.Equal(t, "README.md", toolUsages[0].Args.(map[string]any)["path"]) + + promptUsages := bridgeServer.Recorder.RecordedPromptUsages() + require.Len(t, promptUsages, 1) + assert.Equal(t, "how large is the README.md file in my current path", promptUsages[0].Prompt) + + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) + }) + } + }) + + t.Run("streaming injected tool call edge cases", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + fixture []byte + expectedArgs map[string]any + }{ + { + name: "tool call no preamble", + fixture: fixtures.OaiChatStreamingInjectedToolNoPreamble, + expectedArgs: map[string]any{"owner": "me"}, + }, + { + name: "tool call with non-zero index", + fixture: fixtures.OaiChatStreamingInjectedToolNonzeroIndex, + expectedArgs: nil, // No arguments in this fixture + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + // Setup mock server for multi-turn interaction. + // First request → tool call response, second → tool response. + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix), newFixtureToolResponse(fix)) + + // Setup MCP proxies with the tool from the fixture + mockMCP := setupMCPForTest(t, defaultTracer) + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, + withMCP(mockMCP), + ) + + // Add the stream param to the request. + reqBody, err := sjson.SetBytes(fix.Request(), "stream", true) + require.NoError(t, err) + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathOpenAIChatCompletions, reqBody) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify SSE headers are sent correctly + require.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) + require.Equal(t, "no-cache", resp.Header.Get("Cache-Control")) + require.Equal(t, "keep-alive", resp.Header.Get("Connection")) + + // Consume the full response body to ensure the interception completes + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + // Verify the MCP tool was actually invoked + invocations := mockMCP.getCallsByTool(mockToolName) + require.Len(t, invocations, 1, "expected MCP tool to be invoked") + + // Verify tool was invoked with the expected args (if specified) + if tc.expectedArgs != nil { + expected, err := json.Marshal(tc.expectedArgs) + require.NoError(t, err) + actual, err := json.Marshal(invocations[0]) + require.NoError(t, err) + require.EqualValues(t, expected, actual) + } + + // Verify tool usage was recorded + toolUsages := bridgeServer.Recorder.RecordedToolUsages() + require.Len(t, toolUsages, 1) + assert.Equal(t, mockToolName, toolUsages[0].Tool) + + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) + }) + } + }) +} + +func TestSimple(t *testing.T) { + t.Parallel() + + getAnthropicResponseID := func(streaming bool, resp *http.Response) (string, error) { + if streaming { + decoder := ssestream.NewDecoder(resp) + stream := ssestream.NewStream[anthropic.MessageStreamEventUnion](decoder, nil) + var message anthropic.Message + for stream.Next() { + event := stream.Current() + if err := message.Accumulate(event); err != nil { + return "", xerrors.Errorf("accumulate event: %w", err) + } + } + if stream.Err() != nil { + return "", xerrors.Errorf("stream error: %w", stream.Err()) + } + return message.ID, nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", xerrors.Errorf("read body: %w", err) + } + + var message anthropic.Message + if err := json.Unmarshal(body, &message); err != nil { + return "", xerrors.Errorf("unmarshal response: %w", err) + } + return message.ID, nil + } + + getOpenAIResponseID := func(streaming bool, resp *http.Response) (string, error) { + if streaming { + // Parse the response stream. + decoder := oaissestream.NewDecoder(resp) + stream := oaissestream.NewStream[openai.ChatCompletionChunk](decoder, nil) + var message openai.ChatCompletionAccumulator + for stream.Next() { + chunk := stream.Current() + message.AddChunk(chunk) + } + if stream.Err() != nil { + return "", xerrors.Errorf("stream error: %w", stream.Err()) + } + return message.ID, nil + } + + // Parse & unmarshal the response. + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", xerrors.Errorf("read body: %w", err) + } + + var message openai.ChatCompletion + if err := json.Unmarshal(body, &message); err != nil { + return "", xerrors.Errorf("unmarshal response: %w", err) + } + return message.ID, nil + } + + testCases := []struct { + name string + fixture []byte + basePath string + expectedPath string + getResponseIDFunc func(streaming bool, resp *http.Response) (string, error) + path string + expectedMsgID string + userAgent string + expectedClient aibridge.Client + }{ + { + name: config.ProviderAnthropic, + fixture: fixtures.AntSimple, + basePath: "", + expectedPath: "/v1/messages", + getResponseIDFunc: getAnthropicResponseID, + path: pathAnthropicMessages, + expectedMsgID: "msg_01Pvyf26bY17RcjmWfJsXGBn", + userAgent: "claude-cli/2.0.67 (external, cli)", + expectedClient: aibridge.ClientClaudeCode, + }, + { + name: config.ProviderAnthropic + "_haiku_prompt_capture", + fixture: fixtures.AntHaikuSimple, + basePath: "", + expectedPath: "/v1/messages", + getResponseIDFunc: getAnthropicResponseID, + path: pathAnthropicMessages, + expectedMsgID: "msg_01Pvyf26bY17RcjmWfJsXGBn", + userAgent: "claude-cli/2.0.67 (external, cli)", + expectedClient: aibridge.ClientClaudeCode, + }, + { + name: config.ProviderOpenAI, + fixture: fixtures.OaiChatSimple, + basePath: "", + expectedPath: "/chat/completions", + getResponseIDFunc: getOpenAIResponseID, + path: pathOpenAIChatCompletions, + expectedMsgID: "chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N", + userAgent: "codex_cli_rs/0.87.0 (Mac OS 26.2.0; arm64)", + expectedClient: aibridge.ClientCodex, + }, + { + name: config.ProviderAnthropic + "_baseURL_path", + fixture: fixtures.AntSimple, + basePath: "/api", + expectedPath: "/api/v1/messages", + getResponseIDFunc: getAnthropicResponseID, + path: pathAnthropicMessages, + expectedMsgID: "msg_01Pvyf26bY17RcjmWfJsXGBn", + userAgent: "GitHubCopilotChat/0.37.2026011603", + expectedClient: aibridge.ClientCopilotVSC, + }, + { + name: config.ProviderOpenAI + "_baseURL_path", + fixture: fixtures.OaiChatSimple, + basePath: "/api", + expectedPath: "/api/chat/completions", + getResponseIDFunc: getOpenAIResponseID, + path: pathOpenAIChatCompletions, + expectedMsgID: "chatcmpl-BwoiPTGRbKkY5rncfaM0s9KtWrq5N", + userAgent: "Zed/0.219.4+stable.119.abc123 (macos; aarch64)", + expectedClient: aibridge.ClientZed, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + for _, streaming := range []bool{true, false} { + t.Run(fmt.Sprintf("streaming=%v", streaming), func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL+tc.basePath) + + // When: calling the "API server" with the fixture's request body. + reqBody, err := sjson.SetBytes(fix.Request(), "stream", streaming) + require.NoError(t, err) + resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, reqBody, http.Header{"User-Agent": {tc.userAgent}}) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Then: I expect the upstream request to have the correct path. + received := upstream.receivedRequests() + require.Len(t, received, 1) + require.Equal(t, tc.expectedPath, received[0].Path) + + // Then: I expect a non-empty response. + bodyBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.NotEmpty(t, bodyBytes, "should have received response body") + + // Reset the body after being read. + resp.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + + // Then: I expect the prompt to have been tracked. + promptUsages := bridgeServer.Recorder.RecordedPromptUsages() + require.NotEmpty(t, promptUsages, "no prompts tracked") + assert.Contains(t, promptUsages[0].Prompt, "how many angels can dance on the head of a pin") + + // Validate that responses have their IDs overridden with a interception ID rather than the original ID from the upstream provider. + // The reason for this is that Bridge may make multiple upstream requests (i.e. to invoke injected tools), and clients will not be expecting + // multiple messages in response to a single request. + id, err := tc.getResponseIDFunc(streaming, resp) + require.NoError(t, err, "failed to retrieve response ID") + require.Nilf(t, uuid.Validate(id), "%s is not a valid UUID", id) + + tokenUsages := bridgeServer.Recorder.RecordedTokenUsages() + require.GreaterOrEqual(t, len(tokenUsages), 1) + require.Equal(t, tokenUsages[0].MsgID, tc.expectedMsgID) + + // Validate user agent and client have been recorded. + interceptions := bridgeServer.Recorder.RecordedInterceptions() + require.Len(t, interceptions, 1, "expected exactly one interception, got: %v", interceptions) + assert.Equal(t, id, interceptions[0].ID) + assert.Equal(t, tc.userAgent, interceptions[0].UserAgent) + assert.Equal(t, string(tc.expectedClient), interceptions[0].Client) + + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) + }) + } + }) + } +} + +func TestSessionIDTracking(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + fixture []byte + header http.Header + metadataSessionID string + expectedClient aibridge.Client + expectSessionID string + }{ + // Session in header. + { + name: "mux", + fixture: fixtures.AntSimple, + expectedClient: aibridge.ClientMux, + expectSessionID: "mux-workspace-321", + header: http.Header{ + "User-Agent": []string{"mux/1.0.0"}, + "X-Mux-Workspace-Id": []string{"mux-workspace-321"}, + }, + }, + // Session in body. + { + name: "claude_code", + fixture: fixtures.AntSimple, + expectedClient: aibridge.ClientClaudeCode, + expectSessionID: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + header: http.Header{ + "User-Agent": []string{"claude-cli/2.0.67 (external, cli)"}, + }, + metadataSessionID: "user_abc123_account_456_session_f47ac10b-58cc-4372-a567-0e02b2c3d479", + }, + // No session. + { + name: "zed", + fixture: fixtures.AntSimple, + expectedClient: aibridge.ClientZed, + header: http.Header{ + "User-Agent": []string{"Zed/0.219.4+stable.119.abc123 (macos; aarch64)"}, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, withProvider(config.ProviderAnthropic)) + + reqBody := fix.Request() + if tc.metadataSessionID != "" { + var err error + reqBody, err = sjson.SetBytes(reqBody, "metadata.user_id", tc.metadataSessionID) + require.NoError(t, err) + } + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody, tc.header) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Drain the body to let the stream complete. + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + interceptions := bridgeServer.Recorder.RecordedInterceptions() + require.Len(t, interceptions, 1, "expected exactly one interception") + assert.Equal(t, string(tc.expectedClient), interceptions[0].Client) + + if tc.expectSessionID == "" { + assert.Nil(t, interceptions[0].ClientSessionID, "expected nil session ID for %s", tc.name) + } else { + require.NotNil(t, interceptions[0].ClientSessionID, "expected non-nil session ID for %s", tc.name) + assert.Equal(t, tc.expectSessionID, *interceptions[0].ClientSessionID) + } + + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) + }) + } +} + +func TestFallthrough(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + fixture []byte + basePath string + requestPath string + expectedUpstreamPath string + expectAuthHeader string + }{ + { + name: "ant_empty_base_url_path", + fixture: fixtures.AntFallthrough, + basePath: "", + requestPath: "/anthropic/v1/models", + expectedUpstreamPath: "/v1/models", + expectAuthHeader: "X-Api-Key", + }, + { + name: "oai_empty_base_url_path", + fixture: fixtures.OaiChatFallthrough, + basePath: "", + requestPath: "/openai/v1/models", + expectedUpstreamPath: "/models", + expectAuthHeader: "Authorization", + }, + { + name: "ant_some_base_url_path", + fixture: fixtures.AntFallthrough, + basePath: "/api", + requestPath: "/anthropic/v1/models", + expectedUpstreamPath: "/api/v1/models", + expectAuthHeader: "X-Api-Key", + }, + { + name: "oai_some_base_url_path", + fixture: fixtures.OaiChatFallthrough, + basePath: "/api", + requestPath: "/openai/v1/models", + expectedUpstreamPath: "/api/models", + expectAuthHeader: "Authorization", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(t.Context(), t, newFixtureResponse(fix)) + bridgeServer := newBridgeTestServer(t.Context(), t, upstream.URL+tc.basePath) + + resp, err := bridgeServer.makeRequest(t, http.MethodGet, tc.requestPath, nil) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify upstream received the request at the expected path + // with the API key header. + received := upstream.receivedRequests() + require.Len(t, received, 1) + require.Equal(t, tc.expectedUpstreamPath, received[0].Path) + require.Contains(t, received[0].Header.Get(tc.expectAuthHeader), apiKey) + + gotBytes, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // Compare JSON bodies for semantic equality. + var got any + var exp any + require.NoError(t, json.Unmarshal(gotBytes, &got)) + require.NoError(t, json.Unmarshal(fix.NonStreaming(), &exp)) + require.EqualValues(t, exp, got) + }) + } +} + +func TestAnthropicInjectedTools(t *testing.T) { + t.Parallel() + + for _, streaming := range []bool{true, false} { + t.Run(fmt.Sprintf("streaming=%v", streaming), func(t *testing.T) { + t.Parallel() + + // Build the requirements & make the assertions which are common to all providers. + bridgeServer, mockMCP, resp := setupInjectedToolTest(t, fixtures.AntSingleInjectedTool, streaming, defaultTracer, pathAnthropicMessages, anthropicToolResultValidator(t)) + defer resp.Body.Close() + + // Ensure expected tool was invoked with expected input. + toolUsages := bridgeServer.Recorder.RecordedToolUsages() + require.Len(t, toolUsages, 1) + require.Equal(t, mockToolName, toolUsages[0].Tool) + expected, err := json.Marshal(map[string]any{"owner": "admin"}) + require.NoError(t, err) + actual, err := json.Marshal(toolUsages[0].Args) + require.NoError(t, err) + require.EqualValues(t, expected, actual) + invocations := mockMCP.getCallsByTool(mockToolName) + require.Len(t, invocations, 1) + actual, err = json.Marshal(invocations[0]) + require.NoError(t, err) + require.EqualValues(t, expected, actual) + + var ( + content *anthropic.ContentBlockUnion + message anthropic.Message + ) + if streaming { + // Parse the response stream. + decoder := ssestream.NewDecoder(resp) + stream := ssestream.NewStream[anthropic.MessageStreamEventUnion](decoder, nil) + for stream.Next() { + event := stream.Current() + require.NoError(t, message.Accumulate(event), "accumulate event") + } + + require.NoError(t, stream.Err(), "stream error") + require.Len(t, message.Content, 2) + + content = &message.Content[1] + } else { + // Parse & unmarshal the response. + body, err := io.ReadAll(resp.Body) + require.NoError(t, err, "read response body") + + require.NoError(t, json.Unmarshal(body, &message), "unmarshal response") + require.GreaterOrEqual(t, len(message.Content), 1) + + content = &message.Content[0] + } + + // Ensure tool returned expected value. + require.NotNil(t, content) + require.Contains(t, content.Text, "dd711d5c-83c6-4c08-a0af-b73055906e8c") // The ID of the workspace to be returned. + + // Check the token usage from the client's perspective. + // + // We overwrite the final message_delta which is relayed to the client to include the + // accumulated tokens but currently the SDK only supports accumulating output tokens + // for message_delta events. + // + // For non-streaming requests the token usage is also overwritten and should be faithfully + // represented in the response. + // + // See https://github.com/anthropics/anthropic-sdk-go/blob/v1.12.0/message.go#L2619-L2622 + if !streaming { + assert.EqualValues(t, 15308, message.Usage.InputTokens) + } + assert.EqualValues(t, 204, message.Usage.OutputTokens) + + // Ensure tokens used during injected tool invocation are accounted for. + assert.EqualValues(t, 15308, bridgeServer.Recorder.TotalInputTokens()) + assert.EqualValues(t, 204, bridgeServer.Recorder.TotalOutputTokens()) + + // Ensure we received exactly one prompt. + promptUsages := bridgeServer.Recorder.RecordedPromptUsages() + require.Len(t, promptUsages, 1) + }) + } +} + +func TestOpenAIInjectedTools(t *testing.T) { + t.Parallel() + + for _, streaming := range []bool{true, false} { + t.Run(fmt.Sprintf("streaming=%v", streaming), func(t *testing.T) { + t.Parallel() + + // Build the requirements & make the assertions which are common to all providers. + bridgeServer, mockMCP, resp := setupInjectedToolTest(t, fixtures.OaiChatSingleInjectedTool, streaming, defaultTracer, pathOpenAIChatCompletions, openaiChatToolResultValidator(t)) + defer resp.Body.Close() + + // Ensure expected tool was invoked with expected input. + toolUsages := bridgeServer.Recorder.RecordedToolUsages() + require.Len(t, toolUsages, 1) + require.Equal(t, mockToolName, toolUsages[0].Tool) + expected, err := json.Marshal(map[string]any{"owner": "admin"}) + require.NoError(t, err) + actual, err := json.Marshal(toolUsages[0].Args) + require.NoError(t, err) + require.EqualValues(t, expected, actual) + invocations := mockMCP.getCallsByTool(mockToolName) + require.Len(t, invocations, 1) + actual, err = json.Marshal(invocations[0]) + require.NoError(t, err) + require.EqualValues(t, expected, actual) + + var ( + content *openai.ChatCompletionChoice + message openai.ChatCompletion + ) + if streaming { + // Parse the response stream. + decoder := oaissestream.NewDecoder(resp) + stream := oaissestream.NewStream[openai.ChatCompletionChunk](decoder, nil) + var acc openai.ChatCompletionAccumulator + detectedToolCalls := make(map[string]struct{}) + for stream.Next() { + chunk := stream.Current() + acc.AddChunk(chunk) + + if len(chunk.Choices) == 0 { + continue + } + + for _, c := range chunk.Choices { + if len(c.Delta.ToolCalls) == 0 { + continue + } + + for _, t := range c.Delta.ToolCalls { + if t.Function.Name == "" { + continue + } + + detectedToolCalls[t.Function.Name] = struct{}{} + } + } + } + + // Verify that no injected tool call events (or partials thereof) were sent to the client. + require.Len(t, detectedToolCalls, 0) + + message = acc.ChatCompletion + require.NoError(t, stream.Err(), "stream error") + } else { + // Parse & unmarshal the response. + body, err := io.ReadAll(resp.Body) + require.NoError(t, err, "read response body") + require.NoError(t, json.Unmarshal(body, &message), "unmarshal response") + + // Verify that no injected tools were sent to the client. + require.GreaterOrEqual(t, len(message.Choices), 1) + require.Len(t, message.Choices[0].Message.ToolCalls, 0) + } + + require.GreaterOrEqual(t, len(message.Choices), 1) + content = &message.Choices[0] + + // Ensure tool returned expected value. + require.NotNil(t, content) + require.Contains(t, content.Message.Content, "dd711d5c-83c6-4c08-a0af-b73055906e8c") // The ID of the workspace to be returned. + + // Check the token usage from the client's perspective. + // This *should* work but the openai SDK doesn't accumulate the prompt token details :(. + // See https://github.com/openai/openai-go/blob/v2.7.0/streamaccumulator.go#L145-L147. + // assert.EqualValues(t, 5047, message.Usage.PromptTokens-message.Usage.PromptTokensDetails.CachedTokens) + assert.EqualValues(t, 105, message.Usage.CompletionTokens) + + // Ensure tokens used during injected tool invocation are accounted for. + require.EqualValues(t, 5047, bridgeServer.Recorder.TotalInputTokens()) + require.EqualValues(t, 105, bridgeServer.Recorder.TotalOutputTokens()) + + // Ensure we received exactly one prompt. + promptUsages := bridgeServer.Recorder.RecordedPromptUsages() + require.Len(t, promptUsages, 1) + }) + } +} + +// anthropicToolResultValidator returns a request validator that asserts the second +// upstream request contains the assistant's tool_use and user's tool_result messages +// appended by the inner agentic loop. If the raw payload is not kept in sync with +// the structured messages, the second request will be identical to the first. +func anthropicToolResultValidator(t *testing.T) func(*http.Request, []byte) { + t.Helper() + + return func(_ *http.Request, raw []byte) { + messages := gjson.GetBytes(raw, "messages").Array() + + // After the agentic loop the messages must contain at minimum: + // [0] original user message + // [N-2] assistant message with tool_use content block + // [N-1] user message with tool_result content block + require.GreaterOrEqual(t, len(messages), 3, + "second upstream request must contain the original message, assistant tool_use, and user tool_result") + + assistantMsg := messages[len(messages)-2] + require.Equal(t, "assistant", assistantMsg.Get("role").Str, + "penultimate message must be from the assistant") + var hasToolUse bool + for _, block := range assistantMsg.Get("content").Array() { + if block.Get("type").Str == "tool_use" { + hasToolUse = true + break + } + } + require.True(t, hasToolUse, "assistant message must contain a tool_use content block") + + toolResultMsg := messages[len(messages)-1] + require.Equal(t, "user", toolResultMsg.Get("role").Str, + "last message must be a user message carrying the tool_result") + var hasToolResult bool + for _, block := range toolResultMsg.Get("content").Array() { + if block.Get("type").Str == "tool_result" { + hasToolResult = true + break + } + } + require.True(t, hasToolResult, "user message must contain a tool_result content block") + } +} + +// openaiChatToolResultValidator returns a request validator that asserts the second +// upstream request contains the assistant's tool_calls and a role=tool result message +// appended by the inner agentic loop. +func openaiChatToolResultValidator(t *testing.T) func(*http.Request, []byte) { + t.Helper() + + return func(_ *http.Request, raw []byte) { + messages := gjson.GetBytes(raw, "messages").Array() + + // After the agentic loop the messages must contain at minimum: + // [0] original user message + // [N-2] assistant message with tool_calls array + // [N-1] message with role=tool + require.GreaterOrEqual(t, len(messages), 3, + "second upstream request must contain the original message, assistant tool_calls, and tool result") + + assistantMsg := messages[len(messages)-2] + require.Equal(t, "assistant", assistantMsg.Get("role").Str, + "penultimate message must be from the assistant") + require.NotEmpty(t, len(assistantMsg.Get("tool_calls").Array()), + "assistant message must contain a tool_calls array") + + toolResultMsg := messages[len(messages)-1] + require.Equal(t, "tool", toolResultMsg.Get("role").Str, + "last message must have role=tool") + require.NotEmpty(t, toolResultMsg.Get("tool_call_id").Str, + "tool result message must have a tool_call_id") + } +} + +func TestErrorHandling(t *testing.T) { + t.Parallel() + + // Tests that errors which occur *before* a streaming response begins, or in non-streaming requests, are handled as expected. + t.Run("non-stream error", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + fixture []byte + path string + responseHandlerFn func(resp *http.Response) + }{ + { + name: config.ProviderAnthropic, + fixture: fixtures.AntNonStreamError, + path: pathAnthropicMessages, + responseHandlerFn: func(resp *http.Response) { + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "error", gjson.GetBytes(body, "type").Str) + require.Equal(t, "invalid_request_error", gjson.GetBytes(body, "error.type").Str) + require.Contains(t, gjson.GetBytes(body, "error.message").Str, "prompt is too long") + }, + }, + { + name: config.ProviderOpenAI, + fixture: fixtures.OaiChatNonStreamError, + path: pathOpenAIChatCompletions, + responseHandlerFn: func(resp *http.Response) { + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "context_length_exceeded", gjson.GetBytes(body, "error.code").Str) + require.Equal(t, "invalid_request_error", gjson.GetBytes(body, "error.type").Str) + require.Contains(t, gjson.GetBytes(body, "error.message").Str, "Input tokens exceed the configured limit") + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + for _, streaming := range []bool{true, false} { + t.Run(fmt.Sprintf("streaming=%v", streaming), func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + // Setup mock server. Error fixtures contain raw HTTP + // responses that may cause the bridge to retry. + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL) + + // Add the stream param to the request. + reqBody, err := sjson.SetBytes(fix.Request(), "stream", streaming) + require.NoError(t, err) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, reqBody) + require.NoError(t, err) + defer resp.Body.Close() + + tc.responseHandlerFn(resp) + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) + }) + } + }) + } + }) + + // Tests that errors which occur *during* a streaming response are handled as expected. + t.Run("mid-stream error", func(t *testing.T) { + cases := []struct { + name string + fixture []byte + path string + responseHandlerFn func(resp *http.Response) + }{ + { + name: config.ProviderAnthropic, + fixture: fixtures.AntMidStreamError, + path: pathAnthropicMessages, + responseHandlerFn: func(resp *http.Response) { + // Server responds first with 200 OK then starts streaming. + require.Equal(t, http.StatusOK, resp.StatusCode) + + sp := aibridge.NewSSEParser() + require.NoError(t, sp.Parse(resp.Body)) + require.Len(t, sp.EventsByType("error"), 1) + require.Contains(t, sp.EventsByType("error")[0].Data, "Overloaded") + }, + }, + { + name: config.ProviderOpenAI, + fixture: fixtures.OaiChatMidStreamError, + path: pathOpenAIChatCompletions, + responseHandlerFn: func(resp *http.Response) { + // Server responds first with 200 OK then starts streaming. + require.Equal(t, http.StatusOK, resp.StatusCode) + + sp := aibridge.NewSSEParser() + require.NoError(t, sp.Parse(resp.Body)) + // OpenAI sends all events under the same type. + messageEvents := sp.MessageEvents() + require.NotEmpty(t, messageEvents) + + errEvent := sp.MessageEvents()[len(sp.MessageEvents())-2] // Last event is termination marker ("[DONE]"). + require.NotEmpty(t, errEvent) + require.Contains(t, errEvent.Data, "The server had an error while processing your request. Sorry about that!") + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + // Setup mock server. + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + upstream.StatusCode = http.StatusInternalServerError + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, fix.Request()) + require.NoError(t, err) + defer resp.Body.Close() + + tc.responseHandlerFn(resp) + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) + }) + } + }) +} + +// TestStableRequestEncoding validates that a given intercepted request and a +// given set of injected tools should result identical payloads. +// +// Should the payload vary, it may subvert any caching mechanisms the provider may have. +func TestStableRequestEncoding(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + fixture []byte + path string + }{ + { + name: config.ProviderAnthropic, + fixture: fixtures.AntSimple, + path: pathAnthropicMessages, + }, + { + name: config.ProviderOpenAI, + fixture: fixtures.OaiChatSimple, + path: pathOpenAIChatCompletions, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + // Setup MCP tools. + mockMCP := setupMCPForTest(t, defaultTracer) + + fix := fixtures.Parse(t, tc.fixture) + + // Create a mock upstream that serves the same blocking response for each request. + count := 10 + responses := make([]upstreamResponse, count) + for i := range count { + responses[i] = newFixtureResponse(fix) + } + upstream := newMockUpstream(ctx, t, responses...) + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, + withMCP(mockMCP), + ) + + // Make multiple requests and verify they all have identical payloads. + for range count { + resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, fix.Request()) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + } + + // All upstream request bodies should be identical. + received := upstream.receivedRequests() + require.Len(t, received, count) + reference := string(received[0].Body) + for _, r := range received[1:] { + assert.JSONEq(t, reference, string(r.Body)) + } + }) + } +} + +// TestAnthropicToolChoiceParallelDisabled verifies that parallel tool use is +// correctly disabled based on the tool_choice parameter in the request. +// See https://github.com/coder/aibridge/issues/2 +func TestAnthropicToolChoiceParallelDisabled(t *testing.T) { + t.Parallel() + + var ( + toolChoiceAuto = string(constant.ValueOf[constant.Auto]()) + toolChoiceAny = string(constant.ValueOf[constant.Any]()) + toolChoiceNone = string(constant.ValueOf[constant.None]()) + toolChoiceTool = string(constant.ValueOf[constant.Tool]()) + ) + + cases := []struct { + name string + fixture []byte + toolChoice any // nil, or map with "type" key. + withInjectedTools bool + expectDisableParallel *bool // nil = field should not be present, non-nil = expected value. + expectToolChoiceTypeInRequest string + }{ + // With injected tools - disable_parallel_tool_use should be set to true. + { + name: "with injected tools: no tool_choice defined defaults to auto", + fixture: fixtures.AntSimple, + toolChoice: nil, + withInjectedTools: true, + expectDisableParallel: utils.PtrTo(true), + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + { + name: "with injected tools: tool_choice auto", + fixture: fixtures.AntSimple, + toolChoice: map[string]any{"type": toolChoiceAuto}, + withInjectedTools: true, + expectDisableParallel: utils.PtrTo(true), + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + { + name: "with injected tools: tool_choice any", + fixture: fixtures.AntSimple, + toolChoice: map[string]any{"type": toolChoiceAny}, + withInjectedTools: true, + expectDisableParallel: utils.PtrTo(true), + expectToolChoiceTypeInRequest: toolChoiceAny, + }, + { + name: "with injected tools: tool_choice tool", + fixture: fixtures.AntSimple, + toolChoice: map[string]any{"type": toolChoiceTool, "name": "some_tool"}, + withInjectedTools: true, + expectDisableParallel: utils.PtrTo(true), + expectToolChoiceTypeInRequest: toolChoiceTool, + }, + { + name: "with injected tools: tool_choice none", + fixture: fixtures.AntSimple, + toolChoice: map[string]any{"type": toolChoiceNone}, + withInjectedTools: true, + expectDisableParallel: nil, + expectToolChoiceTypeInRequest: toolChoiceNone, + }, + // With injected tools and builtin tools - disable_parallel_tool_use should be set to true. + { + name: "with injected and builtin tools: no tool_choice defined defaults to auto", + fixture: fixtures.AntSingleBuiltinTool, + toolChoice: nil, + withInjectedTools: true, + expectDisableParallel: utils.PtrTo(true), + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + { + name: "with injected and builtin tools: tool_choice auto", + fixture: fixtures.AntSingleBuiltinTool, + toolChoice: map[string]any{"type": toolChoiceAuto}, + withInjectedTools: true, + expectDisableParallel: utils.PtrTo(true), + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + { + name: "with injected and builtin tools: tool_choice any", + fixture: fixtures.AntSingleBuiltinTool, + toolChoice: map[string]any{"type": toolChoiceAny}, + withInjectedTools: true, + expectDisableParallel: utils.PtrTo(true), + expectToolChoiceTypeInRequest: toolChoiceAny, + }, + { + name: "with injected and builtin tools: tool_choice tool", + fixture: fixtures.AntSingleBuiltinTool, + toolChoice: map[string]any{"type": toolChoiceTool, "name": "some_tool"}, + withInjectedTools: true, + expectDisableParallel: utils.PtrTo(true), + expectToolChoiceTypeInRequest: toolChoiceTool, + }, + { + name: "with injected and builtin tools: tool_choice none", + fixture: fixtures.AntSingleBuiltinTool, + toolChoice: map[string]any{"type": toolChoiceNone}, + withInjectedTools: true, + expectDisableParallel: nil, + expectToolChoiceTypeInRequest: toolChoiceNone, + }, + { + name: "with injected and builtin tools: request already disables parallel", + fixture: fixtures.AntSingleBuiltinTool, + toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": true}, + withInjectedTools: true, + expectDisableParallel: utils.PtrTo(true), + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + { + name: "with injected and builtin tools: request explicitly enables parallel", + fixture: fixtures.AntSingleBuiltinTool, + toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": false}, + withInjectedTools: true, + expectDisableParallel: utils.PtrTo(true), + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + // Without injected or builtin tools - disable_parallel_tool_use should NOT be set. + { + name: "without injected tools or builtin tools: tool_choice auto", + fixture: fixtures.AntSimple, + toolChoice: map[string]any{"type": toolChoiceAuto}, + withInjectedTools: false, + expectDisableParallel: nil, + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + { + name: "without injected tools or builtin tools: tool_choice any", + fixture: fixtures.AntSimple, + toolChoice: map[string]any{"type": toolChoiceAny}, + withInjectedTools: false, + expectDisableParallel: nil, + expectToolChoiceTypeInRequest: toolChoiceAny, + }, + // With builtin tools but without injected tools - disable_parallel_tool_use should NOT be set. + { + name: "with builtin tools only: tool_choice auto", + fixture: fixtures.AntSingleBuiltinTool, + toolChoice: map[string]any{"type": toolChoiceAuto}, + withInjectedTools: false, + expectDisableParallel: nil, + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + { + name: "with builtin tools only: tool_choice any", + fixture: fixtures.AntSingleBuiltinTool, + toolChoice: map[string]any{"type": toolChoiceAny}, + withInjectedTools: false, + expectDisableParallel: nil, + expectToolChoiceTypeInRequest: toolChoiceAny, + }, + { + name: "with builtin tools only: request explicitly disables parallel", + fixture: fixtures.AntSingleBuiltinTool, + toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": true}, + withInjectedTools: false, + expectDisableParallel: utils.PtrTo(true), + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + { + name: "with builtin tools only: request explicitly enables parallel", + fixture: fixtures.AntSingleBuiltinTool, + toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": false}, + withInjectedTools: false, + expectDisableParallel: utils.PtrTo(false), + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + // Without injected or builtin tools - disable_parallel_tool_use should be preserved if set. + { + name: "no tools: request explicitly disables parallel", + fixture: fixtures.AntSimple, + toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": true}, + withInjectedTools: false, + expectDisableParallel: utils.PtrTo(true), + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + { + name: "no tools: request explicitly enables parallel", + fixture: fixtures.AntSimple, + toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": false}, + withInjectedTools: false, + expectDisableParallel: utils.PtrTo(false), + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + // Request already has disable_parallel_tool_use set - with injected tools it should be set to true. + { + name: "with injected tools: request already disables parallel", + fixture: fixtures.AntSimple, + toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": true}, + withInjectedTools: true, + expectDisableParallel: utils.PtrTo(true), + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + { + name: "with injected tools: request explicitly enables parallel", + fixture: fixtures.AntSimple, + toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": false}, + withInjectedTools: true, + expectDisableParallel: utils.PtrTo(true), + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + // Request already has disable_parallel_tool_use set - without injected tools it should be preserved. + { + name: "without injected tools: request already disables parallel", + fixture: fixtures.AntSimple, + toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": true}, + withInjectedTools: false, + expectDisableParallel: utils.PtrTo(true), + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + { + name: "without injected tools: request explicitly enables parallel", + fixture: fixtures.AntSimple, + toolChoice: map[string]any{"type": toolChoiceAuto, "disable_parallel_tool_use": false}, + withInjectedTools: false, + expectDisableParallel: utils.PtrTo(false), + expectToolChoiceTypeInRequest: toolChoiceAuto, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + // Setup MCP tools conditionally. + var mockMCP mcp.ServerProxier + if tc.withInjectedTools { + mockMCP = setupMCPForTest(t, defaultTracer) + } else { + mockMCP = newNoopMCPManager() + } + + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, + withMCP(mockMCP), + ) + + // Prepare request body with tool_choice set. + reqBody, err := sjson.SetBytes(fix.Request(), "tool_choice", tc.toolChoice) + require.NoError(t, err) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify tool_choice in the upstream request. + received := upstream.receivedRequests() + require.Len(t, received, 1) + var receivedRequest map[string]any + require.NoError(t, json.Unmarshal(received[0].Body, &receivedRequest)) + toolChoice, ok := receivedRequest["tool_choice"].(map[string]any) + require.True(t, ok, "expected tool_choice in upstream request") + + // Verify the type matches expectation. + assert.Equal(t, tc.expectToolChoiceTypeInRequest, toolChoice["type"]) + + // Verify name is preserved for tool_choice=tool. + if tc.expectToolChoiceTypeInRequest == toolChoiceTool { + assert.Equal(t, "some_tool", toolChoice["name"]) + } + + // Verify disable_parallel_tool_use based on expectations. + // See https://platform.claude.com/docs/en/agents-and-tools/tool-use/implement-tool-use#parallel-tool-use + disableParallel, hasDisableParallel := toolChoice["disable_parallel_tool_use"].(bool) + + require.Equal(t, tc.expectDisableParallel != nil, hasDisableParallel, + "disable_parallel_tool_use presence mismatch") + if tc.expectDisableParallel != nil { + assert.Equal(t, *tc.expectDisableParallel, disableParallel) + } + }) + } +} + +// TestChatCompletionsParallelToolCallsDisabled verifies that parallel_tool_calls +// is set to false only when injectable MCP tools are present and the request +// includes tools. +func TestChatCompletionsParallelToolCallsDisabled(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + fixture []byte + withInjectedTools bool + initialSetting *bool + expectedSetting *bool + }{ + // With injected tools and builtin tools: parallel_tool_calls should be forced false. + { + name: "with injected and builtin tools: parallel_tool_calls true", + fixture: fixtures.OaiChatSingleBuiltinTool, + withInjectedTools: true, + initialSetting: utils.PtrTo(true), + expectedSetting: utils.PtrTo(false), + }, + { + name: "with injected and builtin tools: parallel_tool_calls false", + fixture: fixtures.OaiChatSingleBuiltinTool, + withInjectedTools: true, + initialSetting: utils.PtrTo(false), + expectedSetting: utils.PtrTo(false), + }, + { + name: "with injected and builtin tools: parallel_tool_calls unset", + fixture: fixtures.OaiChatSingleBuiltinTool, + withInjectedTools: true, + initialSetting: nil, + expectedSetting: utils.PtrTo(false), + }, + // With injected tools but without builtin tools: parallel_tool_calls should be forced false. + { + name: "with injected tools only: parallel_tool_calls true", + fixture: fixtures.OaiChatSimple, + withInjectedTools: true, + initialSetting: utils.PtrTo(true), + expectedSetting: utils.PtrTo(false), + }, + { + name: "with injected tools only: parallel_tool_calls false", + fixture: fixtures.OaiChatSimple, + withInjectedTools: true, + initialSetting: utils.PtrTo(false), + expectedSetting: utils.PtrTo(false), + }, + { + name: "with injected tools only: parallel_tool_calls unset", + fixture: fixtures.OaiChatSimple, + withInjectedTools: true, + initialSetting: nil, + expectedSetting: utils.PtrTo(false), + }, + // With builtin tools but without injected tools: parallel_tool_calls should be preserved. + { + name: "with builtin tools only: parallel_tool_calls true", + fixture: fixtures.OaiChatSingleBuiltinTool, + withInjectedTools: false, + initialSetting: utils.PtrTo(true), + expectedSetting: utils.PtrTo(true), + }, + { + name: "with builtin tools only: parallel_tool_calls false", + fixture: fixtures.OaiChatSingleBuiltinTool, + withInjectedTools: false, + initialSetting: utils.PtrTo(false), + expectedSetting: utils.PtrTo(false), + }, + { + name: "with builtin tools only: parallel_tool_calls unset", + fixture: fixtures.OaiChatSingleBuiltinTool, + withInjectedTools: false, + initialSetting: nil, + expectedSetting: nil, + }, + // Without any tools: nothing is modified. + { + name: "no tools: parallel_tool_calls true", + fixture: fixtures.OaiChatSimple, + withInjectedTools: false, + initialSetting: utils.PtrTo(true), + expectedSetting: utils.PtrTo(true), + }, + { + name: "no tools: parallel_tool_calls false", + fixture: fixtures.OaiChatSimple, + withInjectedTools: false, + initialSetting: utils.PtrTo(false), + expectedSetting: utils.PtrTo(false), + }, + { + name: "no tools: parallel_tool_calls unset", + fixture: fixtures.OaiChatSimple, + withInjectedTools: false, + initialSetting: nil, + expectedSetting: nil, + }, + } + + for _, tc := range cases { + for _, streaming := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/streaming=%v", tc.name, streaming), func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + var opts []bridgeOption + if tc.withInjectedTools { + opts = append(opts, withMCP(setupMCPForTest(t, defaultTracer))) + } + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, opts...) + + var ( + reqBody = fix.Request() + err error + ) + if tc.initialSetting != nil { + reqBody, err = sjson.SetBytes(reqBody, "parallel_tool_calls", *tc.initialSetting) + require.NoError(t, err) + } + reqBody, err = sjson.SetBytes(reqBody, "stream", streaming) + require.NoError(t, err) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathOpenAIChatCompletions, reqBody) + require.NoError(t, err) + defer resp.Body.Close() + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + received := upstream.receivedRequests() + require.Len(t, received, 1) + + var upstreamReq map[string]any + require.NoError(t, json.Unmarshal(received[0].Body, &upstreamReq)) + + ptc, ok := upstreamReq["parallel_tool_calls"].(bool) + require.Equal(t, tc.expectedSetting != nil, ok, + "parallel_tool_calls presence mismatch") + if tc.expectedSetting != nil { + assert.Equal(t, *tc.expectedSetting, ptc) + } + }) + } + } +} + +func TestThinkingAdaptiveIsPreserved(t *testing.T) { + t.Parallel() + + fix := fixtures.Parse(t, fixtures.AntSimple) + + for _, streaming := range []bool{true, false} { + t.Run(fmt.Sprintf("streaming=%v", streaming), func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + // Create a mock server that captures the request body sent upstream. + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL) + + // Inject adaptive thinking into the fixture request. + reqBody, err := sjson.SetBytes(fix.Request(), "thinking", map[string]string{"type": "adaptive"}) + require.NoError(t, err) + reqBody, err = sjson.SetBytes(reqBody, "stream", streaming) + require.NoError(t, err) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + // Verify the thinking field was preserved in the upstream request. + received := upstream.receivedRequests() + require.Len(t, received, 1) + assert.Equal(t, "adaptive", gjson.GetBytes(received[0].Body, "thinking.type").Str) + }) + } +} + +func TestEnvironmentDoNotLeak(t *testing.T) { + // NOTE: Cannot use t.Parallel() here because subtests use t.Setenv which requires sequential execution. + + // Test that environment variables containing API keys/tokens are not leaked to upstream requests. + // See https://github.com/coder/aibridge/issues/60. + testCases := []struct { + name string + fixture []byte + path string + envVars map[string]string + headerName string + }{ + { + name: config.ProviderAnthropic, + fixture: fixtures.AntSimple, + path: pathAnthropicMessages, + envVars: map[string]string{ + "ANTHROPIC_AUTH_TOKEN": "should-not-leak", + }, + headerName: "Authorization", // We only send through the X-Api-Key, so this one should not be present. + }, + { + name: config.ProviderOpenAI, + fixture: fixtures.OaiChatSimple, + path: pathOpenAIChatCompletions, + envVars: map[string]string{ + "OPENAI_ORG_ID": "should-not-leak", + }, + headerName: "OpenAI-Organization", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // NOTE: Cannot use t.Parallel() here because t.Setenv requires sequential execution. + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + // Set environment variables that the SDK would automatically read. + // These should NOT leak into upstream requests. + for key, val := range tc.envVars { + t.Setenv(key, val) + } + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, fix.Request()) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Verify that environment values did not leak. + received := upstream.receivedRequests() + require.Len(t, received, 1) + require.Empty(t, received[0].Header.Get(tc.headerName)) + }) + } +} + +func TestActorHeaders(t *testing.T) { + t.Parallel() + + actorUsername := "bob" + + cases := []struct { + name string + path string + createProviderFn func(url, key string, sendHeaders bool) aibridge.Provider + fixture []byte + streaming bool + }{ + { + name: "openai/v1/chat/completions", + path: pathOpenAIChatCompletions, + createProviderFn: func(url, key string, sendHeaders bool) aibridge.Provider { + cfg := openAICfg(url, key) + cfg.SendActorHeaders = sendHeaders + return provider.NewOpenAI(cfg) + }, + fixture: fixtures.OaiChatSimple, + streaming: true, + }, + { + name: "openai/v1/chat/completions", + path: pathOpenAIChatCompletions, + createProviderFn: func(url, key string, sendHeaders bool) aibridge.Provider { + cfg := openAICfg(url, key) + cfg.SendActorHeaders = sendHeaders + return provider.NewOpenAI(cfg) + }, + fixture: fixtures.OaiChatSimple, + streaming: false, + }, + { + name: "openai/v1/responses", + path: pathOpenAIResponses, + createProviderFn: func(url, key string, sendHeaders bool) aibridge.Provider { + cfg := openAICfg(url, key) + cfg.SendActorHeaders = sendHeaders + return provider.NewOpenAI(cfg) + }, + fixture: fixtures.OaiResponsesStreamingSimple, + streaming: true, + }, + { + name: "openai/v1/responses", + path: pathOpenAIResponses, + createProviderFn: func(url, key string, sendHeaders bool) aibridge.Provider { + cfg := openAICfg(url, key) + cfg.SendActorHeaders = sendHeaders + return provider.NewOpenAI(cfg) + }, + fixture: fixtures.OaiResponsesBlockingSimple, + streaming: false, + }, + { + name: "anthropic/v1/messages", + path: pathAnthropicMessages, + createProviderFn: func(url, key string, sendHeaders bool) aibridge.Provider { + cfg := anthropicCfg(url, key) + cfg.SendActorHeaders = sendHeaders + return provider.NewAnthropic(cfg, nil) + }, + fixture: fixtures.AntSimple, + streaming: true, + }, + { + name: "anthropic/v1/messages", + path: pathAnthropicMessages, + createProviderFn: func(url, key string, sendHeaders bool) aibridge.Provider { + cfg := anthropicCfg(url, key) + cfg.SendActorHeaders = sendHeaders + return provider.NewAnthropic(cfg, nil) + }, + fixture: fixtures.AntSimple, + streaming: false, + }, + } + + for _, tc := range cases { + for _, send := range []bool{true, false} { + t.Run(fmt.Sprintf("%s/streaming=%v/send-headers=%v", tc.name, tc.streaming, send), func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + metadataKey := "Username" + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, + withCustomProvider(tc.createProviderFn(upstream.URL, apiKey, send)), + withActor(defaultActorID, recorder.Metadata{ + metadataKey: actorUsername, + }), + ) + + // Add the stream param to the request. + reqBody, err := sjson.SetBytes(fix.Request(), "stream", tc.streaming) + require.NoError(t, err) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, reqBody) + require.NoError(t, err) + defer resp.Body.Close() + // Drain the body so streaming responses complete without + // a "connection reset" error in the mock upstream. + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + received := upstream.receivedRequests() + require.NotEmpty(t, received) + receivedHeaders := received[0].Header + + // Verify that the actor headers were only received if intended. + found := make(map[string][]string) + for k, v := range receivedHeaders { + k = strings.ToLower(k) + if intercept.IsActorHeader(k) { + found[k] = v + } + } + + if send { + require.Equal(t, found[strings.ToLower(intercept.ActorIDHeader())], []string{defaultActorID}) + require.Equal(t, found[strings.ToLower(intercept.ActorMetadataHeader(metadataKey))], []string{actorUsername}) + } else { + require.Empty(t, found) + } + }) + } + } +} diff --git a/aibridge/internal/integrationtest/circuit_breaker_test.go b/aibridge/internal/integrationtest/circuit_breaker_test.go new file mode 100644 index 0000000000..3ace843275 --- /dev/null +++ b/aibridge/internal/integrationtest/circuit_breaker_test.go @@ -0,0 +1,628 @@ +package integrationtest //nolint:testpackage // tests unexported internals + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + promtest "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/metrics" + "github.com/coder/coder/v2/aibridge/provider" +) + +// Common response bodies for circuit breaker tests. +const ( + anthropicRateLimitError = `{"type":"error","error":{"type":"rate_limit_error","message":"rate limited"}}` + openAIRateLimitError = `{"error":{"type":"rate_limit_error","message":"rate limited","code":"rate_limit_exceeded"}}` +) + +func anthropicSuccessResponse(model string) string { + return fmt.Sprintf(`{"id":"msg_01","type":"message","role":"assistant","content":[{"type":"text","text":"Hello!"}],"model":%q,"stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":5}}`, model) +} + +func openAISuccessResponse(model string) string { + return fmt.Sprintf(`{"id":"chatcmpl-123","object":"chat.completion","created":1677652288,"model":%q,"choices":[{"index":0,"message":{"role":"assistant","content":"Hello!"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":12,"total_tokens":21}}`, model) +} + +// TestCircuitBreaker_FullRecoveryCycle tests the complete circuit breaker lifecycle: +// closed → open (after consecutive failures) → half-open (after timeout) → closed (after successful request) +func TestCircuitBreaker_FullRecoveryCycle(t *testing.T) { + t.Parallel() + + type testCase struct { + name string + errorBody string + successBody string + requestBody string + headers http.Header + path string + createProvider func(baseURL string, cbConfig *config.CircuitBreaker) provider.Provider + expectProvider string + expectEndpoint string + expectModel string + } + + tests := []testCase{ + { + name: "Anthropic", + expectProvider: config.ProviderAnthropic, + expectEndpoint: "/v1/messages", + expectModel: "claude-sonnet-4-20250514", + errorBody: anthropicRateLimitError, + successBody: anthropicSuccessResponse("claude-sonnet-4-20250514"), + requestBody: `{"model":"claude-sonnet-4-20250514","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`, + headers: http.Header{ + "x-api-key": {"test"}, + "anthropic-version": {"2023-06-01"}, + }, + path: pathAnthropicMessages, + createProvider: func(baseURL string, cbConfig *config.CircuitBreaker) provider.Provider { + return provider.NewAnthropic(config.Anthropic{ + BaseURL: baseURL, + Key: "test-key", + CircuitBreaker: cbConfig, + }, nil) + }, + }, + { + name: "OpenAI", + expectProvider: config.ProviderOpenAI, + expectEndpoint: "/v1/chat/completions", + expectModel: "gpt-4o", + errorBody: openAIRateLimitError, + successBody: openAISuccessResponse("gpt-4o"), + requestBody: `{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`, + headers: http.Header{"Authorization": {"Bearer test-key"}}, + path: pathOpenAIChatCompletions, + createProvider: func(baseURL string, cbConfig *config.CircuitBreaker) provider.Provider { + return provider.NewOpenAI(config.OpenAI{ + BaseURL: baseURL, + Key: "test-key", + CircuitBreaker: cbConfig, + }) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var upstreamCalls atomic.Int32 + var shouldFail atomic.Bool + shouldFail.Store(true) + + // Mock upstream that returns 429 or 200 based on shouldFail flag. + // x-should-retry: false is required to disable SDK automatic retries (default MaxRetries=2). + mockUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("x-should-retry", "false") + if shouldFail.Load() { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(tc.errorBody)) + } else { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(tc.successBody)) + } + })) + defer mockUpstream.Close() + + m := metrics.NewMetrics(prometheus.NewRegistry()) + + // Create provider with circuit breaker config + cbConfig := &config.CircuitBreaker{ + FailureThreshold: 2, + Interval: time.Minute, + Timeout: 50 * time.Millisecond, + MaxRequests: 1, + } + + ctx := t.Context() + bridgeServer := newBridgeTestServer(ctx, t, mockUpstream.URL, + withCustomProvider(tc.createProvider(mockUpstream.URL, cbConfig)), + withMetrics(m), + withActor("test-user-id", nil), + ) + + doRequest := func() int { + resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, []byte(tc.requestBody), tc.headers) + require.NoError(t, err) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + return resp.StatusCode + } + + // Phase 1: Trip the circuit breaker + // First FailureThreshold requests hit upstream, get 429 + for i := uint32(0); i < cbConfig.FailureThreshold; i++ { + status := doRequest() + assert.Equal(t, http.StatusTooManyRequests, status) + } + //nolint:gosec // G115: test constant, no overflow risk + assert.Equal(t, int32(cbConfig.FailureThreshold), upstreamCalls.Load()) + + // Phase 2: Verify circuit is open + // Request should be blocked by circuit breaker (no upstream call) + status := doRequest() + assert.Equal(t, http.StatusServiceUnavailable, status) + //nolint:gosec // G115: test constant, no overflow risk + assert.Equal(t, int32(cbConfig.FailureThreshold), upstreamCalls.Load(), "No new upstream call when circuit is open") + + // Verify metrics show circuit is open + trips := promtest.ToFloat64(m.CircuitBreakerTrips.WithLabelValues(tc.expectProvider, tc.expectEndpoint, tc.expectModel)) + assert.Equal(t, 1.0, trips, "CircuitBreakerTrips should be 1") + + state := promtest.ToFloat64(m.CircuitBreakerState.WithLabelValues(tc.expectProvider, tc.expectEndpoint, tc.expectModel)) + assert.Equal(t, 1.0, state, "CircuitBreakerState should be 1 (open)") + + rejects := promtest.ToFloat64(m.CircuitBreakerRejects.WithLabelValues(tc.expectProvider, tc.expectEndpoint, tc.expectModel)) + assert.Equal(t, 1.0, rejects, "CircuitBreakerRejects should be 1") + + // Phase 3: Wait for timeout to transition to half-open + time.Sleep(cbConfig.Timeout + 10*time.Millisecond) + + // Switch upstream to return success + shouldFail.Store(false) + + // Phase 4: Recovery - request in half-open state should succeed and close circuit + upstreamCallsBefore := upstreamCalls.Load() + status = doRequest() + assert.Equal(t, http.StatusOK, status, "Request should succeed in half-open state") + assert.Equal(t, upstreamCallsBefore+1, upstreamCalls.Load(), "Request should reach upstream in half-open state") + + // Verify circuit is now closed + state = promtest.ToFloat64(m.CircuitBreakerState.WithLabelValues(tc.expectProvider, tc.expectEndpoint, tc.expectModel)) + assert.Equal(t, 0.0, state, "CircuitBreakerState should be 0 (closed) after recovery") + + // Phase 5: Verify circuit is fully functional again + // Multiple requests should all succeed and reach upstream + for i := 0; i < 3; i++ { + status = doRequest() + assert.Equal(t, http.StatusOK, status, "Request should succeed after circuit closes") + } + + // All requests should have reached upstream + assert.Equal(t, upstreamCallsBefore+4, upstreamCalls.Load(), "All requests should reach upstream after circuit closes") + + // Rejects count should not have increased + rejects = promtest.ToFloat64(m.CircuitBreakerRejects.WithLabelValues(tc.expectProvider, tc.expectEndpoint, tc.expectModel)) + assert.Equal(t, 1.0, rejects, "CircuitBreakerRejects should still be 1 (no new rejects)") + }) + } +} + +// TestCircuitBreaker_HalfOpenFailure tests that a failed request in half-open state +// returns the circuit to open: closed → open → half-open → open +func TestCircuitBreaker_HalfOpenFailure(t *testing.T) { + t.Parallel() + + type testCase struct { + name string + errorBody string + requestBody string + headers http.Header + path string + createProvider func(baseURL string, cbConfig *config.CircuitBreaker) provider.Provider + expectProvider string + expectEndpoint string + expectModel string + } + + tests := []testCase{ + { + name: "Anthropic", + expectProvider: config.ProviderAnthropic, + expectEndpoint: "/v1/messages", + expectModel: "claude-sonnet-4-20250514", + errorBody: anthropicRateLimitError, + requestBody: `{"model":"claude-sonnet-4-20250514","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`, + headers: http.Header{ + "x-api-key": {"test"}, + "anthropic-version": {"2023-06-01"}, + }, + path: pathAnthropicMessages, + createProvider: func(baseURL string, cbConfig *config.CircuitBreaker) provider.Provider { + return provider.NewAnthropic(config.Anthropic{ + BaseURL: baseURL, + Key: "test-key", + CircuitBreaker: cbConfig, + }, nil) + }, + }, + { + name: "OpenAI", + expectProvider: config.ProviderOpenAI, + expectEndpoint: "/v1/chat/completions", + expectModel: "gpt-4o", + errorBody: openAIRateLimitError, + requestBody: `{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`, + headers: http.Header{"Authorization": {"Bearer test-key"}}, + path: pathOpenAIChatCompletions, + createProvider: func(baseURL string, cbConfig *config.CircuitBreaker) provider.Provider { + return provider.NewOpenAI(config.OpenAI{ + BaseURL: baseURL, + Key: "test-key", + CircuitBreaker: cbConfig, + }) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var upstreamCalls atomic.Int32 + + // Mock upstream that always returns 429. + mockUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("x-should-retry", "false") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(tc.errorBody)) + })) + defer mockUpstream.Close() + + m := metrics.NewMetrics(prometheus.NewRegistry()) + + cbConfig := &config.CircuitBreaker{ + FailureThreshold: 2, + Interval: time.Minute, + Timeout: 50 * time.Millisecond, + MaxRequests: 1, + } + + ctx := t.Context() + bridgeServer := newBridgeTestServer(ctx, t, mockUpstream.URL, + withCustomProvider(tc.createProvider(mockUpstream.URL, cbConfig)), + withMetrics(m), + withActor("test-user-id", nil), + ) + + doRequest := func() int { + resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, []byte(tc.requestBody), tc.headers) + require.NoError(t, err) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + return resp.StatusCode + } + + // Phase 1: Trip the circuit + for i := uint32(0); i < cbConfig.FailureThreshold; i++ { + status := doRequest() + assert.Equal(t, http.StatusTooManyRequests, status) + } + + // Verify circuit is open + status := doRequest() + assert.Equal(t, http.StatusServiceUnavailable, status) + + trips := promtest.ToFloat64(m.CircuitBreakerTrips.WithLabelValues(tc.expectProvider, tc.expectEndpoint, tc.expectModel)) + assert.Equal(t, 1.0, trips, "CircuitBreakerTrips should be 1") + + // Phase 2: Wait for half-open state + time.Sleep(cbConfig.Timeout + 10*time.Millisecond) + + // Phase 3: Request in half-open state fails, circuit should re-open + upstreamCallsBefore := upstreamCalls.Load() + status = doRequest() + assert.Equal(t, http.StatusTooManyRequests, status, "Request should fail in half-open state") + assert.Equal(t, upstreamCallsBefore+1, upstreamCalls.Load(), "Request should reach upstream in half-open state") + + // Circuit should be open again - next request should be rejected immediately + status = doRequest() + assert.Equal(t, http.StatusServiceUnavailable, status, "Circuit should be open again after half-open failure") + assert.Equal(t, upstreamCallsBefore+1, upstreamCalls.Load(), "Request should NOT reach upstream when circuit re-opens") + + // Verify metrics: trips should be 2 now (tripped twice) + trips = promtest.ToFloat64(m.CircuitBreakerTrips.WithLabelValues(tc.expectProvider, tc.expectEndpoint, tc.expectModel)) + assert.Equal(t, 2.0, trips, "CircuitBreakerTrips should be 2 after half-open failure") + + state := promtest.ToFloat64(m.CircuitBreakerState.WithLabelValues(tc.expectProvider, tc.expectEndpoint, tc.expectModel)) + assert.Equal(t, 1.0, state, "CircuitBreakerState should be 1 (open) after half-open failure") + }) + } +} + +// TestCircuitBreaker_HalfOpenMaxRequests tests that MaxRequests limits concurrent +// requests in half-open state. Requests beyond the limit should be rejected. +func TestCircuitBreaker_HalfOpenMaxRequests(t *testing.T) { + t.Parallel() + + type testCase struct { + name string + errorBody string + successBody string + requestBody string + headers http.Header + path string + createProvider func(baseURL string, cbConfig *config.CircuitBreaker) provider.Provider + expectProvider string + expectEndpoint string + expectModel string + } + + tests := []testCase{ + { + name: "Anthropic", + expectProvider: config.ProviderAnthropic, + expectEndpoint: "/v1/messages", + expectModel: "claude-sonnet-4-20250514", + errorBody: anthropicRateLimitError, + successBody: anthropicSuccessResponse("claude-sonnet-4-20250514"), + requestBody: `{"model":"claude-sonnet-4-20250514","max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`, + headers: http.Header{ + "x-api-key": {"test"}, + "anthropic-version": {"2023-06-01"}, + }, + path: pathAnthropicMessages, + createProvider: func(baseURL string, cbConfig *config.CircuitBreaker) provider.Provider { + return provider.NewAnthropic(config.Anthropic{ + BaseURL: baseURL, + Key: "test-key", + CircuitBreaker: cbConfig, + }, nil) + }, + }, + { + name: "OpenAI", + expectProvider: config.ProviderOpenAI, + expectEndpoint: "/v1/chat/completions", + expectModel: "gpt-4o", + errorBody: openAIRateLimitError, + successBody: openAISuccessResponse("gpt-4o"), + requestBody: `{"model":"gpt-4o","messages":[{"role":"user","content":"hi"}]}`, + headers: http.Header{"Authorization": {"Bearer test-key"}}, + path: pathOpenAIChatCompletions, + createProvider: func(baseURL string, cbConfig *config.CircuitBreaker) provider.Provider { + return provider.NewOpenAI(config.OpenAI{ + BaseURL: baseURL, + Key: "test-key", + CircuitBreaker: cbConfig, + }) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var upstreamCalls atomic.Int32 + var shouldFail atomic.Bool + shouldFail.Store(true) + + // Upstream is slow to ensure concurrent requests overlap in half-open state. + mockUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("x-should-retry", "false") + if shouldFail.Load() { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(tc.errorBody)) + } else { + // Slow response to ensure requests overlap + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(tc.successBody)) + } + })) + defer mockUpstream.Close() + + m := metrics.NewMetrics(prometheus.NewRegistry()) + + const maxRequests = 2 + cbConfig := &config.CircuitBreaker{ + FailureThreshold: 2, + Interval: time.Minute, + Timeout: 50 * time.Millisecond, + MaxRequests: maxRequests, // Allow only 2 concurrent requests in half-open + } + + ctx := t.Context() + bridgeServer := newBridgeTestServer(ctx, t, mockUpstream.URL, + withCustomProvider(tc.createProvider(mockUpstream.URL, cbConfig)), + withMetrics(m), + withActor("test-user-id", nil), + ) + + doRequest := func() int { + resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, []byte(tc.requestBody), tc.headers) + require.NoError(t, err) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + return resp.StatusCode + } + + // Phase 1: Trip the circuit + for i := uint32(0); i < cbConfig.FailureThreshold; i++ { + status := doRequest() + assert.Equal(t, http.StatusTooManyRequests, status) + } + + // Verify circuit is open + status := doRequest() + assert.Equal(t, http.StatusServiceUnavailable, status) + + // Phase 2: Wait for half-open state and switch upstream to success + time.Sleep(cbConfig.Timeout + 10*time.Millisecond) + shouldFail.Store(false) + upstreamCalls.Store(0) + + // Phase 3: Send concurrent requests (more than MaxRequests) + const totalRequests = 5 + var wg sync.WaitGroup + responses := make(chan int, totalRequests) + + for i := 0; i < totalRequests; i++ { + wg.Add(1) + go func() { + defer wg.Done() + status := doRequest() + responses <- status + }() + } + + wg.Wait() + close(responses) + + // Count results + var successCount, rejectedCount int + for status := range responses { + switch status { + case http.StatusOK: + successCount++ + case http.StatusServiceUnavailable: + rejectedCount++ + } + } + + // Verify only MaxRequests reached upstream + assert.Equal(t, int32(maxRequests), upstreamCalls.Load(), + "Only MaxRequests (%d) should reach upstream in half-open state", maxRequests) + + // Verify request counts + assert.Equal(t, maxRequests, successCount, + "Only %d requests should succeed (MaxRequests)", maxRequests) + assert.Equal(t, totalRequests-maxRequests, rejectedCount, + "%d requests should be rejected (ErrTooManyRequests)", totalRequests-maxRequests) + + // Verify rejects metric increased + rejects := promtest.ToFloat64(m.CircuitBreakerRejects.WithLabelValues(tc.expectProvider, tc.expectEndpoint, tc.expectModel)) + assert.Equal(t, float64(1+totalRequests-maxRequests), rejects, + "CircuitBreakerRejects should include half-open rejections") + }) + } +} + +// TestCircuitBreaker_PerModelIsolation tests that circuit breakers are independent per model. +// Rate limits on one model should not affect other models on the same endpoint. +func TestCircuitBreaker_PerModelIsolation(t *testing.T) { + t.Parallel() + + var sonnetCalls, haikuCalls atomic.Int32 + var sonnetShouldFail atomic.Bool + sonnetShouldFail.Store(true) + + // Mock upstream that returns different responses based on model in request + mockUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("x-should-retry", "false") + + if strings.Contains(string(body), "claude-sonnet-4-20250514") { + sonnetCalls.Add(1) + if sonnetShouldFail.Load() { + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(anthropicRateLimitError)) + } else { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(anthropicSuccessResponse("claude-sonnet-4-20250514"))) + } + } else if strings.Contains(string(body), "claude-3-5-haiku-20241022") { + haikuCalls.Add(1) + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(anthropicSuccessResponse("claude-3-5-haiku-20241022"))) + } + })) + defer mockUpstream.Close() + + m := metrics.NewMetrics(prometheus.NewRegistry()) + + cbConfig := &config.CircuitBreaker{ + FailureThreshold: 2, + Interval: time.Minute, + Timeout: 500 * time.Millisecond, + MaxRequests: 1, + } + ctx := t.Context() + bridgeServer := newBridgeTestServer(ctx, t, mockUpstream.URL, + withCustomProvider(provider.NewAnthropic(config.Anthropic{ + BaseURL: mockUpstream.URL, + Key: "test-key", + CircuitBreaker: cbConfig, + }, nil)), + withMetrics(m), + withActor("test-user-id", nil), + ) + + doRequest := func(model string) int { + body := fmt.Sprintf(`{"model":%q,"max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`, model) + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, []byte(body), http.Header{ + "x-api-key": {"test"}, + "anthropic-version": {"2023-06-01"}, + }) + require.NoError(t, err) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + return resp.StatusCode + } + + // Phase 1: Trip the circuit for sonnet model + for i := uint32(0); i < cbConfig.FailureThreshold; i++ { + status := doRequest("claude-sonnet-4-20250514") + assert.Equal(t, http.StatusTooManyRequests, status) + } + //nolint:gosec // G115: test constant, no overflow risk + assert.Equal(t, int32(cbConfig.FailureThreshold), sonnetCalls.Load()) + + // Verify sonnet circuit is open + status := doRequest("claude-sonnet-4-20250514") + assert.Equal(t, http.StatusServiceUnavailable, status, "Sonnet circuit should be open") + //nolint:gosec // G115: test constant, no overflow risk + assert.Equal(t, int32(cbConfig.FailureThreshold), sonnetCalls.Load(), "No new sonnet calls when circuit is open") + + // Verify sonnet metrics show circuit is open + sonnetTrips := promtest.ToFloat64(m.CircuitBreakerTrips.WithLabelValues(config.ProviderAnthropic, "/v1/messages", "claude-sonnet-4-20250514")) + assert.Equal(t, 1.0, sonnetTrips, "Sonnet CircuitBreakerTrips should be 1") + + sonnetState := promtest.ToFloat64(m.CircuitBreakerState.WithLabelValues(config.ProviderAnthropic, "/v1/messages", "claude-sonnet-4-20250514")) + assert.Equal(t, 1.0, sonnetState, "Sonnet CircuitBreakerState should be 1 (open)") + + // Phase 2: Haiku model should still work (independent circuit) + status = doRequest("claude-3-5-haiku-20241022") + assert.Equal(t, http.StatusOK, status, "Haiku should succeed while sonnet circuit is open") + assert.Equal(t, int32(1), haikuCalls.Load(), "Haiku call should reach upstream") + + // Make multiple haiku requests - all should succeed + for i := 0; i < 3; i++ { + status = doRequest("claude-3-5-haiku-20241022") + assert.Equal(t, http.StatusOK, status, "Haiku should continue to succeed") + } + assert.Equal(t, int32(4), haikuCalls.Load(), "All haiku calls should reach upstream") + + // Verify haiku circuit is still closed (no trips) + haikuTrips := promtest.ToFloat64(m.CircuitBreakerTrips.WithLabelValues(config.ProviderAnthropic, "/v1/messages", "claude-3-5-haiku-20241022")) + assert.Equal(t, 0.0, haikuTrips, "Haiku CircuitBreakerTrips should be 0") + + haikuState := promtest.ToFloat64(m.CircuitBreakerState.WithLabelValues(config.ProviderAnthropic, "/v1/messages", "claude-3-5-haiku-20241022")) + assert.Equal(t, 0.0, haikuState, "Haiku CircuitBreakerState should be 0 (closed)") + + // Phase 3: Sonnet recovers after timeout + time.Sleep(cbConfig.Timeout + 10*time.Millisecond) + sonnetShouldFail.Store(false) + + status = doRequest("claude-sonnet-4-20250514") + assert.Equal(t, http.StatusOK, status, "Sonnet should recover after timeout") + + // Verify sonnet circuit is now closed + sonnetState = promtest.ToFloat64(m.CircuitBreakerState.WithLabelValues(config.ProviderAnthropic, "/v1/messages", "claude-sonnet-4-20250514")) + assert.Equal(t, 0.0, sonnetState, "Sonnet CircuitBreakerState should be 0 (closed) after recovery") +} diff --git a/aibridge/internal/integrationtest/helpers.go b/aibridge/internal/integrationtest/helpers.go new file mode 100644 index 0000000000..7b6e80c903 --- /dev/null +++ b/aibridge/internal/integrationtest/helpers.go @@ -0,0 +1,65 @@ +package integrationtest + +import ( + "testing" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/recorder" +) + +// anthropicCfg creates a minimal Anthropic config for testing. +func anthropicCfg(url string, key string) config.Anthropic { + return config.Anthropic{ + BaseURL: url, + Key: key, + } +} + +func anthropicCfgWithAPIDump(url string, key string, dumpDir string) config.Anthropic { + cfg := anthropicCfg(url, key) + cfg.APIDumpDir = dumpDir + return cfg +} + +// bedrockCfg returns a test AWS Bedrock config pointing at the given URL. +func bedrockCfg(url string) *config.AWSBedrock { + return &config.AWSBedrock{ + Region: "us-west-2", + AccessKey: "test-access-key", + AccessKeySecret: "test-secret-key", + Model: "beddel", // This model should override the request's given one. + SmallFastModel: "modrock", // Unused but needed for validation. + BaseURL: url, + } +} + +// openAICfg creates a minimal OpenAI config for testing. +func openAICfg(url string, key string) config.OpenAI { + return config.OpenAI{ + BaseURL: url, + Key: key, + } +} + +func openaiCfgWithAPIDump(url string, key string, dumpDir string) config.OpenAI { + cfg := openAICfg(url, key) + cfg.APIDumpDir = dumpDir + return cfg +} + +// newLogger creates a test logger at Debug level. +func newLogger(t *testing.T) slog.Logger { + t.Helper() + return slogtest.Make(t, &slogtest.Options{}).Leveled(slog.LevelDebug) +} + +func newModelThought(content, source string) recorder.ModelThoughtRecord { + return recorder.ModelThoughtRecord{ + Content: content, + Metadata: recorder.Metadata{ + "source": source, + }, + } +} diff --git a/aibridge/internal/integrationtest/metrics_test.go b/aibridge/internal/integrationtest/metrics_test.go new file mode 100644 index 0000000000..c3d61ad715 --- /dev/null +++ b/aibridge/internal/integrationtest/metrics_test.go @@ -0,0 +1,450 @@ +package integrationtest //nolint:testpackage // tests unexported internals + +import ( + "bytes" + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/prometheus/client_golang/prometheus" + promtest "github.com/prometheus/client_golang/prometheus/testutil" + "github.com/stretchr/testify/require" + "github.com/tidwall/sjson" + + "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/fixtures" + "github.com/coder/coder/v2/aibridge/internal/testutil" + "github.com/coder/coder/v2/aibridge/metrics" +) + +func TestMetrics_Interception(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + fixture []byte + path string + headers http.Header + expectStatus string + expectModel string + expectRoute string + expectProvider string + expectClient aibridge.Client + allowOverflow bool // error fixtures may cause retries + }{ + { + name: "ant_simple", + fixture: fixtures.AntSimple, + path: pathAnthropicMessages, + expectStatus: metrics.InterceptionCountStatusCompleted, + expectModel: "claude-sonnet-4-0", + expectRoute: "/v1/messages", + expectProvider: config.ProviderAnthropic, + expectClient: aibridge.ClientUnknown, + }, + { + name: "ant_error", + fixture: fixtures.AntNonStreamError, + path: pathAnthropicMessages, + headers: http.Header{"User-Agent": []string{"kilo-code/1.2.3"}}, + expectStatus: metrics.InterceptionCountStatusFailed, + expectModel: "claude-sonnet-4-0", + expectRoute: "/v1/messages", + expectProvider: config.ProviderAnthropic, + expectClient: aibridge.ClientKilo, + allowOverflow: true, + }, + { + name: "ant_simple_claude_code", + fixture: fixtures.AntSimple, + path: pathAnthropicMessages, + headers: http.Header{"User-Agent": []string{"claude-code/1.0.0"}}, + expectStatus: metrics.InterceptionCountStatusCompleted, + expectModel: "claude-sonnet-4-0", + expectRoute: "/v1/messages", + expectProvider: config.ProviderAnthropic, + expectClient: aibridge.ClientClaudeCode, + }, + { + name: "oai_chat_simple", + fixture: fixtures.OaiChatSimple, + path: pathOpenAIChatCompletions, + headers: http.Header{"User-Agent": []string{"copilot/1.0.0"}}, + expectStatus: metrics.InterceptionCountStatusCompleted, + expectModel: "gpt-4.1", + expectRoute: "/v1/chat/completions", + expectProvider: config.ProviderOpenAI, + expectClient: aibridge.ClientCopilotCLI, + }, + { + name: "oai_chat_error", + fixture: fixtures.OaiChatNonStreamError, + path: pathOpenAIChatCompletions, + headers: http.Header{"User-Agent": []string{"githubcopilotchat/0.30.0"}}, + expectStatus: metrics.InterceptionCountStatusFailed, + expectModel: "gpt-4.1", + expectRoute: "/v1/chat/completions", + expectProvider: config.ProviderOpenAI, + expectClient: aibridge.ClientCopilotVSC, + allowOverflow: true, + }, + { + name: "oai_responses_blocking_simple", + fixture: fixtures.OaiResponsesBlockingSimple, + path: pathOpenAIResponses, + headers: http.Header{"X-Cursor-Client-Version": []string{"0.50.0"}}, + expectStatus: metrics.InterceptionCountStatusCompleted, + expectModel: "gpt-4o-mini", + expectRoute: "/v1/responses", + expectProvider: config.ProviderOpenAI, + expectClient: aibridge.ClientCursor, + }, + { + name: "oai_responses_blocking_error", + fixture: fixtures.OaiResponsesBlockingHTTPErr, + path: pathOpenAIResponses, + headers: http.Header{"User-Agent": []string{"codex/1.0.0"}}, + expectStatus: metrics.InterceptionCountStatusFailed, + expectModel: "gpt-4o-mini", + expectRoute: "/v1/responses", + expectProvider: config.ProviderOpenAI, + expectClient: aibridge.ClientCodex, + allowOverflow: true, + }, + { + name: "oai_responses_streaming_simple", + fixture: fixtures.OaiResponsesStreamingSimple, + path: pathOpenAIResponses, + headers: http.Header{"User-Agent": []string{"zed/0.200.0"}}, + expectStatus: metrics.InterceptionCountStatusCompleted, + expectModel: "gpt-4o-mini", + expectRoute: "/v1/responses", + expectProvider: config.ProviderOpenAI, + expectClient: aibridge.ClientZed, + }, + { + name: "oai_responses_streaming_error", + fixture: fixtures.OaiResponsesStreamingHTTPErr, + path: pathOpenAIResponses, + headers: http.Header{"Originator": []string{"roo-code"}}, + expectStatus: metrics.InterceptionCountStatusFailed, + expectModel: "gpt-4o-mini", + expectRoute: "/v1/responses", + expectProvider: config.ProviderOpenAI, + expectClient: aibridge.ClientRoo, + allowOverflow: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + upstream.AllowOverflow = tc.allowOverflow + + m := aibridge.NewMetrics(prometheus.NewRegistry()) + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, + withMetrics(m), + ) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, fix.Request(), tc.headers) + require.NoError(t, err) + defer resp.Body.Close() + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + count := promtest.ToFloat64(m.InterceptionCount.WithLabelValues( + tc.expectProvider, tc.expectModel, tc.expectStatus, tc.expectRoute, "POST", defaultActorID, string(tc.expectClient))) + require.Equal(t, 1.0, count) + require.Equal(t, 1, promtest.CollectAndCount(m.InterceptionDuration)) + require.Equal(t, 1, promtest.CollectAndCount(m.InterceptionCount)) + }) + } +} + +func TestMetrics_InterceptionsInflight(t *testing.T) { + t.Parallel() + + fix := fixtures.Parse(t, fixtures.AntSimple) + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + blockCh := make(chan struct{}) + + // Setup a mock HTTP server which blocks until the request is marked as inflight then proceeds. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-blockCh + })) + t.Cleanup(srv.Close) + + m := aibridge.NewMetrics(prometheus.NewRegistry()) + bridgeServer := newBridgeTestServer(ctx, t, srv.URL, + withMetrics(m), + ) + + // Make request in background. + doneCh := make(chan struct{}) + go func() { + defer close(doneCh) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, bridgeServer.URL+pathAnthropicMessages, bytes.NewReader(fix.Request())) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err == nil { + defer resp.Body.Close() + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + } + }() + + // Wait until request is detected as inflight. + require.Eventually(t, func() bool { + return promtest.ToFloat64( + m.InterceptionsInflight.WithLabelValues(config.ProviderAnthropic, "claude-sonnet-4-0", "/v1/messages"), + ) == 1 + }, testutil.WaitMedium, testutil.IntervalFast) + + // Unblock request, await completion. + close(blockCh) + select { + case <-doneCh: + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + + // Metric is not updated immediately after request completes, so wait until it is. + require.Eventually(t, func() bool { + return promtest.ToFloat64( + m.InterceptionsInflight.WithLabelValues(config.ProviderAnthropic, "claude-sonnet-4-0", "/v1/messages"), + ) == 0 + }, testutil.WaitMedium, testutil.IntervalFast) +} + +func TestMetrics_PassthroughCount(t *testing.T) { + t.Parallel() + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + t.Cleanup(upstream.Close) + + m := aibridge.NewMetrics(prometheus.NewRegistry()) + bridgeServer := newBridgeTestServer(t.Context(), t, upstream.URL, + withMetrics(m), + ) + + resp, err := bridgeServer.makeRequest(t, http.MethodGet, "/openai/v1/models", nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + count := promtest.ToFloat64(m.PassthroughCount.WithLabelValues( + config.ProviderOpenAI, "/models", "GET")) + require.Equal(t, 1.0, count) +} + +func TestMetrics_PromptCount(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, fixtures.OaiChatSimple) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + m := aibridge.NewMetrics(prometheus.NewRegistry()) + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, + withMetrics(m), + ) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathOpenAIChatCompletions, fix.Request(), http.Header{"User-Agent": []string{"claude-code/1.0.0"}}) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + prompts := promtest.ToFloat64(m.PromptCount.WithLabelValues( + config.ProviderOpenAI, "gpt-4.1", defaultActorID, string(aibridge.ClientClaudeCode))) + require.Equal(t, 1.0, prompts) +} + +func TestMetrics_TokenUseCount(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + fixture []byte + reqPath string + streaming bool + expectProvider string + expectModel string + expectedLabels map[string]float64 + }{ + { + name: "openai_responses", + fixture: fixtures.OaiResponsesBlockingCachedInputTokens, + reqPath: pathOpenAIResponses, + expectProvider: config.ProviderOpenAI, + expectModel: "gpt-4.1", + expectedLabels: map[string]float64{ + "input": 129, // 12033 - 11904 cached + "output": 44, + "cache_read_input_tokens": 11904, + "cache_write_input_tokens": 0, + "input_cached": 11904, + "output_reasoning": 0, + "total_tokens": 12077, + }, + }, + { + name: "anthropic_messages_streaming", + fixture: fixtures.AntSingleBuiltinTool, + reqPath: pathAnthropicMessages, + streaming: true, + expectProvider: config.ProviderAnthropic, + expectModel: "claude-sonnet-4-20250514", + expectedLabels: map[string]float64{ + "input": 2, + "output": 66, + "cache_read_input_tokens": 13993, + "cache_write_input_tokens": 22, + "cache_read_input": 13993, + "cache_creation_input": 22, + }, + }, + { + name: "openai_chat_completions", + fixture: fixtures.OaiChatSimple, + reqPath: pathOpenAIChatCompletions, + expectProvider: config.ProviderOpenAI, + expectModel: "gpt-4.1", + expectedLabels: map[string]float64{ + "input": 19, + "output": 200, + "cache_read_input_tokens": 0, + "cache_write_input_tokens": 0, + "prompt_cached": 0, + "completion_reasoning": 0, + "completion_accepted_prediction": 0, + "completion_rejected_prediction": 0, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + m := aibridge.NewMetrics(prometheus.NewRegistry()) + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, + withMetrics(m), + ) + + reqBody := fix.Request() + if tc.streaming { + var err error + reqBody, err = sjson.SetBytes(reqBody, "stream", true) + require.NoError(t, err) + } + resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.reqPath, reqBody, nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + _, _ = io.ReadAll(resp.Body) + + // metrics are updated asynchronously + require.Eventually(t, func() bool { + return promtest.ToFloat64(m.TokenUseCount.WithLabelValues( + tc.expectProvider, tc.expectModel, "input", defaultActorID, string(aibridge.ClientUnknown))) > 0 + }, testutil.WaitMedium, testutil.IntervalFast) + + for label, expected := range tc.expectedLabels { + require.Equal(t, expected, promtest.ToFloat64(m.TokenUseCount.WithLabelValues( + tc.expectProvider, tc.expectModel, label, defaultActorID, string(aibridge.ClientUnknown), + )), "metric label %q mismatch", label) + } + }) + } +} + +func TestMetrics_NonInjectedToolUseCount(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, fixtures.OaiChatSingleBuiltinTool) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + m := aibridge.NewMetrics(prometheus.NewRegistry()) + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, + withMetrics(m), + ) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathOpenAIChatCompletions, fix.Request()) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + count := promtest.ToFloat64(m.NonInjectedToolUseCount.WithLabelValues( + config.ProviderOpenAI, "gpt-4.1", "read_file")) + require.Equal(t, 1.0, count) +} + +func TestMetrics_InjectedToolUseCount(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + // First request returns the tool invocation, the second returns the mocked response to the tool result. + fix := fixtures.Parse(t, fixtures.AntSingleInjectedTool) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix), newFixtureToolResponse(fix)) + + m := aibridge.NewMetrics(prometheus.NewRegistry()) + + // Setup mocked MCP server & tools. + mockMCP := setupMCPForTest(t, defaultTracer) + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, + withMetrics(m), + withMCP(mockMCP), + ) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, fix.Request()) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + // Wait until full roundtrip has completed. + require.Eventually(t, func() bool { + return upstream.Calls.Load() == 2 + }, testutil.WaitMedium, testutil.IntervalFast) + + recorder := bridgeServer.Recorder + require.Len(t, recorder.ToolUsages(), 1) + require.True(t, recorder.ToolUsages()[0].Injected) + require.NotNil(t, recorder.ToolUsages()[0].ServerURL) + actualServerURL := *recorder.ToolUsages()[0].ServerURL + + count := promtest.ToFloat64(m.InjectedToolUseCount.WithLabelValues( + config.ProviderAnthropic, "claude-sonnet-4-20250514", actualServerURL, mockToolName)) + require.Equal(t, 1.0, count) +} diff --git a/aibridge/internal/integrationtest/mockmcp.go b/aibridge/internal/integrationtest/mockmcp.go new file mode 100644 index 0000000000..ffbd4fad19 --- /dev/null +++ b/aibridge/internal/integrationtest/mockmcp.go @@ -0,0 +1,154 @@ +package integrationtest + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/mark3labs/mcp-go/client/transport" + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/aibridge/internal/testutil" + "github.com/coder/coder/v2/aibridge/mcp" +) + +// mockToolName is the primary mock tool name used in MCP tests. +const mockToolName = "coder_list_workspaces" + +// mockMCP wraps a real mcp.ServerProxier with test assertion helpers. +// Implements mcp.ServerProxier so it can be passed directly to NewRequestBridge. +type mockMCP struct { + mcp.ServerProxier + calls *callAccumulator +} + +// getCallsByTool returns recorded arguments for a given tool name. +func (m *mockMCP) getCallsByTool(name string) []any { + return m.calls.getCallsByTool(name) +} + +// setToolError configures a tool to return an error when invoked. +func (m *mockMCP) setToolError(tool, errMsg string) { + m.calls.setToolError(tool, errMsg) +} + +// setupMCPForTest creates a ready-to-use MCP server with proxy named "coder". +func setupMCPForTest(t *testing.T, tracer trace.Tracer) *mockMCP { + t.Helper() + return setupMCPForTestWithName(t, "coder", tracer) +} + +func setupMCPForTestWithName(t *testing.T, name string, tracer trace.Tracer) *mockMCP { + t.Helper() + + srv, acc := createMockMCPSrv(t) + mcpSrv := httptest.NewServer(srv) + t.Cleanup(mcpSrv.Close) // FIRST registered → runs LAST (LIFO) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + // Use a dedicated HTTP client so MCP mocks don't use http.DefaultTransport, + // which can break when httptest.Server calls CloseIdleConnections in parallel + // resulting in error `init MCP client: failed to send initialized notification: failed to send request: failed to send request: Post "http://127.0.0.1:43843": net/http: HTTP/1.x transport connection broken: http: CloseIdleConnections called` + // https://github.com/golang/go/blob/44ec057a3e89482cf775f5eaaf03b0b5fcab1fa4/src/net/http/httptest/server.go#L268 + httpTransport := &http.Transport{} + t.Cleanup(httpTransport.CloseIdleConnections) + httpClient := &http.Client{Transport: httpTransport} + proxy, err := mcp.NewStreamableHTTPServerProxy(name, mcpSrv.URL, nil, nil, nil, logger, tracer, transport.WithHTTPBasicClient(httpClient)) + require.NoError(t, err) + + mgr := mcp.NewServerProxyManager(map[string]mcp.ServerProxier{proxy.Name(): proxy}, tracer) + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + require.NoError(t, mgr.Shutdown(ctx)) + }) + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + require.NoError(t, mgr.Init(ctx)) + require.NotEmpty(t, mgr.ListTools(), "mock MCP server should expose tools after init") + + return &mockMCP{ServerProxier: mgr, calls: acc} +} + +func newNoopMCPManager() mcp.ServerProxier { + return mcp.NewServerProxyManager(nil, noop.NewTracerProvider().Tracer("")) +} + +// callAccumulator tracks all tool invocations by name and each instance's arguments. +type callAccumulator struct { + calls map[string][]any + callsMu sync.Mutex + toolErrors map[string]string +} + +func newCallAccumulator() *callAccumulator { + return &callAccumulator{ + calls: make(map[string][]any), + toolErrors: make(map[string]string), + } +} + +func (a *callAccumulator) setToolError(tool string, errMsg string) { + a.callsMu.Lock() + defer a.callsMu.Unlock() + a.toolErrors[tool] = errMsg +} + +func (a *callAccumulator) getToolError(tool string) (string, bool) { + a.callsMu.Lock() + defer a.callsMu.Unlock() + errMsg, ok := a.toolErrors[tool] + return errMsg, ok +} + +func (a *callAccumulator) addCall(tool string, args any) { + a.callsMu.Lock() + defer a.callsMu.Unlock() + a.calls[tool] = append(a.calls[tool], args) +} + +func (a *callAccumulator) getCallsByTool(name string) []any { + a.callsMu.Lock() + defer a.callsMu.Unlock() + result := make([]any, len(a.calls[name])) + copy(result, a.calls[name]) + return result +} + +func createMockMCPSrv(t *testing.T) (http.Handler, *callAccumulator) { + t.Helper() + + s := server.NewMCPServer( + "Mock coder MCP server", + "1.0.0", + server.WithToolCapabilities(true), + ) + + acc := newCallAccumulator() + + for _, name := range []string{mockToolName, "coder_list_templates", "coder_template_version_parameters", "coder_get_authenticated_user", "coder_create_workspace_build", "coder_delete_template"} { + tool := mcplib.NewTool(name, + mcplib.WithDescription(fmt.Sprintf("Mock of the %s tool", name)), + ) + s.AddTool(tool, func(_ context.Context, request mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + acc.addCall(request.Params.Name, request.Params.Arguments) + if errMsg, ok := acc.getToolError(request.Params.Name); ok { + return nil, xerrors.New(errMsg) + } + return mcplib.NewToolResultText("mock"), nil + }) + } + + return server.NewStreamableHTTPServer(s), acc +} diff --git a/aibridge/internal/integrationtest/mockupstream.go b/aibridge/internal/integrationtest/mockupstream.go new file mode 100644 index 0000000000..2731ed5506 --- /dev/null +++ b/aibridge/internal/integrationtest/mockupstream.go @@ -0,0 +1,314 @@ +package integrationtest + +import ( + "bufio" + "bytes" + "cmp" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/openai/openai-go/v3" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "github.com/coder/coder/v2/aibridge/fixtures" + "github.com/coder/coder/v2/aibridge/intercept/eventstream" +) + +// upstreamResponse defines a single response that mockUpstream will replay +// for one incoming request. Use [newFixtureResponse] or [newFixtureToolResponse] to +// construct one from a parsed txtar archive. +type upstreamResponse struct { + Streaming []byte // returned when the request has "stream": true. + Blocking []byte // returned for non-streaming requests. + + // OnRequest, if non-nil, is called with the incoming request and body + // before the response is sent. Use it for per-request assertions. + OnRequest func(r *http.Request, body []byte) +} + +// newFixtureResponse creates an upstreamResponse from a parsed fixture archive. +// It reads whichever of 'streaming' and 'non-streaming' sections exist; +// not every fixture has both (e.g. error fixtures may only define one). +func newFixtureResponse(fix fixtures.Fixture) upstreamResponse { + var resp upstreamResponse + if fix.Has(fixtures.SectionStreaming) { + resp.Streaming = fix.Streaming() + } + if fix.Has(fixtures.SectionNonStreaming) { + resp.Blocking = fix.NonStreaming() + } + return resp +} + +// newFixtureToolResponse creates an upstreamResponse from the tool-call fixture files. +// It reads whichever of 'streaming/tool-call' and 'non-streaming/tool-call' +// sections exist. +func newFixtureToolResponse(fix fixtures.Fixture) upstreamResponse { + var resp upstreamResponse + if fix.Has(fixtures.SectionStreamingToolCall) { + resp.Streaming = fix.StreamingToolCall() + } + if fix.Has(fixtures.SectionNonStreamToolCall) { + resp.Blocking = fix.NonStreamingToolCall() + } + return resp +} + +// receivedRequest captures the details of a single request handled by mockUpstream. +type receivedRequest struct { + Method string + Path string + Header http.Header + Body []byte +} + +// mockUpstream replays txtar fixture responses, validates incoming request +// bodies, and counts calls. It stands in for a real AI provider API +// (Anthropic, OpenAI) during integration tests. +type mockUpstream struct { + *httptest.Server + + // Calls is incremented atomically on every request. + Calls atomic.Uint32 + + // StatusCode overrides the HTTP status for non-streaming responses. + // Zero means 200. + StatusCode int + + // AllowOverflow disables the strict call-count check. When true, + // requests beyond the last response repeat that response, and the + // cleanup assertion only verifies that at least len(responses) + // requests were made. This is useful for error-response tests where + // the bridge may retry. + AllowOverflow bool + + mu sync.Mutex + requests []receivedRequest + + t *testing.T + responses []upstreamResponse +} + +// receivedRequests returns a copy of all requests received so far. +func (ms *mockUpstream) receivedRequests() []receivedRequest { + ms.mu.Lock() + defer ms.mu.Unlock() + return append([]receivedRequest(nil), ms.requests...) +} + +// newMockUpstream creates a started httptest.Server that replays fixture +// responses. Responses are returned in order: first call → first response. +// The test fails if the number of requests doesn't match the number of +// responses (when AllowOverflow is not set, default). +// +// srv := newMockUpstream(ctx, t, newFixtureResponse(fix)) // simple +// srv := newMockUpstream(ctx, t, newFixtureResponse(fix), newFixtureToolResponse(fix)) // multi-turn +func newMockUpstream(ctx context.Context, t *testing.T, responses ...upstreamResponse) *mockUpstream { + t.Helper() + require.NotEmpty(t, responses, "at least one upstreamResponse required") + + ms := &mockUpstream{ + t: t, + responses: responses, + } + + srv := httptest.NewUnstartedServer(http.HandlerFunc(ms.handle)) + srv.Config.BaseContext = func(_ net.Listener) context.Context { return ctx } + srv.Start() + + t.Cleanup(func() { + srv.Close() + + // Verify the number of requests matches expectations. + calls := int(ms.Calls.Load()) + if ms.AllowOverflow { + require.LessOrEqual(t, len(ms.responses), calls, "too few requests, got: %v, want at least: %v", calls, len(ms.responses)) + } else { + require.Equal(t, len(ms.responses), calls, "unexpected number of requests, got: %v, want: %v", calls, len(ms.responses)) + } + }) + + ms.Server = srv + return ms +} + +func (ms *mockUpstream) handle(w http.ResponseWriter, r *http.Request) { + call := int(ms.Calls.Add(1) - 1) + + body, err := io.ReadAll(r.Body) + defer r.Body.Close() + require.NoError(ms.t, err) + + ms.mu.Lock() + ms.requests = append(ms.requests, receivedRequest{ + Method: r.Method, + Path: r.URL.Path, + Header: r.Header.Clone(), + Body: append([]byte(nil), body...), + }) + ms.mu.Unlock() + + validateRequest(ms.t, call, r.URL.Path, body) + + resp := ms.responseForCall(call) + if resp.OnRequest != nil { + resp.OnRequest(r, body) + } + + if isStreaming(body, r.URL.Path) { + require.NotEmpty(ms.t, resp.Streaming, "response #%d: Streaming body is empty (fixture missing streaming response?)", call+1) + if isRawHTTPResponse(resp.Streaming) { + ms.writeRawHTTPResponse(w, r, resp.Streaming) + return + } + ms.writeSSE(w, resp.Streaming) + return + } + + require.NotEmpty(ms.t, resp.Blocking, "response #%d: Blocking body is empty (fixture missing non-streaming response?)", call+1) + if isRawHTTPResponse(resp.Blocking) { + ms.writeRawHTTPResponse(w, r, resp.Blocking) + return + } + + status := cmp.Or(ms.StatusCode, http.StatusOK) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(resp.Blocking) +} + +func (ms *mockUpstream) responseForCall(call int) upstreamResponse { + if call >= len(ms.responses) { + if ms.AllowOverflow { + return ms.responses[len(ms.responses)-1] + } + ms.t.Fatalf("unexpected number of calls: %v, got only %v responses", call, len(ms.responses)) + } + return ms.responses[call] +} + +func isStreaming(body []byte, urlPath string) bool { + // The Anthropic SDK's Bedrock middleware extracts "stream" + // from the JSON body and encodes them in the URL path instead. + // See: https://github.com/anthropics/anthropic-sdk-go/blob/4d669338f2041f3c60640b6dd317c4895dc71cd4/bedrock/bedrock.go#L247-L248 + return gjson.GetBytes(body, "stream").Bool() || strings.HasSuffix(urlPath, "invoke-with-response-stream") +} + +func (ms *mockUpstream) writeSSE(w http.ResponseWriter, data []byte) { + ms.t.Helper() + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + + // Write line-by-line to simulate SSE events arriving incrementally + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + _, err := fmt.Fprintf(w, "%s\n", scanner.Text()) + if eventstream.IsConnError(err) { + return // client disconnected, stop writing + } + require.NoError(ms.t, err) + flusher.Flush() + } + require.NoError(ms.t, scanner.Err()) +} + +// isRawHTTPResponse returns true if data starts with "HTTP/", indicating +// it contains a complete HTTP response (status line + headers + body) rather +// than just a response body. +func isRawHTTPResponse(data []byte) bool { + return bytes.HasPrefix(data, []byte("HTTP/")) +} + +// writeRawHTTPResponse parses data as a complete HTTP response and replays it, +// copying the status code, headers, and body to w. This supports error fixtures +// that contain full HTTP responses (e.g. "HTTP/2.0 400 Bad Request\r\n..."). +func (ms *mockUpstream) writeRawHTTPResponse(w http.ResponseWriter, r *http.Request, data []byte) { + ms.t.Helper() + + resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(data)), r) + require.NoError(ms.t, err) + defer resp.Body.Close() + + for key, values := range resp.Header { + for _, value := range values { + w.Header().Add(key, value) + } + } + w.WriteHeader(resp.StatusCode) + + _, err = io.Copy(w, resp.Body) + require.NoError(ms.t, err) +} + +// validateRequest dispatches to provider-specific validators based on URL path +// and fails the test immediately if the request body is invalid. +func validateRequest(t *testing.T, call int, path string, body []byte) { + t.Helper() + + msgAndArgs := []any{fmt.Sprintf("request #%d validation failed\n\nBody:\n%s", call+1, body)} + switch { + case strings.Contains(path, "/chat/completions"): + validateOpenAIChatCompletion(t, body, msgAndArgs...) + case strings.Contains(path, "/responses"): + validateOpenAIResponses(t, body, msgAndArgs...) + case strings.Contains(path, "/messages"): + validateAnthropicMessages(t, body, msgAndArgs...) + } +} + +// validateOpenAIChatCompletion validates that an OpenAI chat completion request +// has all required fields. +// See https://platform.openai.com/docs/api-reference/chat/create. +func validateOpenAIChatCompletion(t *testing.T, body []byte, msgAndArgs ...any) { + t.Helper() + + var req openai.ChatCompletionNewParams + require.NoError(t, json.Unmarshal(body, &req), msgAndArgs...) + require.NotEmpty(t, req.Model, "model is required", msgAndArgs) + require.NotEmpty(t, req.Messages, "messages is required", msgAndArgs) +} + +// validateOpenAIResponses validates that an OpenAI responses request +// has all required fields. +// See https://platform.openai.com/docs/api-reference/responses/create. +func validateOpenAIResponses(t *testing.T, body []byte, msgAndArgs ...any) { + t.Helper() + + var m map[string]any + require.NoError(t, json.Unmarshal(body, &m), msgAndArgs...) + require.NotEmpty(t, m["model"], "model is required", msgAndArgs) + require.Contains(t, m, "input", msgAndArgs...) +} + +// validateAnthropicMessages validates that an Anthropic messages request +// has all required fields. +// See https://github.com/anthropics/anthropic-sdk-go. +func validateAnthropicMessages(t *testing.T, body []byte, msgAndArgs ...any) { + t.Helper() + + var req anthropic.MessageNewParams + require.NoError(t, json.Unmarshal(body, &req), msgAndArgs...) + require.NotEmpty(t, req.Model, "model is required", msgAndArgs) + require.NotEmpty(t, req.Messages, "messages is required", msgAndArgs) + require.NotZero(t, req.MaxTokens, "max_tokens is required", msgAndArgs) +} diff --git a/aibridge/internal/integrationtest/responses_test.go b/aibridge/internal/integrationtest/responses_test.go new file mode 100644 index 0000000000..dd333fd3aa --- /dev/null +++ b/aibridge/internal/integrationtest/responses_test.go @@ -0,0 +1,1126 @@ +package integrationtest //nolint:testpackage // tests unexported internals + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "sync" + "testing" + "time" + + "github.com/openai/openai-go/v3/responses" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/sjson" + + "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/fixtures" + "github.com/coder/coder/v2/aibridge/internal/testutil" + "github.com/coder/coder/v2/aibridge/provider" + "github.com/coder/coder/v2/aibridge/recorder" + "github.com/coder/coder/v2/aibridge/utils" +) + +type keyVal struct { + key string + val any +} + +func TestResponsesOutputMatchesUpstream(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fixture []byte + streaming bool + expectModel string + expectPromptRecorded string + expectToolRecorded *recorder.ToolUsageRecord + expectTokenUsage *recorder.TokenUsageRecord + userAgent string + expectedClient aibridge.Client + }{ + { + name: "blocking_simple", + fixture: fixtures.OaiResponsesBlockingSimple, + expectModel: "gpt-4o-mini", + expectPromptRecorded: "tell me a joke", + expectTokenUsage: &recorder.TokenUsageRecord{ + MsgID: "resp_0388c79043df3e3400695f9f83cd6481959062cec6830d8d51", + Input: 11, + Output: 18, + ExtraTokenTypes: map[string]int64{ + "input_cached": 0, + "output_reasoning": 0, + "total_tokens": 29, + }, + }, + userAgent: "claude-cli/2.0.67 (external, cli)", + expectedClient: aibridge.ClientClaudeCode, + }, + { + name: "blocking_builtin_tool", + fixture: fixtures.OaiResponsesBlockingSingleBuiltinTool, + expectModel: "gpt-4.1", + expectPromptRecorded: "Is 3 + 5 a prime number? Use the add function to calculate the sum.", + expectToolRecorded: &recorder.ToolUsageRecord{ + MsgID: "resp_0da6045a8b68fa5200695fa23dcc2c81a19c849f627abf8a31", + Tool: "add", + ToolCallID: "call_CJSaa2u51JG996575oVljuNq", + Args: map[string]any{"a": float64(3), "b": float64(5)}, + Injected: false, + }, + expectTokenUsage: &recorder.TokenUsageRecord{ + MsgID: "resp_0da6045a8b68fa5200695fa23dcc2c81a19c849f627abf8a31", + Input: 58, + Output: 18, + ExtraTokenTypes: map[string]int64{ + "input_cached": 0, + "output_reasoning": 0, + "total_tokens": 76, + }, + }, + expectedClient: aibridge.ClientUnknown, + }, + { + name: "blocking_cached_input_tokens", + fixture: fixtures.OaiResponsesBlockingCachedInputTokens, + expectModel: "gpt-4.1", + expectPromptRecorded: "This was a large input...", + expectTokenUsage: &recorder.TokenUsageRecord{ + MsgID: "resp_0cd5d6b8310055d600696a1776b42c81a199fbb02248a8bfa0", + Input: 129, // 12033 input - 11904 cached + Output: 44, + CacheReadInputTokens: 11904, + ExtraTokenTypes: map[string]int64{ + "input_cached": 11904, + "output_reasoning": 0, + "total_tokens": 12077, + }, + }, + expectedClient: aibridge.ClientUnknown, + }, + { + name: "blocking_custom_tool", + fixture: fixtures.OaiResponsesBlockingCustomTool, + expectModel: "gpt-5", + expectPromptRecorded: "Use the code_exec tool to print hello world to the console.", + expectToolRecorded: &recorder.ToolUsageRecord{ + MsgID: "resp_09c614364030cdf000696942589da081a0af07f5859acb7308", + Tool: "code_exec", + ToolCallID: "call_haf8njtwrVZ1754Gm6fjAtuA", + Args: "print(\"hello world\")", + Injected: false, + }, + expectTokenUsage: &recorder.TokenUsageRecord{ + MsgID: "resp_09c614364030cdf000696942589da081a0af07f5859acb7308", + Input: 64, + Output: 148, + ExtraTokenTypes: map[string]int64{ + "input_cached": 0, + "output_reasoning": 128, + "total_tokens": 212, + }, + }, + expectedClient: aibridge.ClientUnknown, + }, + { + name: "blocking_conversation", + fixture: fixtures.OaiResponsesBlockingConversation, + expectModel: "gpt-4o-mini", + expectPromptRecorded: "explain why this is funny.", + expectTokenUsage: &recorder.TokenUsageRecord{ + MsgID: "resp_0c9f1f0524a858fa00695fa15fc5a081958f4304aafd3bdec2", + Input: 48, + Output: 116, + ExtraTokenTypes: map[string]int64{ + "input_cached": 0, + "output_reasoning": 0, + "total_tokens": 164, + }, + }, + expectedClient: aibridge.ClientUnknown, + }, + { + name: "blocking_prev_response_id", + fixture: fixtures.OaiResponsesBlockingPrevResponseID, + expectModel: "gpt-4o-mini", + expectPromptRecorded: "explain why this is funny.", + expectTokenUsage: &recorder.TokenUsageRecord{ + MsgID: "resp_0388c79043df3e3400695f9f86cfa08195af1f015c60117a83", + Input: 43, + Output: 129, + ExtraTokenTypes: map[string]int64{ + "input_cached": 0, + "output_reasoning": 0, + "total_tokens": 172, + }, + }, + expectedClient: aibridge.ClientUnknown, + }, + { + name: "streaming_simple", + fixture: fixtures.OaiResponsesStreamingSimple, + streaming: true, + expectModel: "gpt-4o-mini", + expectPromptRecorded: "tell me a joke", + expectTokenUsage: &recorder.TokenUsageRecord{ + MsgID: "resp_0f9c4b2f224d858000695fa062bf048197a680f357bbb09000", + Input: 11, + Output: 18, + ExtraTokenTypes: map[string]int64{ + "input_cached": 0, + "output_reasoning": 0, + "total_tokens": 29, + }, + }, + userAgent: "Zed/0.219.4+stable.119.abc123 (macos; aarch64)", + expectedClient: aibridge.ClientZed, + }, + { + name: "streaming_codex", + fixture: fixtures.OaiResponsesStreamingCodex, + streaming: true, + expectModel: "gpt-5-codex", + expectPromptRecorded: "hello", + expectTokenUsage: &recorder.TokenUsageRecord{ + MsgID: "resp_0e172b76542a9100016964f7e63d888191a2a28cb2ba0ab6d3", + Input: 4006, + Output: 13, + ExtraTokenTypes: map[string]int64{ + "input_cached": 0, + "output_reasoning": 0, + "total_tokens": 4019, + }, + }, + userAgent: "codex_cli_rs/0.87.0 (Mac OS 26.2.0; arm64)", + expectedClient: aibridge.ClientCodex, + }, + { + name: "streaming_builtin_tool", + fixture: fixtures.OaiResponsesStreamingBuiltinTool, + streaming: true, + expectModel: "gpt-4.1", + expectPromptRecorded: "Is 3 + 5 a prime number? Use the add function to calculate the sum.", + expectToolRecorded: &recorder.ToolUsageRecord{ + MsgID: "resp_0c3fb28cfcf463a500695fa2f0239481a095ec6ce3dfe4d458", + Tool: "add", + ToolCallID: "call_7VaiUXZYuuuwWwviCrckxq6t", + Args: map[string]any{"a": float64(3), "b": float64(5)}, + Injected: false, + }, + expectTokenUsage: &recorder.TokenUsageRecord{ + MsgID: "resp_0c3fb28cfcf463a500695fa2f0239481a095ec6ce3dfe4d458", + Input: 58, + Output: 18, + ExtraTokenTypes: map[string]int64{ + "input_cached": 0, + "output_reasoning": 0, + "total_tokens": 76, + }, + }, + expectedClient: aibridge.ClientUnknown, + }, + { + name: "streaming_cached_tokens", + fixture: fixtures.OaiResponsesStreamingCachedInputTokens, + streaming: true, + expectModel: "gpt-5.2-codex", + expectPromptRecorded: "Test cached input tokens.", + expectTokenUsage: &recorder.TokenUsageRecord{ + MsgID: "resp_05080461b406f3f501696a1409d34c8195a40ff4b092145c35", + Input: 1165, // 16909 input - 15744 cached + Output: 54, + CacheReadInputTokens: 15744, + ExtraTokenTypes: map[string]int64{ + "input_cached": 15744, + "output_reasoning": 0, + "total_tokens": 16963, + }, + }, + expectedClient: aibridge.ClientUnknown, + }, + { + name: "streaming_custom_tool", + fixture: fixtures.OaiResponsesStreamingCustomTool, + streaming: true, + expectModel: "gpt-5", + expectPromptRecorded: "Use the code_exec tool to print hello world to the console.", + expectToolRecorded: &recorder.ToolUsageRecord{ + MsgID: "resp_0c26996bc41c2a0500696942e83634819fb71b2b8ff8a4a76c", + Tool: "code_exec", + ToolCallID: "call_2gSnF58IEhXLwlbnqbm5XKMd", + Args: "print(\"hello world\")", + Injected: false, + }, + expectTokenUsage: &recorder.TokenUsageRecord{ + MsgID: "resp_0c26996bc41c2a0500696942e83634819fb71b2b8ff8a4a76c", + Input: 64, + Output: 340, + ExtraTokenTypes: map[string]int64{ + "input_cached": 0, + "output_reasoning": 320, + "total_tokens": 404, + }, + }, + expectedClient: aibridge.ClientUnknown, + }, + { + name: "streaming_conversation", + fixture: fixtures.OaiResponsesStreamingConversation, + streaming: true, + expectModel: "gpt-4o-mini", + expectPromptRecorded: "explain why this is funny.", + expectedClient: aibridge.ClientUnknown, + }, + { + name: "streaming_prev_response_id", + fixture: fixtures.OaiResponsesStreamingPrevResponseID, + streaming: true, + expectModel: "gpt-4o-mini", + expectPromptRecorded: "explain why this is funny.", + expectTokenUsage: &recorder.TokenUsageRecord{ + MsgID: "resp_0f9c4b2f224d858000695fa0649b8c8197b38914b15a7add0e", + Input: 43, + Output: 182, + ExtraTokenTypes: map[string]int64{ + "input_cached": 0, + "output_reasoning": 0, + "total_tokens": 225, + }, + }, + expectedClient: aibridge.ClientUnknown, + }, + { + name: "stream_error", + fixture: fixtures.OaiResponsesStreamingStreamError, + streaming: true, + expectModel: "gpt-6.7", + expectPromptRecorded: "hello_stream_error", + expectedClient: aibridge.ClientUnknown, + }, + { + name: "stream_failure", + fixture: fixtures.OaiResponsesStreamingStreamFailure, + streaming: true, + expectModel: "gpt-6.7", + expectPromptRecorded: "hello_stream_failure", + expectedClient: aibridge.ClientUnknown, + }, + + // Original status code and body is kept even with wrong json format + { + name: "blocking_wrong_format", + fixture: fixtures.OaiResponsesBlockingWrongResponseFormat, + expectModel: "gpt-6.7", + expectedClient: aibridge.ClientUnknown, + }, + { + name: "streaming_wrong_format", + fixture: fixtures.OaiResponsesStreamingWrongResponseFormat, + streaming: true, + expectModel: "gpt-6.7", + expectPromptRecorded: "hello_wrong_format", + expectedClient: aibridge.ClientUnknown, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathOpenAIResponses, fix.Request(), http.Header{"User-Agent": {tc.userAgent}}) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + got, err := io.ReadAll(resp.Body) + + require.NoError(t, err) + if tc.streaming { + require.Equal(t, string(fix.Streaming()), string(got)) + } else { + require.Equal(t, string(fix.NonStreaming()), string(got)) + } + + interceptions := bridgeServer.Recorder.RecordedInterceptions() + require.Len(t, interceptions, 1) + intc := interceptions[0] + require.Equal(t, intc.InitiatorID, defaultActorID) + require.Equal(t, intc.Provider, config.ProviderOpenAI) + require.Equal(t, intc.Model, tc.expectModel) + require.Equal(t, tc.userAgent, intc.UserAgent) + require.Equal(t, string(tc.expectedClient), intc.Client) + + recordedPrompts := bridgeServer.Recorder.RecordedPromptUsages() + if tc.expectPromptRecorded != "" { + require.Len(t, recordedPrompts, 1) + promptEq := func(pur *recorder.PromptUsageRecord) bool { return pur.Prompt == tc.expectPromptRecorded } + require.Truef(t, slices.ContainsFunc(recordedPrompts, promptEq), "promnt not found, got: %v, want: %v", recordedPrompts, tc.expectPromptRecorded) + } else { + require.Empty(t, recordedPrompts) + } + + recordedTools := bridgeServer.Recorder.RecordedToolUsages() + if tc.expectToolRecorded != nil { + require.Len(t, recordedTools, 1) + recordedTools[0].InterceptionID = tc.expectToolRecorded.InterceptionID // ignore interception id (interception id is not constant and response doesn't contain it) + recordedTools[0].CreatedAt = tc.expectToolRecorded.CreatedAt // ignore time + require.Equal(t, tc.expectToolRecorded, recordedTools[0]) + } else { + require.Empty(t, recordedTools) + } + + recordedTokens := bridgeServer.Recorder.RecordedTokenUsages() + if tc.expectTokenUsage != nil { + require.Len(t, recordedTokens, 1) + recordedTokens[0].InterceptionID = tc.expectTokenUsage.InterceptionID // ignore interception id + recordedTokens[0].CreatedAt = tc.expectTokenUsage.CreatedAt // ignore time + require.Equal(t, tc.expectTokenUsage, recordedTokens[0]) + } else { + require.Empty(t, recordedTokens) + } + }) + } +} + +func TestResponsesBackgroundModeForbidden(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + streaming bool + }{ + { + name: "blocking", + streaming: false, + }, + { + name: "streaming", + streaming: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + // request with Background mode should be rejected before it reaches upstream + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request to upstream: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(upstream.Close) + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL) + + // Create a request with background mode enabled + reqBytes := responsesRequestBytes(t, tc.streaming, keyVal{"background", true}) + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathOpenAIResponses, reqBytes) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, "application/json", resp.Header.Get("Content-Type")) + require.Equal(t, http.StatusNotImplemented, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + requireResponsesError(t, http.StatusNotImplemented, "background requests are currently not supported by AI Bridge", body) + }) + } +} + +func TestResponsesParallelToolsOverwritten(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + fixture [2][]byte // [blocking, streaming] fixture pair. + withInjectedTools bool + initialSetting *bool + expectedSetting *bool // nil = field should not be present, non-nil = expected value. + }{ + // With injected tools and builtin tools: parallel_tool_calls should be forced false. + { + name: "with injected and builtin tools: parallel_tool_calls true", + fixture: [2][]byte{fixtures.OaiResponsesBlockingSingleBuiltinTool, fixtures.OaiResponsesStreamingBuiltinTool}, + withInjectedTools: true, + initialSetting: utils.PtrTo(true), + expectedSetting: utils.PtrTo(false), + }, + { + name: "with injected and builtin tools: parallel_tool_calls false", + fixture: [2][]byte{fixtures.OaiResponsesBlockingSingleBuiltinTool, fixtures.OaiResponsesStreamingBuiltinTool}, + withInjectedTools: true, + initialSetting: utils.PtrTo(false), + expectedSetting: utils.PtrTo(false), + }, + { + name: "with injected and builtin tools: parallel_tool_calls unset", + fixture: [2][]byte{fixtures.OaiResponsesBlockingSingleBuiltinTool, fixtures.OaiResponsesStreamingBuiltinTool}, + withInjectedTools: true, + initialSetting: nil, + expectedSetting: utils.PtrTo(false), + }, + // With injected tools but without builtin tools: parallel_tool_calls should be forced false. + { + name: "with injected tools only: parallel_tool_calls true", + fixture: [2][]byte{fixtures.OaiResponsesBlockingSimple, fixtures.OaiResponsesStreamingSimple}, + withInjectedTools: true, + initialSetting: utils.PtrTo(true), + expectedSetting: utils.PtrTo(false), + }, + { + name: "with injected tools only: parallel_tool_calls false", + fixture: [2][]byte{fixtures.OaiResponsesBlockingSimple, fixtures.OaiResponsesStreamingSimple}, + withInjectedTools: true, + initialSetting: utils.PtrTo(false), + expectedSetting: utils.PtrTo(false), + }, + { + name: "with injected tools only: parallel_tool_calls unset", + fixture: [2][]byte{fixtures.OaiResponsesBlockingSimple, fixtures.OaiResponsesStreamingSimple}, + withInjectedTools: true, + initialSetting: nil, + expectedSetting: utils.PtrTo(false), + }, + // With builtin tools but without injected tools: parallel_tool_calls should be preserved. + { + name: "with builtin tools only: parallel_tool_calls true", + fixture: [2][]byte{fixtures.OaiResponsesBlockingSingleBuiltinTool, fixtures.OaiResponsesStreamingBuiltinTool}, + withInjectedTools: false, + initialSetting: utils.PtrTo(true), + expectedSetting: utils.PtrTo(true), + }, + { + name: "with builtin tools only: parallel_tool_calls false", + fixture: [2][]byte{fixtures.OaiResponsesBlockingSingleBuiltinTool, fixtures.OaiResponsesStreamingBuiltinTool}, + withInjectedTools: false, + initialSetting: utils.PtrTo(false), + expectedSetting: utils.PtrTo(false), + }, + { + name: "with builtin tools only: parallel_tool_calls unset", + fixture: [2][]byte{fixtures.OaiResponsesBlockingSingleBuiltinTool, fixtures.OaiResponsesStreamingBuiltinTool}, + withInjectedTools: false, + initialSetting: nil, + expectedSetting: nil, + }, + // Without any tools: nothing is modified. + { + name: "no tools: parallel_tool_calls true", + fixture: [2][]byte{fixtures.OaiResponsesBlockingSimple, fixtures.OaiResponsesStreamingSimple}, + withInjectedTools: false, + initialSetting: utils.PtrTo(true), + expectedSetting: utils.PtrTo(true), + }, + { + name: "no tools: parallel_tool_calls false", + fixture: [2][]byte{fixtures.OaiResponsesBlockingSimple, fixtures.OaiResponsesStreamingSimple}, + withInjectedTools: false, + initialSetting: utils.PtrTo(false), + expectedSetting: utils.PtrTo(false), + }, + { + name: "no tools: parallel_tool_calls unset", + fixture: [2][]byte{fixtures.OaiResponsesBlockingSimple, fixtures.OaiResponsesStreamingSimple}, + withInjectedTools: false, + initialSetting: nil, + expectedSetting: nil, + }, + } + + for _, tc := range cases { + for i, streaming := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/streaming=%v", tc.name, streaming), func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, tc.fixture[i]) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + var opts []bridgeOption + if tc.withInjectedTools { + opts = append(opts, withMCP(setupMCPForTest(t, defaultTracer))) + } + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, opts...) + + var ( + reqBody = fix.Request() + err error + ) + if tc.initialSetting != nil { + reqBody, err = sjson.SetBytes(reqBody, "parallel_tool_calls", *tc.initialSetting) + require.NoError(t, err) + } + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathOpenAIResponses, reqBody) + require.NoError(t, err) + defer resp.Body.Close() + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + received := upstream.receivedRequests() + require.Len(t, received, 1) + + var upstreamReq map[string]any + require.NoError(t, json.Unmarshal(received[0].Body, &upstreamReq)) + + ptc, ok := upstreamReq["parallel_tool_calls"].(bool) + require.Equal(t, tc.expectedSetting != nil, ok, + "parallel_tool_calls presence mismatch") + if tc.expectedSetting != nil { + assert.Equal(t, *tc.expectedSetting, ptc) + } + }) + } + } +} + +func TestClientAndConnectionError(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + addr string + streaming bool + errContains string + }{ + { + name: "blocking_connection_refused", + addr: startRejectingListener(t), + streaming: false, + errContains: "connection reset by peer", + }, + { + name: "streaming_connection_refused", + addr: startRejectingListener(t), + streaming: true, + errContains: "connection reset by peer", + }, + { + name: "blocking_bad_url", + addr: "not_url", + streaming: false, + errContains: "unsupported protocol scheme", + }, + { + name: "streaming_bad_url", + addr: "not_url", + streaming: true, + errContains: "unsupported protocol scheme", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + // tc.addr may be an intentionally invalid URL; use withCustomProvider. + // MaxRetries is set to 0 to disable SDK retries and speed up the test. + cfg := openAICfg(tc.addr, apiKey) + maxRetries := 0 + cfg.MaxRetries = &maxRetries + bridgeServer := newBridgeTestServer(ctx, t, tc.addr, withCustomProvider(provider.NewOpenAI(cfg))) + + reqBytes := responsesRequestBytes(t, tc.streaming) + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathOpenAIResponses, reqBytes) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, "application/json", resp.Header.Get("Content-Type")) + require.Equal(t, http.StatusInternalServerError, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + requireResponsesError(t, http.StatusInternalServerError, tc.errContains, body) + require.Empty(t, bridgeServer.Recorder.RecordedPromptUsages()) + }) + } +} + +func TestUpstreamError(t *testing.T) { + t.Parallel() + + responsesError := `{"error":{"message":"Something went wrong","type":"invalid_request_error","param":null,"code":"invalid_request"}}` + nonResponsesError := `plain text error` + + tests := []struct { + name string + streaming bool + statusCode int + contentType string + body string + }{ + { + name: "blocking_responses_error", + streaming: false, + statusCode: http.StatusBadRequest, + contentType: "application/json", + body: responsesError, + }, + { + name: "streaming_responses_error", + streaming: true, + statusCode: http.StatusBadRequest, + contentType: "application/json", + body: responsesError, + }, + { + name: "blocking_non_responses_error", + streaming: false, + statusCode: http.StatusBadGateway, + contentType: "text/plain", + body: nonResponsesError, + }, + { + name: "streaming_non_responses_error", + streaming: true, + statusCode: http.StatusBadGateway, + contentType: "text/plain", + body: nonResponsesError, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", tc.contentType) + w.WriteHeader(tc.statusCode) + _, err := w.Write([]byte(tc.body)) + require.NoError(t, err) + })) + t.Cleanup(upstream.Close) + + // MaxRetries is set to 0 to disable SDK retries and speed up the test. + cfg := openAICfg(upstream.URL, apiKey) + maxRetries := 0 + cfg.MaxRetries = &maxRetries + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, withCustomProvider(provider.NewOpenAI(cfg))) + + reqBytes := responsesRequestBytes(t, tc.streaming) + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathOpenAIResponses, reqBytes) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, tc.statusCode, resp.StatusCode) + require.Equal(t, tc.contentType, resp.Header.Get("Content-Type")) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, tc.body, string(body)) + }) + } +} + +// TestResponsesInjectedTool tests that injected MCP tool calls trigger the inner agentic loop, +// invoke the tool via MCP, and send the result back to the model. +func TestResponsesInjectedTool(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fixture []byte + streaming bool + mcpToolName string + expectToolArgs map[string]any + expectToolError string // If non-empty, MCP tool returns this error. + expectPrompt string + expectTokenUsages []recorder.TokenUsageRecord + }{ + { + name: "blocking_success", + fixture: fixtures.OaiResponsesBlockingSingleInjectedTool, + mcpToolName: "coder_template_version_parameters", + expectToolArgs: map[string]any{ + "template_version_id": "aa4e30e4-a086-4df6-a364-1343f1458104", + }, + expectPrompt: "list the template params for version aa4e30e4-a086-4df6-a364-1343f1458104", + expectTokenUsages: []recorder.TokenUsageRecord{ + { + MsgID: "resp_012db006225b0ec700696b5de8a01481a28182ea6885448f93", + Input: 227, // 6371 input - 6144 cached + Output: 75, + CacheReadInputTokens: 6144, + ExtraTokenTypes: map[string]int64{ + "input_cached": 6144, + "output_reasoning": 25, + "total_tokens": 6446, + }, + }, + { + MsgID: "resp_012db006225b0ec700696b5dec1d4c81a2a6a416e31af39b90", + Input: 612, // 6756 input - 6144 cached + Output: 231, + CacheReadInputTokens: 6144, + ExtraTokenTypes: map[string]int64{ + "input_cached": 6144, + "output_reasoning": 43, + "total_tokens": 6987, + }, + }, + }, + }, + { + name: "blocking_tool_error", + fixture: fixtures.OaiResponsesBlockingSingleInjectedToolError, + mcpToolName: "coder_delete_template", + expectToolArgs: map[string]any{ + "template_id": "03cb4fdd-8109-4a22-8e22-bb4975171395", + }, + expectPrompt: "delete the template with ID 03cb4fdd-8109-4a22-8e22-bb4975171395, don't ask for confirmation", + expectToolError: "500 Internal error deleting template: unauthorized: rbac: forbidden", + expectTokenUsages: []recorder.TokenUsageRecord{ + { + MsgID: "resp_06e2afba24b6b2ad00696b774d1df0819eaf1ec802bc8a2ca9", + Input: 233, // 6377 input - 6144 cached + Output: 119, + CacheReadInputTokens: 6144, + ExtraTokenTypes: map[string]int64{ + "input_cached": 6144, + "output_reasoning": 70, + "total_tokens": 6496, + }, + }, + { + MsgID: "resp_06e2afba24b6b2ad00696b775044e8819ea14840698ef966e2", + Input: 395, // 6539 input - 6144 cached + Output: 144, + CacheReadInputTokens: 6144, + ExtraTokenTypes: map[string]int64{ + "input_cached": 6144, + "output_reasoning": 28, + "total_tokens": 6683, + }, + }, + }, + }, + { + name: "streaming_success", + fixture: fixtures.OaiResponsesStreamingSingleInjectedTool, + streaming: true, + mcpToolName: "coder_list_templates", + expectToolArgs: map[string]any{}, + expectPrompt: "List my coder templates.", + expectTokenUsages: []recorder.TokenUsageRecord{ + { + MsgID: "resp_016595fe42aa62ca0069724419c52081a0b7eb479c6bc8109f", + Input: 6269, // 6269 input - 0 cached + Output: 18, + ExtraTokenTypes: map[string]int64{ + "input_cached": 0, + "output_reasoning": 0, + "total_tokens": 6287, + }, + }, + { + MsgID: "resp_0bc5f54fce6df69a006972442175908194bb81d31f576e6ca6", + Input: 319, // 6463 input - 6144 cached + Output: 182, + CacheReadInputTokens: 6144, + ExtraTokenTypes: map[string]int64{ + "input_cached": 6144, + "output_reasoning": 0, + "total_tokens": 6645, + }, + }, + }, + }, + { + name: "streaming_tool_error", + fixture: fixtures.OaiResponsesStreamingSingleInjectedToolError, + streaming: true, + mcpToolName: "coder_create_workspace_build", + expectToolArgs: map[string]any{ + "transition": "start", + "workspace_id": "non_existing_id", + }, + expectPrompt: "Create a new workspace build for an workspace with id: 'non_existing_id'", + expectToolError: "workspace_id must be a valid UUID: invalid UUID length: 15", + expectTokenUsages: []recorder.TokenUsageRecord{ + { + MsgID: "resp_0dfed48e1052ad7f0069725ca129f88193b97d6deff1760524", + Input: 6280, // 6280 input - 0 cached + Output: 30, + ExtraTokenTypes: map[string]int64{ + "input_cached": 0, + "output_reasoning": 0, + "total_tokens": 6310, + }, + }, + { + MsgID: "resp_0dfed48e1052ad7f0069725ca39880819390fcc5b2eb8cf8c6", + Input: 6346, // 6346 input - 0 cached + Output: 56, + ExtraTokenTypes: map[string]int64{ + "input_cached": 0, + "output_reasoning": 0, + "total_tokens": 6402, + }, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + // Setup mock server for multi-turn interaction. + // First request → tool call response, second → tool response. + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix), newFixtureToolResponse(fix)) + + // Setup MCP server proxies (with mock tools). + mockMCP := setupMCPForTest(t, defaultTracer) + if tc.expectToolError != "" { + mockMCP.setToolError(tc.mcpToolName, tc.expectToolError) + } + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, withMCP(mockMCP)) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathOpenAIResponses, fix.Request()) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // Wait for both requests to be made (inner agentic loop). + require.Eventually(t, func() bool { + return upstream.Calls.Load() == 2 + }, testutil.WaitMedium, testutil.IntervalFast) + + // Verify the injected tool was invoked via MCP. + invocations := mockMCP.getCallsByTool(tc.mcpToolName) + require.Len(t, invocations, 1, "expected MCP tool to be invoked once") + + // Verify the injected tool usage was recorded. + toolUsages := bridgeServer.Recorder.RecordedToolUsages() + require.Len(t, toolUsages, 1) + require.Equal(t, tc.mcpToolName, toolUsages[0].Tool) + require.Equal(t, tc.expectToolArgs, toolUsages[0].Args) + require.True(t, toolUsages[0].Injected, "injected tool should be marked as injected") + if tc.expectToolError != "" { + require.Contains(t, toolUsages[0].InvocationError.Error(), tc.expectToolError) + } + + // Verify prompt was recorded. + prompts := bridgeServer.Recorder.RecordedPromptUsages() + require.Len(t, prompts, 1) + require.Equal(t, tc.expectPrompt, prompts[0].Prompt) + + tokenUsages := bridgeServer.Recorder.RecordedTokenUsages() + require.Len(t, tokenUsages, len(tc.expectTokenUsages)) + for i := range tokenUsages { + tokenUsages[i].InterceptionID = "" // ignore interception ID and time creation when comparing + tokenUsages[i].CreatedAt = time.Time{} + require.Equal(t, tc.expectTokenUsages[i], *tokenUsages[i]) + } + + // Verify the response is the final tool response (after agentic loop). + if tc.streaming { + require.Equal(t, string(fix.StreamingToolCall()), string(body)) + } else { + require.Equal(t, string(fix.NonStreamingToolCall()), string(body)) + } + }) + } +} + +func TestResponsesModelThoughts(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + fixture []byte + expectedThoughts []recorder.ModelThoughtRecord // nil means no tool usages expected at all + }{ + { + name: "single reasoning/blocking", + fixture: fixtures.OaiResponsesBlockingSingleBuiltinTool, + expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("The user wants to add 3 and 5", recorder.ThoughtSourceReasoningSummary)}, + }, + { + name: "single reasoning/streaming", + fixture: fixtures.OaiResponsesStreamingBuiltinTool, + expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("The user wants to add 3 and 5", recorder.ThoughtSourceReasoningSummary)}, + }, + { + name: "multiple reasoning items/blocking", + fixture: fixtures.OaiResponsesBlockingMultiReasoningBuiltinTool, + expectedThoughts: []recorder.ModelThoughtRecord{ + newModelThought("The user wants to add 3 and 5", recorder.ThoughtSourceReasoningSummary), + newModelThought("After adding, I will check if the result is prime", recorder.ThoughtSourceReasoningSummary), + }, + }, + { + name: "multiple reasoning items/streaming", + fixture: fixtures.OaiResponsesStreamingMultiReasoningBuiltinTool, + expectedThoughts: []recorder.ModelThoughtRecord{ + newModelThought("The user wants to add 3 and 5", recorder.ThoughtSourceReasoningSummary), + newModelThought("After adding, I will check if the result is prime", recorder.ThoughtSourceReasoningSummary), + }, + }, + { + name: "commentary/blocking", + fixture: fixtures.OaiResponsesBlockingCommentaryBuiltinTool, + expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("Checking whether 3 + 5 is prime by calling the add function first.", recorder.ThoughtSourceCommentary)}, + }, + { + name: "commentary/streaming", + fixture: fixtures.OaiResponsesStreamingCommentaryBuiltinTool, + expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("Checking whether 3 + 5 is prime by calling the add function first.", recorder.ThoughtSourceCommentary)}, + }, + { + name: "summary and commentary/blocking", + fixture: fixtures.OaiResponsesBlockingSummaryAndCommentaryBuiltinTool, + expectedThoughts: []recorder.ModelThoughtRecord{ + newModelThought("I need to add 3 and 5 to check primality.", recorder.ThoughtSourceReasoningSummary), + newModelThought("Let me calculate the sum first using the add function.", recorder.ThoughtSourceCommentary), + }, + }, + { + name: "summary and commentary/streaming", + fixture: fixtures.OaiResponsesStreamingSummaryAndCommentaryBuiltinTool, + expectedThoughts: []recorder.ModelThoughtRecord{ + newModelThought("I need to add 3 and 5 to check primality.", recorder.ThoughtSourceReasoningSummary), + newModelThought("Let me calculate the sum first using the add function.", recorder.ThoughtSourceCommentary), + }, + }, + { + name: "parallel tool calls/blocking", + fixture: fixtures.OaiResponsesBlockingSingleBuiltinToolParallel, + expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("The user wants two additions", recorder.ThoughtSourceReasoningSummary)}, + }, + { + name: "parallel tool calls/streaming", + fixture: fixtures.OaiResponsesStreamingSingleBuiltinToolParallel, + expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("The user wants two additions", recorder.ThoughtSourceReasoningSummary)}, + }, + { + name: "thoughts without tool calls", + fixture: fixtures.OaiResponsesStreamingCodex, // This fixture contains reasoning, but it's not associated with tool calls. + expectedThoughts: []recorder.ModelThoughtRecord{newModelThought("Preparing simple response", recorder.ThoughtSourceReasoningSummary)}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathOpenAIResponses, fix.Request()) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + bridgeServer.Recorder.VerifyModelThoughtsRecorded(t, tc.expectedThoughts) + bridgeServer.Recorder.VerifyAllInterceptionsEnded(t) + }) + } +} + +func requireResponsesError(t *testing.T, code int, message string, body []byte) { + var respErr responses.Error + err := json.Unmarshal(body, &respErr) + require.NoError(t, err) + + require.Equal(t, strconv.Itoa(code), respErr.Code) + require.Contains(t, respErr.Message, message) +} + +func responsesRequestBytes(t *testing.T, streaming bool, additionalFields ...keyVal) []byte { + reqBody := map[string]any{ + "input": "tell me a joke", + "model": "gpt-4o-mini", + "stream": streaming, + } + + for _, kv := range additionalFields { + reqBody[kv.key] = kv.val + } + + reqBytes, err := json.Marshal(reqBody) + require.NoError(t, err) + return reqBytes +} + +func startRejectingListener(t *testing.T) (addr string) { + t.Helper() + var wg sync.WaitGroup + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = ln.Close() + wg.Wait() + }) + + go func() { + for { + wg.Add(1) + defer wg.Done() + + c, err := ln.Accept() + if err != nil { + // When ln.Close() is called, Accept returns an error -> exit. + return + } + + // Read at least 1 byte so the client has started writing + // before we RST, ensuring a consistent "connection reset by peer". + buf := make([]byte, 1) + _, _ = c.Read(buf) + if tc, ok := c.(*net.TCPConn); ok { + _ = tc.SetLinger(0) + } + _ = c.Close() + } + }() + + return "http://" + ln.Addr().String() +} diff --git a/aibridge/internal/integrationtest/setupbridge.go b/aibridge/internal/integrationtest/setupbridge.go new file mode 100644 index 0000000000..a77ac6f61a --- /dev/null +++ b/aibridge/internal/integrationtest/setupbridge.go @@ -0,0 +1,261 @@ +package integrationtest + +import ( + "bytes" + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "github.com/tidwall/sjson" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/aibridge/config" + aibcontext "github.com/coder/coder/v2/aibridge/context" + "github.com/coder/coder/v2/aibridge/fixtures" + "github.com/coder/coder/v2/aibridge/internal/testutil" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/metrics" + "github.com/coder/coder/v2/aibridge/provider" + "github.com/coder/coder/v2/aibridge/recorder" +) + +const ( + pathAnthropicMessages = "/anthropic/v1/messages" + pathOpenAIChatCompletions = "/openai/v1/chat/completions" + pathOpenAIResponses = "/openai/v1/responses" + pathCopilotChatCompletions = "/copilot/chat/completions" + pathCopilotResponses = "/copilot/responses" + + // providerBedrock identifies a Bedrock provider in [withProvider]. + // other providers use config.Provider* constants. + providerBedrock = "bedrock" + + // defaults + apiKey = "api-key" + defaultActorID = "ae235cc1-9f8f-417d-a636-a7b170bac62e" +) + +var defaultTracer = otel.Tracer("integrationtest") + +type bridgeConfig struct { + providerBuilders []func(upstreamURL string) aibridge.Provider + metrics *metrics.Metrics + tracer trace.Tracer + mcpProxy mcp.ServerProxier + userID string + metadata recorder.Metadata + logger slog.Logger +} + +// bridgeTestServer wraps an httptest.Server running a RequestBridge. +type bridgeTestServer struct { + *httptest.Server + Recorder *testutil.MockRecorder + Bridge *aibridge.RequestBridge +} + +// makeRequest builds and executes an HTTP request against this server. +// Optional headers are applied after the default Content-Type. +func (s *bridgeTestServer) makeRequest(t *testing.T, method string, path string, body []byte, header ...http.Header) (*http.Response, error) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), method, s.URL+path, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + for _, h := range header { + for k, vals := range h { + for _, v := range vals { + req.Header.Add(k, v) + } + } + } + return http.DefaultClient.Do(req) +} + +type bridgeOption func(*bridgeConfig) + +// withProvider adds a default-configured provider of the given type. +// When any provider option is used, the default "all providers" set is not created. +func withProvider(providerType string) bridgeOption { + return func(c *bridgeConfig) { + c.providerBuilders = append(c.providerBuilders, func(addr string) aibridge.Provider { + return newDefaultProvider(providerType, addr) + }) + } +} + +// withCustomProvider adds a pre-built provider. The upstream URL passed to +// [newBridgeTestServer] is ignored for this provider. +// When any provider option is used, the default "all providers" set is not created. +func withCustomProvider(p aibridge.Provider) bridgeOption { + return func(c *bridgeConfig) { + c.providerBuilders = append(c.providerBuilders, func(string) aibridge.Provider { + return p + }) + } +} + +// withMetrics sets the Prometheus metrics for the bridge. +func withMetrics(m *metrics.Metrics) bridgeOption { + return func(c *bridgeConfig) { c.metrics = m } +} + +// withTracer overrides the default tracer. +func withTracer(t trace.Tracer) bridgeOption { + return func(c *bridgeConfig) { c.tracer = t } +} + +// withMCP sets the MCP server proxier (default: NoopMCPManager). +func withMCP(p mcp.ServerProxier) bridgeOption { + return func(c *bridgeConfig) { c.mcpProxy = p } +} + +// withActor sets the actor ID and metadata for the BaseContext. +func withActor(id string, md recorder.Metadata) bridgeOption { + return func(c *bridgeConfig) { c.userID = id; c.metadata = md } +} + +// newBridgeTestServer creates a fully configured test server running +// a RequestBridge with sensible defaults: +// - All standard providers (unless withProvider / withCustomProvider) +// - NoopMCPManager (unless withMCP) +// - slogtest debug logger +// - defaultTracer (unless withTracer) +// - defaultActorID (unless withActor) +func newBridgeTestServer( + ctx context.Context, + t *testing.T, + upstreamURL string, + opts ...bridgeOption, +) *bridgeTestServer { + t.Helper() + + cfg := &bridgeConfig{ + userID: defaultActorID, + } + for _, o := range opts { + o(cfg) + } + if cfg.tracer == nil { + cfg.tracer = defaultTracer + } + cfg.logger = newLogger(t) + if cfg.mcpProxy == nil { + cfg.mcpProxy = newNoopMCPManager() + } + + // Resolve providers: use explicit builders when provided, otherwise + // create default providers for every supported type. + var providers []aibridge.Provider + if len(cfg.providerBuilders) > 0 { + for _, b := range cfg.providerBuilders { + providers = append(providers, b(upstreamURL)) + } + } else { + providers = []aibridge.Provider{ + newDefaultProvider(config.ProviderAnthropic, upstreamURL), + newDefaultProvider(config.ProviderOpenAI, upstreamURL), + } + } + + mockRec := &testutil.MockRecorder{} + rec := aibridge.NewRecorder(cfg.logger, cfg.tracer, func() (aibridge.Recorder, error) { + return mockRec, nil + }) + + bridge, err := aibridge.NewRequestBridge( + ctx, providers, rec, cfg.mcpProxy, + cfg.logger, cfg.metrics, cfg.tracer, + ) + require.NoError(t, err) + + actorID, md := cfg.userID, cfg.metadata + srv := httptest.NewUnstartedServer(bridge) + srv.Config.BaseContext = func(_ net.Listener) context.Context { + return aibcontext.AsActor(ctx, actorID, md) + } + srv.Start() + t.Cleanup(srv.Close) + + return &bridgeTestServer{ + Server: srv, + Recorder: mockRec, + Bridge: bridge, + } +} + +// setupInjectedToolTest abstracts common setup required for injected-tool integration tests. +// Extra bridge options (e.g. [withProvider]) are appended after the built-in +// MCP / tracer / actor options. When no provider option is given the default +// provider set (all providers) is used. +func setupInjectedToolTest( + t *testing.T, + fixture []byte, + streaming bool, + tracer trace.Tracer, + path string, + toolRequestValidatorFn func(*http.Request, []byte), + opts ...bridgeOption, +) (*bridgeTestServer, *mockMCP, *http.Response) { + t.Helper() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + fix := fixtures.Parse(t, fixture) + + // Setup mock server for multi-turn interaction. + // First request → tool call response + // Second request → final response. + firstResp := newFixtureResponse(fix) + toolResp := newFixtureToolResponse(fix) + toolResp.OnRequest = toolRequestValidatorFn + upstream := newMockUpstream(ctx, t, firstResp, toolResp) + + mockMCP := setupMCPForTest(t, tracer) + + allOpts := []bridgeOption{ + withMCP(mockMCP), + withTracer(tracer), + withActor(defaultActorID, nil), + } + allOpts = append(allOpts, opts...) + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, allOpts...) + + // Add the stream param to the request. + reqBody, err := sjson.SetBytes(fix.Request(), "stream", streaming) + require.NoError(t, err) + + resp, err := bridgeServer.makeRequest(t, http.MethodPost, path, reqBody) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + // Wait both requests (initial + tool call result) + require.Eventually(t, func() bool { + return upstream.Calls.Load() == 2 + }, testutil.WaitMedium, testutil.IntervalFast) + + return bridgeServer, mockMCP, resp +} + +// newDefaultProvider creates a Provider with default test configuration. +func newDefaultProvider(providerType string, addr string) aibridge.Provider { + switch providerType { + case config.ProviderAnthropic: + return provider.NewAnthropic(anthropicCfg(addr, apiKey), nil) + case config.ProviderOpenAI: + return provider.NewOpenAI(openAICfg(addr, apiKey)) + case providerBedrock: + return provider.NewAnthropic(anthropicCfg(addr, apiKey), bedrockCfg(addr)) + default: + panic("unknown provider type: " + providerType) + } +} diff --git a/aibridge/internal/integrationtest/trace_test.go b/aibridge/internal/integrationtest/trace_test.go new file mode 100644 index 0000000000..f3e835ca8a --- /dev/null +++ b/aibridge/internal/integrationtest/trace_test.go @@ -0,0 +1,831 @@ +package integrationtest //nolint:testpackage // tests unexported internals + +import ( + "context" + "net/http" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + oteltrace "go.opentelemetry.io/otel/trace" + + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/fixtures" + "github.com/coder/coder/v2/aibridge/internal/testutil" + "github.com/coder/coder/v2/aibridge/tracing" +) + +// expect 'count' amount of traces named 'name' with status 'status' +type expectTrace struct { + name string + count int + status codes.Code +} + +func setupTracer(t *testing.T) (*tracetest.SpanRecorder, oteltrace.Tracer) { + t.Helper() + + sr := tracetest.NewSpanRecorder() + tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr)) + t.Cleanup(func() { + _ = tp.Shutdown(t.Context()) + }) + + return sr, tp.Tracer(t.Name()) +} + +func TestTraceAnthropic(t *testing.T) { + t.Parallel() + + expectNonStreaming := []expectTrace{ + {"Intercept", 1, codes.Unset}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Unset}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.RecordPromptUsage", 1, codes.Unset}, + {"Intercept.RecordTokenUsage", 1, codes.Unset}, + {"Intercept.RecordToolUsage", 1, codes.Unset}, + {"Intercept.RecordModelThought", 1, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Unset}, + } + + expectStreaming := []expectTrace{ + {"Intercept", 1, codes.Unset}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Unset}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.RecordPromptUsage", 1, codes.Unset}, + {"Intercept.RecordTokenUsage", 2, codes.Unset}, + {"Intercept.RecordToolUsage", 1, codes.Unset}, + {"Intercept.RecordModelThought", 1, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Unset}, + } + + cases := []struct { + name string + fixture []byte + streaming bool + bedrock bool + expect []expectTrace + }{ + { + name: "trace_anthr_non_streaming", + expect: expectNonStreaming, + fixture: fixtures.AntSingleBuiltinTool, + }, + { + name: "trace_bedrock_non_streaming", + bedrock: true, + expect: expectNonStreaming, + fixture: fixtures.AntSingleBuiltinTool, + }, + { + name: "trace_anthr_streaming", + streaming: true, + expect: expectStreaming, + fixture: fixtures.AntSingleBuiltinTool, + }, + { + name: "trace_bedrock_streaming", + streaming: true, + bedrock: true, + expect: expectStreaming, + fixture: fixtures.AntSingleBuiltinTool, + }, + { + name: "trace_multi_thinking_non_streaming", + fixture: fixtures.AntMultiThinkingBuiltinTool, + expect: []expectTrace{ + {"Intercept", 1, codes.Unset}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Unset}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.RecordPromptUsage", 1, codes.Unset}, + {"Intercept.RecordTokenUsage", 1, codes.Unset}, + {"Intercept.RecordToolUsage", 1, codes.Unset}, + {"Intercept.RecordModelThought", 2, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Unset}, + }, + }, + { + name: "trace_multi_thinking_streaming", + fixture: fixtures.AntMultiThinkingBuiltinTool, + streaming: true, + expect: []expectTrace{ + {"Intercept", 1, codes.Unset}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Unset}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.RecordPromptUsage", 1, codes.Unset}, + {"Intercept.RecordTokenUsage", 2, codes.Unset}, + {"Intercept.RecordToolUsage", 1, codes.Unset}, + {"Intercept.RecordModelThought", 2, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Unset}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + sr, tracer := setupTracer(t) + + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + opts := []bridgeOption{ + withTracer(tracer), + } + if tc.bedrock { + opts = append(opts, withProvider(providerBedrock)) + } + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, opts...) + + reqBody, err := sjson.SetBytes(fix.Request(), "stream", tc.streaming) + require.NoError(t, err) + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + bridgeServer.Close() + + require.Equal(t, 1, len(bridgeServer.Recorder.RecordedInterceptions())) + intcID := bridgeServer.Recorder.RecordedInterceptions()[0].ID + + model := gjson.Get(string(reqBody), "model").Str + if tc.bedrock { + model = "beddel" + } + + totalCount := 0 + for _, e := range tc.expect { + totalCount += e.count + } + + attrs := []attribute.KeyValue{ + attribute.String(tracing.RequestPath, "/anthropic/v1/messages"), + attribute.String(tracing.InterceptionID, intcID), + attribute.String(tracing.Provider, config.ProviderAnthropic), + attribute.String(tracing.Model, model), + attribute.String(tracing.InitiatorID, defaultActorID), + attribute.Bool(tracing.Streaming, tc.streaming), + attribute.Bool(tracing.IsBedrock, tc.bedrock), + } + + require.Len(t, sr.Ended(), totalCount) + verifyTraces(t, sr, tc.expect, attrs) + }) + } +} + +func TestTraceAnthropicErr(t *testing.T) { + t.Parallel() + + expectNonStream := []expectTrace{ + {"Intercept", 1, codes.Error}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Error}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Error}, + } + + expectStreaming := []expectTrace{ + {"Intercept", 1, codes.Error}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Error}, + {"Intercept.RecordPromptUsage", 1, codes.Unset}, + {"Intercept.RecordTokenUsage", 1, codes.Unset}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Unset}, + } + + cases := []struct { + name string + fixture []byte + streaming bool + bedrock bool + expectCode int // expected status code for non-streaming responses + expect []expectTrace + }{ + { + name: "anthr_non_streaming_err", + fixture: fixtures.AntNonStreamError, + expectCode: http.StatusBadRequest, + expect: expectNonStream, + }, + { + name: "anthr_streaming_err", + fixture: fixtures.AntMidStreamError, + streaming: true, + expect: expectStreaming, + }, + { + name: "bedrock_non_streaming_err", + fixture: fixtures.AntNonStreamError, + bedrock: true, + expectCode: http.StatusBadRequest, + expect: expectNonStream, + }, + { + name: "bedrock_streaming_err", + fixture: fixtures.AntMidStreamError, + streaming: true, + bedrock: true, + expect: expectStreaming, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + sr, tracer := setupTracer(t) + + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + + opts := []bridgeOption{ + withTracer(tracer), + } + if tc.bedrock { + opts = append(opts, withProvider(providerBedrock)) + } + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, opts...) + + reqBody, err := sjson.SetBytes(fix.Request(), "stream", tc.streaming) + require.NoError(t, err) + resp, err := bridgeServer.makeRequest(t, http.MethodPost, pathAnthropicMessages, reqBody) + require.NoError(t, err) + defer resp.Body.Close() + if tc.streaming { + require.Equal(t, http.StatusOK, resp.StatusCode) + } else { + require.Equal(t, tc.expectCode, resp.StatusCode) + } + bridgeServer.Close() + + require.Equal(t, 1, len(bridgeServer.Recorder.RecordedInterceptions())) + intcID := bridgeServer.Recorder.RecordedInterceptions()[0].ID + + totalCount := 0 + for _, e := range tc.expect { + totalCount += e.count + } + for _, s := range sr.Ended() { + t.Logf("SPAN: %v", s.Name()) + } + require.Len(t, sr.Ended(), totalCount) + + model := gjson.Get(string(reqBody), "model").Str + if tc.bedrock { + model = "beddel" + } + + attrs := []attribute.KeyValue{ + attribute.String(tracing.RequestPath, "/anthropic/v1/messages"), + attribute.String(tracing.InterceptionID, intcID), + attribute.String(tracing.Provider, config.ProviderAnthropic), + attribute.String(tracing.Model, model), + attribute.String(tracing.InitiatorID, defaultActorID), + attribute.Bool(tracing.Streaming, tc.streaming), + attribute.Bool(tracing.IsBedrock, tc.bedrock), + } + + verifyTraces(t, sr, tc.expect, attrs) + }) + } +} + +func TestInjectedToolsTrace(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + streaming bool + bedrock bool + fixture []byte + path string + expectModel string + expectProvider string + opts []bridgeOption + }{ + { + name: "anthr_blocking", + streaming: false, + fixture: fixtures.AntSingleInjectedTool, + path: pathAnthropicMessages, + expectModel: "claude-sonnet-4-20250514", + expectProvider: config.ProviderAnthropic, + }, + { + name: "anthr_streaming", + streaming: true, + fixture: fixtures.AntSingleInjectedTool, + path: pathAnthropicMessages, + expectModel: "claude-sonnet-4-20250514", + expectProvider: config.ProviderAnthropic, + }, + { + name: "bedrock_blocking", + streaming: false, + bedrock: true, + fixture: fixtures.AntSingleInjectedTool, + path: pathAnthropicMessages, + expectModel: "beddel", + expectProvider: config.ProviderAnthropic, + opts: []bridgeOption{withProvider(providerBedrock)}, + }, + { + name: "bedrock_streaming", + streaming: true, + bedrock: true, + fixture: fixtures.AntSingleInjectedTool, + path: pathAnthropicMessages, + expectModel: "beddel", + expectProvider: config.ProviderAnthropic, + opts: []bridgeOption{withProvider(providerBedrock)}, + }, + { + name: "openai_blocking", + streaming: false, + fixture: fixtures.OaiChatSingleInjectedTool, + path: pathOpenAIChatCompletions, + expectModel: "gpt-4.1", + expectProvider: config.ProviderOpenAI, + }, + { + name: "openai_streaming", + streaming: true, + fixture: fixtures.OaiChatSingleInjectedTool, + path: pathOpenAIChatCompletions, + expectModel: "gpt-4.1", + expectProvider: config.ProviderOpenAI, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + sr, tracer := setupTracer(t) + + var validatorFn func(*http.Request, []byte) + if tc.expectProvider == config.ProviderAnthropic { + validatorFn = anthropicToolResultValidator(t) + } else { + validatorFn = openaiChatToolResultValidator(t) + } + + bridgeServer, mockMCP, resp := setupInjectedToolTest( + t, tc.fixture, tc.streaming, tracer, + tc.path, validatorFn, tc.opts..., + ) + defer resp.Body.Close() + + require.Len(t, bridgeServer.Recorder.RecordedInterceptions(), 1) + intcID := bridgeServer.Recorder.RecordedInterceptions()[0].ID + + tool := mockMCP.ListTools()[0] + + attrs := []attribute.KeyValue{ + attribute.String(tracing.RequestPath, tc.path), + attribute.String(tracing.InterceptionID, intcID), + attribute.String(tracing.Provider, tc.expectProvider), + attribute.String(tracing.Model, tc.expectModel), + attribute.String(tracing.InitiatorID, defaultActorID), + attribute.String(tracing.MCPInput, `{"owner":"admin"}`), + attribute.String(tracing.MCPToolName, "coder_list_workspaces"), + attribute.String(tracing.MCPServerName, tool.ServerName), + attribute.String(tracing.MCPServerURL, tool.ServerURL), + attribute.Bool(tracing.Streaming, tc.streaming), + } + if tc.expectProvider == config.ProviderAnthropic { + attrs = append(attrs, attribute.Bool(tracing.IsBedrock, tc.bedrock)) + } + + verifyTraces(t, sr, []expectTrace{{"Intercept.ProcessRequest.ToolCall", 1, codes.Unset}}, attrs) + }) + } +} + +func TestTraceOpenAI(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + fixture []byte + streaming bool + path string + + expect []expectTrace + }{ + { + name: "trace_openai_chat_streaming", + fixture: fixtures.OaiChatSimple, + streaming: true, + path: pathOpenAIChatCompletions, + expect: []expectTrace{ + {"Intercept", 1, codes.Unset}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Unset}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.RecordPromptUsage", 1, codes.Unset}, + {"Intercept.RecordTokenUsage", 1, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Unset}, + }, + }, + { + name: "trace_openai_chat_blocking", + fixture: fixtures.OaiChatSimple, + streaming: false, + path: pathOpenAIChatCompletions, + expect: []expectTrace{ + {"Intercept", 1, codes.Unset}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Unset}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.RecordPromptUsage", 1, codes.Unset}, + {"Intercept.RecordTokenUsage", 1, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Unset}, + }, + }, + { + name: "trace_openai_responses_streaming", + fixture: fixtures.OaiResponsesStreamingSimple, + streaming: true, + path: pathOpenAIResponses, + expect: []expectTrace{ + {"Intercept", 1, codes.Unset}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Unset}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.RecordPromptUsage", 1, codes.Unset}, + {"Intercept.RecordTokenUsage", 1, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Unset}, + }, + }, + { + name: "trace_openai_responses_blocking", + fixture: fixtures.OaiResponsesBlockingSimple, + streaming: false, + path: pathOpenAIResponses, + expect: []expectTrace{ + {"Intercept", 1, codes.Unset}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Unset}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.RecordPromptUsage", 1, codes.Unset}, + {"Intercept.RecordTokenUsage", 1, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Unset}, + }, + }, + { + name: "trace_openai_responses_streaming_with_reasoning", + fixture: fixtures.OaiResponsesStreamingMultiReasoningBuiltinTool, + streaming: true, + path: pathOpenAIResponses, + expect: []expectTrace{ + {"Intercept", 1, codes.Unset}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Unset}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.RecordPromptUsage", 1, codes.Unset}, + {"Intercept.RecordTokenUsage", 1, codes.Unset}, + {"Intercept.RecordToolUsage", 1, codes.Unset}, + {"Intercept.RecordModelThought", 2, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Unset}, + }, + }, + { + name: "trace_openai_responses_blocking_with_reasoning", + fixture: fixtures.OaiResponsesBlockingMultiReasoningBuiltinTool, + streaming: false, + path: pathOpenAIResponses, + expect: []expectTrace{ + {"Intercept", 1, codes.Unset}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Unset}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.RecordPromptUsage", 1, codes.Unset}, + {"Intercept.RecordTokenUsage", 1, codes.Unset}, + {"Intercept.RecordToolUsage", 1, codes.Unset}, + {"Intercept.RecordModelThought", 2, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Unset}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + sr, tracer := setupTracer(t) + + fix := fixtures.Parse(t, tc.fixture) + upstream := newMockUpstream(ctx, t, newFixtureResponse(fix)) + bridgeServer := newBridgeTestServer(ctx, t, upstream.URL, + withTracer(tracer), + ) + + reqBody, err := sjson.SetBytes(fix.Request(), "stream", tc.streaming) + require.NoError(t, err) + resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, reqBody) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + bridgeServer.Close() + + require.Equal(t, 1, len(bridgeServer.Recorder.RecordedInterceptions())) + intcID := bridgeServer.Recorder.RecordedInterceptions()[0].ID + + totalCount := 0 + for _, e := range tc.expect { + totalCount += e.count + } + require.Len(t, sr.Ended(), totalCount) + + attrs := []attribute.KeyValue{ + attribute.String(tracing.RequestPath, tc.path), + attribute.String(tracing.InterceptionID, intcID), + attribute.String(tracing.Provider, config.ProviderOpenAI), + attribute.String(tracing.Model, gjson.Get(string(reqBody), "model").Str), + attribute.String(tracing.InitiatorID, defaultActorID), + attribute.Bool(tracing.Streaming, tc.streaming), + } + verifyTraces(t, sr, tc.expect, attrs) + }) + } +} + +func TestTraceOpenAIErr(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + fixture []byte + streaming bool + allowOverflow bool + path string + + expect []expectTrace + expectCode int + }{ + { + name: "trace_openai_chat_streaming_error", + fixture: fixtures.OaiChatMidStreamError, + streaming: true, + path: pathOpenAIChatCompletions, + expectCode: http.StatusOK, + expect: []expectTrace{ + {"Intercept", 1, codes.Error}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Error}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.RecordPromptUsage", 1, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Unset}, + }, + }, + { + name: "trace_openai_chat_blocking_error", + fixture: fixtures.OaiChatNonStreamError, + streaming: false, + path: pathOpenAIChatCompletions, + expectCode: http.StatusBadRequest, + expect: []expectTrace{ + {"Intercept", 1, codes.Error}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Error}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Error}, + }, + }, + { + name: "trace_openai_responses_streaming_error", + streaming: true, + fixture: fixtures.OaiResponsesStreamingWrongResponseFormat, + path: pathOpenAIResponses, + expectCode: http.StatusOK, + expect: []expectTrace{ + {"Intercept", 1, codes.Error}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Error}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.RecordPromptUsage", 1, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Unset}, + }, + }, + { + name: "trace_openai_responses_blocking_error", + fixture: fixtures.OaiResponsesBlockingWrongResponseFormat, + streaming: false, + path: pathOpenAIResponses, + // Fixture returns http 200 response with wrong body + // responses forward received response as is so + // expected code == 200 even though ProcessRequest + // traces are expected to have error status + expectCode: http.StatusOK, + expect: []expectTrace{ + {"Intercept", 1, codes.Error}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Error}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Error}, + }, + }, + { + name: "trace_openai_responses_streaming_http_error", + fixture: fixtures.OaiResponsesStreamingHTTPErr, + streaming: true, + allowOverflow: true, // 429 error causes retries + + path: pathOpenAIResponses, + expectCode: http.StatusTooManyRequests, + expect: []expectTrace{ + {"Intercept", 1, codes.Error}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Error}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Unset}, + }, + }, + { + name: "trace_openai_responses_blocking_http_error", + fixture: fixtures.OaiResponsesBlockingHTTPErr, + streaming: false, + + path: pathOpenAIResponses, + expectCode: http.StatusUnauthorized, + expect: []expectTrace{ + {"Intercept", 1, codes.Error}, + {"Intercept.CreateInterceptor", 1, codes.Unset}, + {"Intercept.RecordInterception", 1, codes.Unset}, + {"Intercept.ProcessRequest", 1, codes.Error}, + {"Intercept.RecordInterceptionEnded", 1, codes.Unset}, + {"Intercept.ProcessRequest.Upstream", 1, codes.Error}, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + sr, tracer := setupTracer(t) + + fix := fixtures.Parse(t, tc.fixture) + + mockAPI := newMockUpstream(ctx, t, newFixtureResponse(fix)) + mockAPI.AllowOverflow = tc.allowOverflow + bridgeServer := newBridgeTestServer(ctx, t, mockAPI.URL, + withTracer(tracer), + ) + + reqBody, err := sjson.SetBytes(fix.Request(), "stream", tc.streaming) + require.NoError(t, err) + resp, err := bridgeServer.makeRequest(t, http.MethodPost, tc.path, reqBody) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, tc.expectCode, resp.StatusCode) + bridgeServer.Close() + + require.Equal(t, 1, len(bridgeServer.Recorder.RecordedInterceptions())) + intcID := bridgeServer.Recorder.RecordedInterceptions()[0].ID + + totalCount := 0 + for _, e := range tc.expect { + totalCount += e.count + } + require.Len(t, sr.Ended(), totalCount) + + attrs := []attribute.KeyValue{ + attribute.String(tracing.RequestPath, tc.path), + attribute.String(tracing.InterceptionID, intcID), + attribute.String(tracing.Provider, config.ProviderOpenAI), + attribute.String(tracing.Model, gjson.Get(string(reqBody), "model").Str), + attribute.String(tracing.InitiatorID, defaultActorID), + attribute.Bool(tracing.Streaming, tc.streaming), + } + verifyTraces(t, sr, tc.expect, attrs) + }) + } +} + +func TestTracePassthrough(t *testing.T) { + t.Parallel() + + fix := fixtures.Parse(t, fixtures.OaiChatFallthrough) + + upstream := newMockUpstream(t.Context(), t, newFixtureResponse(fix)) + + sr, tracer := setupTracer(t) + + bridgeServer := newBridgeTestServer(t.Context(), t, upstream.URL, + withTracer(tracer), + ) + + resp, err := bridgeServer.makeRequest(t, http.MethodGet, "/openai/v1/models", nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + bridgeServer.Close() + + spans := sr.Ended() + require.Len(t, spans, 1) + + assert.Equal(t, spans[0].Name(), "Passthrough") + want := []attribute.KeyValue{ + attribute.String(tracing.PassthroughMethod, "GET"), + attribute.String(tracing.PassthroughUpstreamURL, upstream.URL+"/models"), + attribute.String(tracing.PassthroughURL, "/models"), + } + got := slices.SortedFunc(slices.Values(spans[0].Attributes()), cmpAttrKeyVal) + require.Equal(t, want, got) +} + +func TestNewServerProxyManagerTraces(t *testing.T) { + t.Parallel() + + sr, tracer := setupTracer(t) + + serverName := "serverName" + mockMCP := setupMCPForTestWithName(t, serverName, tracer) + tool := mockMCP.ListTools()[0] + + require.Len(t, sr.Ended(), 3) + verifyTraces(t, sr, []expectTrace{{"ServerProxyManager.Init", 1, codes.Unset}}, []attribute.KeyValue{}) + + attrs := []attribute.KeyValue{ + attribute.String(tracing.MCPProxyName, serverName), + attribute.String(tracing.MCPServerURL, tool.ServerURL), + attribute.String(tracing.MCPServerName, serverName), + } + verifyTraces(t, sr, []expectTrace{{"StreamableHTTPServerProxy.Init", 1, codes.Unset}}, attrs) + + attrs = append(attrs, attribute.Int(tracing.MCPToolCount, len(mockMCP.ListTools()))) + verifyTraces(t, sr, []expectTrace{{"StreamableHTTPServerProxy.Init.fetchTools", 1, codes.Unset}}, attrs) +} + +func cmpAttrKeyVal(a attribute.KeyValue, b attribute.KeyValue) int { + return strings.Compare(string(a.Key), string(b.Key)) +} + +// checks counts of traces with given name, status and attributes +func verifyTraces(t *testing.T, spanRecorder *tracetest.SpanRecorder, expect []expectTrace, attrs []attribute.KeyValue) { + spans := spanRecorder.Ended() + + for _, e := range expect { + found := 0 + for _, s := range spans { + if s.Name() != e.name || s.Status().Code != e.status { + continue + } + found++ + want := slices.SortedFunc(slices.Values(attrs), cmpAttrKeyVal) + got := slices.SortedFunc(slices.Values(s.Attributes()), cmpAttrKeyVal) + require.Equal(t, want, got) + assert.Equalf(t, e.status, s.Status().Code, "unexpected status for trace naned: %v got: %v want: %v", e.name, s.Status().Code, e.status) + } + if found != e.count { + t.Errorf("found unexpected number of spans named: %v with status %v, got: %v want: %v", e.name, e.status, found, e.count) + } + } +} diff --git a/aibridge/internal/testutil/mock_recorder.go b/aibridge/internal/testutil/mock_recorder.go new file mode 100644 index 0000000000..52a86c847d --- /dev/null +++ b/aibridge/internal/testutil/mock_recorder.go @@ -0,0 +1,214 @@ +package testutil + +import ( + "context" + "slices" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/aibridge/recorder" +) + +// MockRecorder is a test implementation of aibridge.Recorder that +// captures all recording calls for test assertions. +type MockRecorder struct { + mu sync.Mutex + + interceptions []*recorder.InterceptionRecord + tokenUsages []*recorder.TokenUsageRecord + userPrompts []*recorder.PromptUsageRecord + toolUsages []*recorder.ToolUsageRecord + modelThoughts []*recorder.ModelThoughtRecord + interceptionsEnd map[string]*recorder.InterceptionRecordEnded +} + +func (m *MockRecorder) RecordInterception(_ context.Context, req *recorder.InterceptionRecord) error { + m.mu.Lock() + defer m.mu.Unlock() + m.interceptions = append(m.interceptions, req) + return nil +} + +func (m *MockRecorder) RecordInterceptionEnded(_ context.Context, req *recorder.InterceptionRecordEnded) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.interceptionsEnd == nil { + m.interceptionsEnd = make(map[string]*recorder.InterceptionRecordEnded) + } + if !slices.ContainsFunc(m.interceptions, func(intc *recorder.InterceptionRecord) bool { return intc.ID == req.ID }) { + return xerrors.New("id not found") + } + m.interceptionsEnd[req.ID] = req + return nil +} + +func (m *MockRecorder) RecordPromptUsage(_ context.Context, req *recorder.PromptUsageRecord) error { + m.mu.Lock() + defer m.mu.Unlock() + m.userPrompts = append(m.userPrompts, req) + return nil +} + +func (m *MockRecorder) RecordTokenUsage(_ context.Context, req *recorder.TokenUsageRecord) error { + m.mu.Lock() + defer m.mu.Unlock() + m.tokenUsages = append(m.tokenUsages, req) + return nil +} + +func (m *MockRecorder) RecordToolUsage(_ context.Context, req *recorder.ToolUsageRecord) error { + m.mu.Lock() + defer m.mu.Unlock() + m.toolUsages = append(m.toolUsages, req) + return nil +} + +func (m *MockRecorder) RecordModelThought(_ context.Context, req *recorder.ModelThoughtRecord) error { + m.mu.Lock() + defer m.mu.Unlock() + m.modelThoughts = append(m.modelThoughts, req) + return nil +} + +// RecordedTokenUsages returns a copy of recorded token usages in a thread-safe manner. +// Note: This is a shallow clone - the slice is copied but the pointers reference the +// same underlying records. This is sufficient for our test assertions which only read +// the data and don't modify the records. +func (m *MockRecorder) RecordedTokenUsages() []*recorder.TokenUsageRecord { + m.mu.Lock() + defer m.mu.Unlock() + return slices.Clone(m.tokenUsages) +} + +// TotalInputTokens returns the sum of input tokens across all recorded token usages. +func (m *MockRecorder) TotalInputTokens() int64 { + m.mu.Lock() + defer m.mu.Unlock() + var total int64 + for _, el := range m.tokenUsages { + total += el.Input + } + return total +} + +// TotalOutputTokens returns the sum of output tokens across all recorded token usages. +func (m *MockRecorder) TotalOutputTokens() int64 { + m.mu.Lock() + defer m.mu.Unlock() + var total int64 + for _, el := range m.tokenUsages { + total += el.Output + } + return total +} + +// TotalCacheReadInputTokens returns the sum of cache read input tokens across all recorded token usages. +func (m *MockRecorder) TotalCacheReadInputTokens() int64 { + m.mu.Lock() + defer m.mu.Unlock() + var total int64 + for _, el := range m.tokenUsages { + total += el.CacheReadInputTokens + } + return total +} + +// TotalCacheWriteInputTokens returns the sum of cache write input tokens across all recorded token usages. +func (m *MockRecorder) TotalCacheWriteInputTokens() int64 { + m.mu.Lock() + defer m.mu.Unlock() + var total int64 + for _, el := range m.tokenUsages { + total += el.CacheWriteInputTokens + } + return total +} + +// RecordedPromptUsages returns a copy of recorded prompt usages in a thread-safe manner. +// Note: This is a shallow clone (see RecordedTokenUsages for details). +func (m *MockRecorder) RecordedPromptUsages() []*recorder.PromptUsageRecord { + m.mu.Lock() + defer m.mu.Unlock() + return slices.Clone(m.userPrompts) +} + +// RecordedToolUsages returns a copy of recorded tool usages in a thread-safe manner. +// Note: This is a shallow clone (see RecordedTokenUsages for details). +func (m *MockRecorder) RecordedToolUsages() []*recorder.ToolUsageRecord { + m.mu.Lock() + defer m.mu.Unlock() + return slices.Clone(m.toolUsages) +} + +// RecordedModelThoughts returns a copy of recorded model thoughts in a thread-safe manner. +// Note: This is a shallow clone (see RecordedTokenUsages for details). +func (m *MockRecorder) RecordedModelThoughts() []*recorder.ModelThoughtRecord { + m.mu.Lock() + defer m.mu.Unlock() + return slices.Clone(m.modelThoughts) +} + +// RecordedInterceptions returns a copy of recorded interceptions in a thread-safe manner. +// Note: This is a shallow clone (see RecordedTokenUsages for details). +func (m *MockRecorder) RecordedInterceptions() []*recorder.InterceptionRecord { + m.mu.Lock() + defer m.mu.Unlock() + return slices.Clone(m.interceptions) +} + +// ToolUsages returns the raw toolUsages slice for direct field access in tests. +// Use RecordedToolUsages() for thread-safe access when assertions don't need direct field access. +func (m *MockRecorder) ToolUsages() []*recorder.ToolUsageRecord { + m.mu.Lock() + defer m.mu.Unlock() + return m.toolUsages +} + +// RecordedInterceptionEnd returns the stored InterceptionRecordEnded for the +// given interception ID, or nil if not found. +func (m *MockRecorder) RecordedInterceptionEnd(id string) *recorder.InterceptionRecordEnded { + m.mu.Lock() + defer m.mu.Unlock() + return m.interceptionsEnd[id] +} + +// VerifyAllInterceptionsEnded verifies all recorded interceptions have been marked as completed. +func (m *MockRecorder) VerifyAllInterceptionsEnded(t *testing.T) { + t.Helper() + + m.mu.Lock() + defer m.mu.Unlock() + require.Equalf(t, len(m.interceptions), len(m.interceptionsEnd), "got %v interception ended calls, want: %v", len(m.interceptionsEnd), len(m.interceptions)) + for _, intc := range m.interceptions { + require.Containsf(t, m.interceptionsEnd, intc.ID, "interception with id: %v has not been ended", intc.ID) + } +} + +func (m *MockRecorder) VerifyModelThoughtsRecorded(t *testing.T, expected []recorder.ModelThoughtRecord) { + thoughts := m.RecordedModelThoughts() + if expected == nil { + require.Empty(t, thoughts) + return + } + + require.Len(t, thoughts, len(expected), "unexpected number of model thoughts") + + // We can't guarantee the order of model thoughts since they're recorded separately, so + // we have to scan all thoughts for a match. + + for _, exp := range expected { + var matched *recorder.ModelThoughtRecord + for _, thought := range thoughts { + if strings.Contains(thought.Content, exp.Content) { + matched = thought + } + } + + require.NotNil(t, matched, "could not find thought matching %q", exp.Content) + require.EqualValues(t, exp.Metadata, matched.Metadata) + } +} diff --git a/aibridge/internal/testutil/mockprovider.go b/aibridge/internal/testutil/mockprovider.go new file mode 100644 index 0000000000..9bdcb10694 --- /dev/null +++ b/aibridge/internal/testutil/mockprovider.go @@ -0,0 +1,41 @@ +package testutil + +import ( + "fmt" + "net/http" + + "go.opentelemetry.io/otel/trace" + + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/intercept" +) + +type MockProvider struct { + NameStr string + URL string + Bridged []string + Passthrough []string + InterceptorFunc func(w http.ResponseWriter, r *http.Request, tracer trace.Tracer) (intercept.Interceptor, error) + InjectAuthHeaderFunc func(h *http.Header) +} + +func (m *MockProvider) Type() string { return m.NameStr } +func (m *MockProvider) Name() string { return m.NameStr } +func (m *MockProvider) BaseURL() string { return m.URL } +func (m *MockProvider) RoutePrefix() string { return fmt.Sprintf("/%s", m.NameStr) } +func (m *MockProvider) BridgedRoutes() []string { return m.Bridged } +func (m *MockProvider) PassthroughRoutes() []string { return m.Passthrough } +func (*MockProvider) AuthHeader() string { return "Authorization" } +func (m *MockProvider) InjectAuthHeader(h *http.Header) { + if m.InjectAuthHeaderFunc != nil { + m.InjectAuthHeaderFunc(h) + } +} +func (*MockProvider) CircuitBreakerConfig() *config.CircuitBreaker { return nil } +func (*MockProvider) APIDumpDir() string { return "" } +func (m *MockProvider) CreateInterceptor(w http.ResponseWriter, r *http.Request, tracer trace.Tracer) (intercept.Interceptor, error) { + if m.InterceptorFunc != nil { + return m.InterceptorFunc(w, r, tracer) + } + return nil, nil //nolint:nilnil // mock: no interceptor configured is not an error +} diff --git a/aibridge/internal/testutil/timeout.go b/aibridge/internal/testutil/timeout.go new file mode 100644 index 0000000000..ef8b2b530d --- /dev/null +++ b/aibridge/internal/testutil/timeout.go @@ -0,0 +1,21 @@ +package testutil + +import "time" + +// Shared test timeout and interval constants. +// Using named constants avoids magic numbers and makes timeout policy +// easy to adjust across the entire test suite. +const ( + // WaitLong is the default timeout for test operations that may take a while + // (e.g. integration tests with HTTP round-trips). + WaitLong = 30 * time.Second + + // WaitMedium is a timeout for moderately slow operations. + WaitMedium = 10 * time.Second + + // WaitShort is a timeout for operations expected to complete quickly. + WaitShort = 5 * time.Second + + // IntervalFast is a short polling interval for require.Eventually and similar. + IntervalFast = 50 * time.Millisecond +) diff --git a/aibridge/mcp/api.go b/aibridge/mcp/api.go new file mode 100644 index 0000000000..1abd476a8c --- /dev/null +++ b/aibridge/mcp/api.go @@ -0,0 +1,26 @@ +package mcp + +import ( + "context" + + "github.com/mark3labs/mcp-go/mcp" +) + +// ServerProxier provides an abstraction to communicate with MCP Servers regardless of their transport. +// The ServerProxier is expected to, at least, fetch any available MCP tools. +type ServerProxier interface { + // Init initializes the proxier, establishing a connection with the upstream server and fetching resources. + Init(context.Context) error + // Gracefully shut down connections to the MCP server. Session management will vary per transport. + // See https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#session-management. + Shutdown(ctx context.Context) error + + // ListTools lists all known tools. These MUST be sorted in a stable order. + ListTools() []*Tool + // GetTool returns a given tool, if known, or returns nil. + GetTool(id string) *Tool + // CallTool invokes an injected MCP tool + CallTool(ctx context.Context, name string, input any) (*mcp.CallToolResult, error) +} + +// TODO: support HTTP+SSE. diff --git a/aibridge/mcp/client_info.go b/aibridge/mcp/client_info.go new file mode 100644 index 0000000000..04a4973a3e --- /dev/null +++ b/aibridge/mcp/client_info.go @@ -0,0 +1,16 @@ +package mcp + +import ( + "github.com/mark3labs/mcp-go/mcp" + + "github.com/coder/coder/v2/buildinfo" +) + +// GetClientInfo returns the MCP client information to use when initializing MCP connections. +// This provides a consistent way for all proxy implementations to report client information. +func GetClientInfo() mcp.Implementation { + return mcp.Implementation{ + Name: "coder/aibridge", + Version: buildinfo.Version(), + } +} diff --git a/aibridge/mcp/client_info_test.go b/aibridge/mcp/client_info_test.go new file mode 100644 index 0000000000..77f4ee7b0e --- /dev/null +++ b/aibridge/mcp/client_info_test.go @@ -0,0 +1,20 @@ +package mcp_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/aibridge/mcp" +) + +func TestGetClientInfo(t *testing.T) { + t.Parallel() + + info := mcp.GetClientInfo() + + assert.Equal(t, "coder/aibridge", info.Name) + assert.NotEmpty(t, info.Version) + // Version will either be a git revision, a semantic version, or a combination + assert.NotEqual(t, "", info.Version) +} diff --git a/aibridge/mcp/mcp_test.go b/aibridge/mcp/mcp_test.go new file mode 100644 index 0000000000..aeea86e72d --- /dev/null +++ b/aibridge/mcp/mcp_test.go @@ -0,0 +1,371 @@ +package mcp_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "slices" + "strings" + "testing" + + mcplib "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.uber.org/goleak" + + "cdr.dev/slog/v3" + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/aibridge/internal/testutil" + "github.com/coder/coder/v2/aibridge/mcp" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestFilterAllowedTools(t *testing.T) { + t.Parallel() + + createTools := func(names ...string) map[string]*mcp.Tool { + tools := make(map[string]*mcp.Tool) + for i, name := range names { + id := string(rune('a' + i)) + tools[id] = &mcp.Tool{ + ID: id, + Name: name, + } + } + return tools + } + + mustCompile := func(pattern string) *regexp.Regexp { + if pattern == "" { + return nil + } + return regexp.MustCompile(pattern) + } + + tests := []struct { + name string + tools map[string]*mcp.Tool + allowlist string + denylist string + expected []string + }{ + { + name: "empty tools returns empty", + tools: map[string]*mcp.Tool{}, + allowlist: ".*", + denylist: "", + expected: []string{}, + }, + { + name: "nil allow and deny lists returns all tools", + tools: createTools("tool1", "tool2", "tool3"), + allowlist: "", + denylist: "", + expected: []string{"tool1", "tool2", "tool3"}, + }, + { + name: "allowlist only - match all", + tools: createTools("tool1", "tool2", "tool3"), + allowlist: ".*", + denylist: "", + expected: []string{"tool1", "tool2", "tool3"}, + }, + { + name: "allowlist only - match specific", + tools: createTools("tool1", "tool2", "tool3"), + allowlist: "tool[12]", + denylist: "", + expected: []string{"tool1", "tool2"}, + }, + { + name: "allowlist only - match none", + tools: createTools("tool1", "tool2", "tool3"), + allowlist: "nonexistent", + denylist: "", + expected: []string{}, + }, + { + name: "denylist only - deny all", + tools: createTools("tool1", "tool2", "tool3"), + allowlist: "", + denylist: ".*", + expected: []string{}, + }, + { + name: "denylist only - deny specific", + tools: createTools("tool1", "tool2", "tool3"), + allowlist: "", + denylist: "tool2", + expected: []string{"tool1", "tool3"}, + }, + { + name: "denylist only - deny none", + tools: createTools("tool1", "tool2", "tool3"), + allowlist: "", + denylist: "nonexistent", + expected: []string{"tool1", "tool2", "tool3"}, + }, + { + name: "both lists - no conflict", + tools: createTools("tool1", "tool2", "tool3", "tool4"), + allowlist: "tool[124]", + denylist: "tool3", + expected: []string{"tool1", "tool2", "tool4"}, + }, + { + name: "both lists - denylist supersedes allowlist", + tools: createTools("tool1", "tool2", "tool3"), + allowlist: "tool.*", + denylist: "tool2", + expected: []string{"tool1", "tool3"}, + }, + { + name: "both lists - complete conflict (denylist wins)", + tools: createTools("tool1", "tool2", "tool3"), + allowlist: ".*", + denylist: ".*", + expected: []string{}, + }, + { + name: "both lists - partial overlap conflict", + tools: createTools("read_file", "write_file", "delete_file", "list_files"), + allowlist: ".*_file", + denylist: "delete.*", + expected: []string{"read_file", "write_file", "list_files"}, + }, + { + name: "regex patterns - word boundaries", + tools: createTools("test", "testing", "pretest", "test123"), + allowlist: "^test$", + denylist: "", + expected: []string{"test"}, + }, + { + name: "regex patterns - alternation in allowlist", + tools: createTools("read", "write", "execute", "delete"), + allowlist: "read|write", + denylist: "", + expected: []string{"read", "write"}, + }, + { + name: "regex patterns - alternation in denylist", + tools: createTools("read", "write", "execute", "delete"), + allowlist: "", + denylist: "execute|delete", + expected: []string{"read", "write"}, + }, + { + name: "complex regex - character classes", + tools: createTools("tool1", "tool2", "toolA", "toolB", "tool_special"), + allowlist: "tool[A-Z]", + denylist: "", + expected: []string{"toolA", "toolB"}, + }, + { + name: "case sensitivity", + tools: createTools("Tool", "tool", "TOOL"), + allowlist: "^tool$", + denylist: "", + expected: []string{"tool"}, + }, + { + name: "special characters in tool names", + tools: createTools("tool.test", "tool-test", "tool_test", "tool$test"), + allowlist: `tool\.test`, + denylist: "", + expected: []string{"tool.test"}, + }, + { + name: "empty string tool name", + tools: createTools("", "tool1", "tool2"), + allowlist: "tool.*", + denylist: "", + expected: []string{"tool1", "tool2"}, + }, + { + name: "unicode in tool names", + tools: createTools("工具1", "工具2", "tool3"), + allowlist: "工具.*", + denylist: "", + expected: []string{"工具1", "工具2"}, + }, + { + name: "whitespace in tool names", + tools: createTools("tool 1", "tool 2", "tool\t3", "tool4"), + allowlist: `tool\s+\d`, + denylist: "", + expected: []string{"tool 1", "tool 2", "tool\t3"}, + }, + { + name: "with both lists unmatched items are denied", + tools: createTools("foo1", "bar1", "other1", "other2"), + allowlist: "^foo", + denylist: "^bar", + expected: []string{"foo1"}, // Only items matching allowlist (and not denylist). + }, + { + name: "complex overlap - denylist pattern subset of allowlist", + tools: createTools("api_read", "api_write", "api_read_sensitive", "api_write_sensitive"), + allowlist: "^api_.*", + denylist: ".*_sensitive$", + expected: []string{"api_read", "api_write"}, + }, + { + name: "nil tools map", + tools: nil, + allowlist: ".*", + denylist: ".*", + expected: []string{}, + }, + { + // Tool IDs are a composite of a prefix, their server name, and their tool name. + name: "tools with same name different IDs", + tools: map[string]*mcp.Tool{ + "id1": {ID: "id1", Name: "duplicate"}, + "id2": {ID: "id2", Name: "duplicate"}, + "id3": {ID: "id3", Name: "unique"}, + }, + allowlist: "duplicate", + denylist: "", + expected: []string{"duplicate", "duplicate"}, + }, + { + name: "greedy vs non-greedy matching", + tools: createTools("start_middle_end", "start_end", "middle"), + allowlist: "start.*end", + denylist: "", + expected: []string{"start_middle_end", "start_end"}, + }, + { + name: "anchored patterns", + tools: createTools("prefix_tool", "tool_suffix", "prefix_tool_suffix"), + allowlist: "^prefix_", + denylist: "_suffix$", + expected: []string{"prefix_tool"}, + }, + { + name: "invalid regex chars in tool names treated literally", + tools: createTools("tool[1]", "tool(2)", "tool{3}", "tool*4"), + allowlist: `tool\[1\]`, + denylist: "", + expected: []string{"tool[1]"}, + }, + { + name: "effective filtering - use denylist to exclude non-matching", + tools: createTools("api_read", "api_write", "db_read", "db_write", "file_read"), + allowlist: "", + denylist: "^(db_|file_)", + expected: []string{"api_read", "api_write"}, + }, + { + name: "allowlist with explicit denylist for complement", + tools: createTools("tool1", "tool2", "tool3", "tool4"), + allowlist: "tool[12]", + denylist: "tool[34]", + expected: []string{"tool1", "tool2"}, + }, + { + name: "allowlist only filters correctly", + tools: createTools("allowed", "notallowed"), + allowlist: "^allowed$", + denylist: "", + expected: []string{"allowed"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var resultNames []string + result := mcp.FilterAllowedTools(slog.Make(), tt.tools, mustCompile(tt.allowlist), mustCompile(tt.denylist)) + for _, tool := range result { + resultNames = append(resultNames, tool.Name) + } + + require.ElementsMatch(t, tt.expected, resultNames) + }) + } +} + +func TestToolInjectionOrder(t *testing.T) { + t.Parallel() + + // Setup. + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: false}).Leveled(slog.LevelDebug) + ctx, cancel := context.WithTimeout(t.Context(), testutil.WaitLong) + t.Cleanup(cancel) + + // Given: a MCP mock server offering a set of tools. + mcpSrv := httptest.NewServer(createMockMCPSrv(t)) + t.Cleanup(mcpSrv.Close) + + tracer := otel.Tracer("forTesting") + // When: creating two MCP server proxies, both listing the same tools by name but under different server namespaces. + proxy, err := mcp.NewStreamableHTTPServerProxy("coder", mcpSrv.URL, nil, nil, nil, logger, tracer) + require.NoError(t, err) + proxy2, err := mcp.NewStreamableHTTPServerProxy("shmoder", mcpSrv.URL, nil, nil, nil, logger, tracer) + require.NoError(t, err) + + // Then: initialize both proxies. + require.NoError(t, proxy.Init(ctx)) + require.NoError(t, proxy2.Init(ctx)) + + // Then: validate that their tools are separately sorted stably. + validateToolOrder(t, proxy) + validateToolOrder(t, proxy2) + + // When: creating a manager which contains both MCP server proxies. + mgr := mcp.NewServerProxyManager(map[string]mcp.ServerProxier{ + "coder": proxy, + "shmoder": proxy2, + }, otel.GetTracerProvider().Tracer("test")) + require.NoError(t, mgr.Init(ctx)) + + // Then: the tools from both servers should be collectively sorted stably. + validateToolOrder(t, mgr) +} + +func validateToolOrder(t *testing.T, proxy mcp.ServerProxier) { + t.Helper() + + tools := proxy.ListTools() + require.NotEmpty(t, tools) + require.Greater(t, len(tools), 1) + + // Ensure tools are sorted by ID; unstable order can bust the cache and lead to increased costs. + sorted := slices.Clone(tools) + slices.SortFunc(sorted, func(a, b *mcp.Tool) int { + return strings.Compare(a.ID, b.ID) + }) + for i, tool := range tools { + require.Equal(t, tool.ID, sorted[i].ID, "tool order is not stable") + } +} + +func createMockMCPSrv(t *testing.T) http.Handler { + t.Helper() + + s := server.NewMCPServer( + "Mock coder MCP server", + "1.0.0", + server.WithToolCapabilities(true), + ) + + for _, name := range []string{"coder_list_workspaces", "coder_list_templates", "coder_template_version_parameters", "coder_get_authenticated_user"} { + tool := mcplib.NewTool(name, + mcplib.WithDescription(fmt.Sprintf("Mock of the %s tool", name)), + ) + s.AddTool(tool, func(ctx context.Context, request mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { + return mcplib.NewToolResultText("mock"), nil + }) + } + + return server.NewStreamableHTTPServer(s) +} diff --git a/aibridge/mcp/proxy_streamable_http.go b/aibridge/mcp/proxy_streamable_http.go new file mode 100644 index 0000000000..132c03965a --- /dev/null +++ b/aibridge/mcp/proxy_streamable_http.go @@ -0,0 +1,180 @@ +package mcp + +import ( + "context" + "regexp" + "slices" + "strings" + + "github.com/mark3labs/mcp-go/client" + "github.com/mark3labs/mcp-go/client/transport" + "github.com/mark3labs/mcp-go/mcp" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "golang.org/x/exp/maps" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/tracing" +) + +var _ ServerProxier = &StreamableHTTPServerProxy{} + +type StreamableHTTPServerProxy struct { + client *client.Client + logger slog.Logger + tracer trace.Tracer + + allowlistPattern *regexp.Regexp + denylistPattern *regexp.Regexp + + serverName string + serverURL string + tools map[string]*Tool +} + +func NewStreamableHTTPServerProxy(serverName, serverURL string, headers map[string]string, allowlist, denylist *regexp.Regexp, logger slog.Logger, tracer trace.Tracer, opts ...transport.StreamableHTTPCOption) (*StreamableHTTPServerProxy, error) { + // nit: headers should be passed in as an option instead of a separate parameter. Not changed as this would be a breaking change. + if headers != nil { + opts = append(opts, transport.WithHTTPHeaders(headers)) + } + + mcpClient, err := client.NewStreamableHttpClient(serverURL, opts...) + if err != nil { + return nil, xerrors.Errorf("create streamable http client: %w", err) + } + + return &StreamableHTTPServerProxy{ + serverName: serverName, + serverURL: serverURL, + client: mcpClient, + logger: logger, + tracer: tracer, + allowlistPattern: allowlist, + denylistPattern: denylist, + }, nil +} + +func (p *StreamableHTTPServerProxy) Name() string { + return p.serverName +} + +func (p *StreamableHTTPServerProxy) Init(ctx context.Context) (outErr error) { + ctx, span := p.tracer.Start(ctx, "StreamableHTTPServerProxy.Init", trace.WithAttributes(p.traceAttributes()...)) + defer tracing.EndSpanErr(span, &outErr) + + if err := p.client.Start(ctx); err != nil { + return xerrors.Errorf("start client: %w", err) + } + + version := mcp.LATEST_PROTOCOL_VERSION + initReq := mcp.InitializeRequest{ + Params: mcp.InitializeParams{ + ProtocolVersion: version, + ClientInfo: GetClientInfo(), + }, + } + + result, err := p.client.Initialize(ctx, initReq) + if err != nil { + return xerrors.Errorf("init MCP client: %w", err) + } + + if !slices.Contains(mcp.ValidProtocolVersions, result.ProtocolVersion) { + if err := p.client.Close(); err != nil { + p.logger.Debug(ctx, "failed to close MCP client on unsuccessful version negotiation", slog.Error(err)) + } + return xerrors.Errorf("MCP version negotiation failed; requested %q, accepts %q, received %q", version, strings.Join(mcp.ValidProtocolVersions, ","), result.ProtocolVersion) + } + + p.logger.Debug(ctx, "mcp client initialized", slog.F("name", result.ServerInfo.Name), slog.F("server_version", result.ServerInfo.Version)) + + tools, err := p.fetchTools(ctx) + if err != nil { + return xerrors.Errorf("fetch tools: %w", err) + } + + // Only include allowed tools. + p.tools = FilterAllowedTools(p.logger.Named("tool-filterer"), tools, p.allowlistPattern, p.denylistPattern) + return nil +} + +func (p *StreamableHTTPServerProxy) ListTools() []*Tool { + tools := maps.Values(p.tools) + slices.SortStableFunc(tools, func(a, b *Tool) int { + return strings.Compare(a.ID, b.ID) + }) + return tools +} + +func (p *StreamableHTTPServerProxy) GetTool(name string) *Tool { + if p.tools == nil { + return nil + } + + t, ok := p.tools[name] + if !ok { + return nil + } + return t +} + +func (p *StreamableHTTPServerProxy) CallTool(ctx context.Context, name string, input any) (*mcp.CallToolResult, error) { + tool := p.GetTool(name) + if tool == nil { + return nil, xerrors.Errorf("%q tool not known", name) + } + + return p.client.CallTool(ctx, mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: tool.Name, + Arguments: input, + }, + }) +} + +func (p *StreamableHTTPServerProxy) fetchTools(ctx context.Context) (_ map[string]*Tool, outErr error) { + ctx, span := p.tracer.Start(ctx, "StreamableHTTPServerProxy.Init.fetchTools", trace.WithAttributes(p.traceAttributes()...)) + defer tracing.EndSpanErr(span, &outErr) + + tools, err := p.client.ListTools(ctx, mcp.ListToolsRequest{}) + if err != nil { + return nil, xerrors.Errorf("list MCP tools: %w", err) + } + + out := make(map[string]*Tool, len(tools.Tools)) + for _, tool := range tools.Tools { + encodedID := EncodeToolID(p.serverName, tool.Name) + out[encodedID] = &Tool{ + Client: p.client, + ID: encodedID, + Name: tool.Name, + ServerName: p.serverName, + ServerURL: p.serverURL, + Description: tool.Description, + Params: tool.InputSchema.Properties, + Required: tool.InputSchema.Required, + Logger: p.logger, + } + } + span.SetAttributes(append(p.traceAttributes(), attribute.Int(tracing.MCPToolCount, len(out)))...) + return out, nil +} + +func (p *StreamableHTTPServerProxy) Shutdown(_ context.Context) error { + if p.client == nil { + return nil + } + + // NOTE: as of v0.38.0 the lib doesn't allow an outside context to be passed in; + // it has an internal timeout of 5s, though. + return p.client.Close() +} + +func (p *StreamableHTTPServerProxy) traceAttributes() []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String(tracing.MCPProxyName, p.Name()), + attribute.String(tracing.MCPServerName, p.serverName), + attribute.String(tracing.MCPServerURL, p.serverURL), + } +} diff --git a/aibridge/mcp/server_proxy_manager.go b/aibridge/mcp/server_proxy_manager.go new file mode 100644 index 0000000000..9c9bdb1232 --- /dev/null +++ b/aibridge/mcp/server_proxy_manager.go @@ -0,0 +1,130 @@ +package mcp + +import ( + "context" + "slices" + "strings" + "sync" + + "github.com/mark3labs/mcp-go/mcp" + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/aibridge/tracing" + "github.com/coder/coder/v2/aibridge/utils" +) + +var _ ServerProxier = &ServerProxyManager{} + +// ServerProxyManager can act on behalf of multiple [ServerProxier]s. +// It aggregates all server resources (currently just tools) across all MCP servers +// for the purpose of injection into bridged requests and invocation. +type ServerProxyManager struct { + proxiers map[string]ServerProxier + tracer trace.Tracer + + // Protects access to the tools map. + toolsMu sync.RWMutex + tools map[string]*Tool +} + +func NewServerProxyManager(proxiers map[string]ServerProxier, tracer trace.Tracer) *ServerProxyManager { + return &ServerProxyManager{ + proxiers: proxiers, + tracer: tracer, + } +} + +func (s *ServerProxyManager) addTools(tools []*Tool) { + s.toolsMu.Lock() + defer s.toolsMu.Unlock() + + if s.tools == nil { + s.tools = make(map[string]*Tool, len(tools)) + } + + for _, tool := range tools { + s.tools[tool.ID] = tool + } +} + +// Init concurrently initializes all of its [ServerProxier]s. +func (s *ServerProxyManager) Init(ctx context.Context) (outErr error) { + ctx, span := s.tracer.Start(ctx, "ServerProxyManager.Init") + defer tracing.EndSpanErr(span, &outErr) + + cg := utils.NewConcurrentGroup() + for _, proxy := range s.proxiers { + cg.Go(func() error { + return proxy.Init(ctx) + }) + } + + // Wait for all servers to initialize and load their tools. + err := cg.Wait() + + // Aggregate all proxiers' tools. + for _, proxy := range s.proxiers { + s.addTools(proxy.ListTools()) + } + + return err +} + +func (s *ServerProxyManager) GetTool(name string) *Tool { + s.toolsMu.RLock() + defer s.toolsMu.RUnlock() + + if s.tools == nil { + return nil + } + + return s.tools[name] +} + +func (s *ServerProxyManager) ListTools() []*Tool { + s.toolsMu.RLock() + defer s.toolsMu.RUnlock() + + if s.tools == nil { + return nil + } + + var out []*Tool + for _, tool := range s.tools { + out = append(out, tool) + } + + slices.SortStableFunc(out, func(a, b *Tool) int { + return strings.Compare(a.ID, b.ID) + }) + + return out +} + +// CallTool locates the proxier to which the requested tool is associated and +// delegates the tool call to it. +func (s *ServerProxyManager) CallTool(ctx context.Context, name string, input any) (*mcp.CallToolResult, error) { + tool := s.GetTool(name) + if tool == nil { + return nil, xerrors.Errorf("%q tool not known", name) + } + + proxy, ok := s.proxiers[tool.ServerName] + if !ok { + return nil, xerrors.Errorf("%q server not known", tool.ServerName) + } + + return proxy.CallTool(ctx, name, input) +} + +// Shutdown concurrently shuts down all known proxiers and waits for them *all* to complete. +func (s *ServerProxyManager) Shutdown(ctx context.Context) error { + cg := utils.NewConcurrentGroup() + for _, proxy := range s.proxiers { + cg.Go(func() error { + return proxy.Shutdown(ctx) + }) + } + return cg.Wait() +} diff --git a/aibridge/mcp/tool.go b/aibridge/mcp/tool.go new file mode 100644 index 0000000000..8fbca9d224 --- /dev/null +++ b/aibridge/mcp/tool.go @@ -0,0 +1,160 @@ +package mcp + +import ( + "context" + "encoding/json" + "regexp" + "strings" + "time" + + "github.com/mark3labs/mcp-go/mcp" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/tracing" +) + +const ( + maxSpanInputAttrLen = 100 // truncates tool.Call span input attribute to first `maxSpanInputAttrLen` letters + injectedToolPrefix = "bmcp" // "bridged MCP" + injectedToolDelimiter = "_" +) + +// ToolCaller is the narrowest interface which describes the behavior required from [mcp.Client], +// which will normally be passed into [Tool] for interaction with an MCP server. +// TODO: don't expose github.com/mark3labs/mcp-go outside this package. +type ToolCaller interface { + CallTool(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) +} + +type Tool struct { + Client ToolCaller + + ID string + Name string + ServerName string + ServerURL string + Description string + Params map[string]any + Required []string + Logger slog.Logger +} + +func (t *Tool) Call(ctx context.Context, input any, tracer trace.Tracer) (_ *mcp.CallToolResult, outErr error) { + if t == nil { + return nil, xerrors.New("nil tool") + } + if t.Client == nil { + return nil, xerrors.New("nil client") + } + + spanAttrs := append( + tracing.InterceptionAttributesFromContext(ctx), + attribute.String(tracing.MCPToolName, t.Name), + attribute.String(tracing.MCPServerName, t.ServerName), + attribute.String(tracing.MCPServerURL, t.ServerURL), + ) + ctx, span := tracer.Start(ctx, "Intercept.ProcessRequest.ToolCall", trace.WithAttributes(spanAttrs...)) + defer tracing.EndSpanErr(span, &outErr) + + inputJSON, err := json.Marshal(input) + if err != nil { + t.Logger.Warn(ctx, "failed to marshal tool input, will be omitted from span attrs", slog.Error(err)) + } else { + strJSON := string(inputJSON) + if len(strJSON) > maxSpanInputAttrLen { + strJSON = strJSON[:maxSpanInputAttrLen] + } + span.SetAttributes(attribute.String(tracing.MCPInput, strJSON)) + } + + start := time.Now() + var res *mcp.CallToolResult + res, outErr = t.Client.CallTool(ctx, mcp.CallToolRequest{ + Params: mcp.CallToolParams{ + Name: t.Name, + Arguments: input, + }, + }) + + logFn := t.Logger.Debug + if outErr != nil { + logFn = t.Logger.Warn + } + + // We don't log MCP results because they could be large or contain sensitive information. + logFn(ctx, "injected tool invoked", + slog.F("name", t.Name), + slog.F("server", t.ServerName), + slog.F("input", inputJSON), + slog.F("duration_sec", time.Since(start).Seconds()), + slog.Error(outErr), + ) + + return res, outErr +} + +// EncodeToolID namespaces the given tool name with a prefix to identify tools injected by this library. +// Claude Code, for example, prefixes the tools it includes from defined MCP servers with the "mcp__" prefix. +// We have to namespace the tools we inject to prevent clashes. +// +// We stick to 5 prefix chars ("bmcp_") like "mcp__" since names can only be up to 64 chars: +// +// See: +// - https://community.openai.com/t/function-call-description-max-length/529902 +// - https://github.com/anthropics/claude-code/issues/2326 +func EncodeToolID(server, tool string) string { + // strings.Builder writes to in-memory storage and never return errors. + var sb strings.Builder + _, _ = sb.WriteString(injectedToolPrefix) + _, _ = sb.WriteString(injectedToolDelimiter) + _, _ = sb.WriteString(server) + _, _ = sb.WriteString(injectedToolDelimiter) + _, _ = sb.WriteString(tool) + return sb.String() +} + +// FilterAllowedTools filters tools based on the given allow/denylists. +// Filtering acts on tool names, and uses tool IDs for tracking. +// The denylist supersedes the allowlist in the case of any conflicts. +// If an allowlist is provided, tools must match it to be allowed. +// If only a denylist is provided, tools are allowed unless explicitly denied. +func FilterAllowedTools(logger slog.Logger, tools map[string]*Tool, allowlist *regexp.Regexp, denylist *regexp.Regexp) map[string]*Tool { + if len(tools) == 0 { + return tools + } + + if allowlist == nil && denylist == nil { + return tools + } + + allowed := make(map[string]*Tool, len(tools)) + for id, tool := range tools { + if tool == nil { + continue + } + + // Check denylist first since it can override allowlist. + if denylist != nil && denylist.MatchString(tool.Name) { + // Log conflict if also in allowlist. + if allowlist != nil && allowlist.MatchString(tool.Name) { + logger.Warn(context.Background(), "tool filtering conflict; marking tool disallowed", slog.F("name", tool.Name)) + } + continue // Not allowed. + } + + // Check allowlist if present. + if allowlist != nil { + if !allowlist.MatchString(tool.Name) { + continue // Not allowed. + } + } + + // Tool is allowed. + allowed[id] = tool + } + + return allowed +} diff --git a/aibridge/mcpmock/doc.go b/aibridge/mcpmock/doc.go new file mode 100644 index 0000000000..0b615f2d69 --- /dev/null +++ b/aibridge/mcpmock/doc.go @@ -0,0 +1,3 @@ +package mcpmock + +//go:generate mockgen -destination ./mcpmock.go -package mcpmock github.com/coder/aibridge/mcp ServerProxier diff --git a/aibridge/mcpmock/mcpmock.go b/aibridge/mcpmock/mcpmock.go new file mode 100644 index 0000000000..2678c73352 --- /dev/null +++ b/aibridge/mcpmock/mcpmock.go @@ -0,0 +1,114 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/coder/aibridge/mcp (interfaces: ServerProxier) +// +// Generated by this command: +// +// mockgen -destination ./mcpmock.go -package mcpmock github.com/coder/aibridge/mcp ServerProxier +// + +// Package mcpmock is a generated GoMock package. +package mcpmock + +import ( + context "context" + reflect "reflect" + + mcp "github.com/coder/coder/v2/aibridge/mcp" + mcp0 "github.com/mark3labs/mcp-go/mcp" + gomock "go.uber.org/mock/gomock" +) + +// MockServerProxier is a mock of ServerProxier interface. +type MockServerProxier struct { + ctrl *gomock.Controller + recorder *MockServerProxierMockRecorder + isgomock struct{} +} + +// MockServerProxierMockRecorder is the mock recorder for MockServerProxier. +type MockServerProxierMockRecorder struct { + mock *MockServerProxier +} + +// NewMockServerProxier creates a new mock instance. +func NewMockServerProxier(ctrl *gomock.Controller) *MockServerProxier { + mock := &MockServerProxier{ctrl: ctrl} + mock.recorder = &MockServerProxierMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockServerProxier) EXPECT() *MockServerProxierMockRecorder { + return m.recorder +} + +// CallTool mocks base method. +func (m *MockServerProxier) CallTool(ctx context.Context, name string, input any) (*mcp0.CallToolResult, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CallTool", ctx, name, input) + ret0, _ := ret[0].(*mcp0.CallToolResult) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CallTool indicates an expected call of CallTool. +func (mr *MockServerProxierMockRecorder) CallTool(ctx, name, input any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CallTool", reflect.TypeOf((*MockServerProxier)(nil).CallTool), ctx, name, input) +} + +// GetTool mocks base method. +func (m *MockServerProxier) GetTool(id string) *mcp.Tool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTool", id) + ret0, _ := ret[0].(*mcp.Tool) + return ret0 +} + +// GetTool indicates an expected call of GetTool. +func (mr *MockServerProxierMockRecorder) GetTool(id any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTool", reflect.TypeOf((*MockServerProxier)(nil).GetTool), id) +} + +// Init mocks base method. +func (m *MockServerProxier) Init(arg0 context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Init", arg0) + ret0, _ := ret[0].(error) + return ret0 +} + +// Init indicates an expected call of Init. +func (mr *MockServerProxierMockRecorder) Init(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Init", reflect.TypeOf((*MockServerProxier)(nil).Init), arg0) +} + +// ListTools mocks base method. +func (m *MockServerProxier) ListTools() []*mcp.Tool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListTools") + ret0, _ := ret[0].([]*mcp.Tool) + return ret0 +} + +// ListTools indicates an expected call of ListTools. +func (mr *MockServerProxierMockRecorder) ListTools() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTools", reflect.TypeOf((*MockServerProxier)(nil).ListTools)) +} + +// Shutdown mocks base method. +func (m *MockServerProxier) Shutdown(ctx context.Context) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Shutdown", ctx) + ret0, _ := ret[0].(error) + return ret0 +} + +// Shutdown indicates an expected call of Shutdown. +func (mr *MockServerProxierMockRecorder) Shutdown(ctx any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Shutdown", reflect.TypeOf((*MockServerProxier)(nil).Shutdown), ctx) +} diff --git a/aibridge/metrics/metrics.go b/aibridge/metrics/metrics.go new file mode 100644 index 0000000000..ec2d182fdf --- /dev/null +++ b/aibridge/metrics/metrics.go @@ -0,0 +1,132 @@ +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var baseLabels = []string{"provider", "model"} + +const ( + InterceptionCountStatusFailed = "failed" + InterceptionCountStatusCompleted = "completed" +) + +type Metrics struct { + // Interception-related metrics. + InterceptionDuration *prometheus.HistogramVec + InterceptionCount *prometheus.CounterVec + InterceptionsInflight *prometheus.GaugeVec + PassthroughCount *prometheus.CounterVec + + // Prompt-related metrics. + PromptCount *prometheus.CounterVec + + // Token-related metrics. + TokenUseCount *prometheus.CounterVec + + // Tool-related metrics. + InjectedToolUseCount *prometheus.CounterVec + NonInjectedToolUseCount *prometheus.CounterVec + + // Circuit breaker metrics. + CircuitBreakerState *prometheus.GaugeVec // Current state (0=closed, 0.5=half-open, 1=open) + CircuitBreakerTrips *prometheus.CounterVec // Total times circuit opened + CircuitBreakerRejects *prometheus.CounterVec // Requests rejected due to open circuit +} + +// NewMetrics creates AND registers metrics. It will panic if a collector has already been registered. +// Note: we are not specifying namespace in the metrics; the provided registerer may specify a "namespace" +// using [prometheus.WrapRegistererWithPrefix]. +func NewMetrics(reg prometheus.Registerer) *Metrics { + return &Metrics{ + // Interception-related metrics. + + // Pessimistic cardinality: 3 providers, 5 models, 2 statuses, 3 routes, 3 methods, 10 clients = up to 2700 PER INITIATOR. + InterceptionCount: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Subsystem: "interceptions", + Name: "total", + Help: "The count of intercepted requests.", + }, append(baseLabels, "status", "route", "method", "initiator_id", "client")), + // Pessimistic cardinality: 3 providers, 5 models, 3 routes = up to 45. + // NOTE: route is not unbounded because this is only for intercepted routes. + InterceptionsInflight: promauto.With(reg).NewGaugeVec(prometheus.GaugeOpts{ + Subsystem: "interceptions", + Name: "inflight", + Help: "The number of intercepted requests which are being processed.", + }, append(baseLabels, "route")), + // Pessimistic cardinality: 3 providers, 5 models, 7 buckets + 3 extra series (count, sum, +Inf) = up to 150. + InterceptionDuration: promauto.With(reg).NewHistogramVec(prometheus.HistogramOpts{ + Subsystem: "interceptions", + Name: "duration_seconds", + Help: "The total duration of intercepted requests, in seconds. " + + "The majority of this time will be the upstream processing of the request. " + + "aibridge has no control over upstream processing time, so it's just an illustrative metric.", + // TODO: add docs around determining aibridge's *own* latency with distributed traces + // once https://github.com/coder/aibridge/issues/26 lands. + Buckets: []float64{0.5, 2, 5, 15, 30, 60, 120}, + }, baseLabels), + + // Pessimistic cardinality: 3 providers, 10 routes, 3 methods = up to 90. + // NOTE: route is not unbounded because PassthroughRoutes (see provider.go) is a static list. + PassthroughCount: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Subsystem: "passthrough", + Name: "total", + Help: "The count of requests which were not intercepted but passed through to the upstream.", + }, []string{"provider", "route", "method"}), + + // Prompt-related metrics. + + // Pessimistic cardinality: 3 providers, 5 models, 10 clients = up to 150 PER INITIATOR. + PromptCount: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Subsystem: "prompts", + Name: "total", + Help: "The number of prompts issued by users (initiators).", + }, append(baseLabels, "initiator_id", "client")), + + // Token-related metrics. + + // Pessimistic cardinality: 3 providers, 5 models, 10 types, 10 clients = up to 1500 PER INITIATOR. + TokenUseCount: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Subsystem: "tokens", + Name: "total", + Help: "The number of tokens used by intercepted requests.", + }, append(baseLabels, "type", "initiator_id", "client")), + + // Tool-related metrics. + + // Pessimistic cardinality: 3 providers, 5 models, 3 servers, 30 tools = up to 1350. + InjectedToolUseCount: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Subsystem: "injected_tool_invocations", + Name: "total", + Help: "The number of times an injected MCP tool was invoked by aibridge.", + }, append(baseLabels, "server", "name")), + // Pessimistic cardinality: 3 providers, 5 models, 30 tools = up to 450. + NonInjectedToolUseCount: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Subsystem: "non_injected_tool_selections", + Name: "total", + Help: "The number of times an AI model selected a tool to be invoked by the client.", + }, append(baseLabels, "name")), + + // Circuit breaker metrics. + + // Pessimistic cardinality: 3 providers, 2 endpoints, 5 models = up to 30. + CircuitBreakerState: promauto.With(reg).NewGaugeVec(prometheus.GaugeOpts{ + Subsystem: "circuit_breaker", + Name: "state", + Help: "Current state of the circuit breaker (0=closed, 0.5=half-open, 1=open).", + }, []string{"provider", "endpoint", "model"}), + // Pessimistic cardinality: 3 providers, 2 endpoints, 5 models = up to 30. + CircuitBreakerTrips: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Subsystem: "circuit_breaker", + Name: "trips_total", + Help: "Total number of times the circuit breaker transitioned to open state.", + }, []string{"provider", "endpoint", "model"}), + // Pessimistic cardinality: 3 providers, 2 endpoints, 5 models = up to 30. + CircuitBreakerRejects: promauto.With(reg).NewCounterVec(prometheus.CounterOpts{ + Subsystem: "circuit_breaker", + Name: "rejects_total", + Help: "Total number of requests rejected due to open circuit breaker.", + }, []string{"provider", "endpoint", "model"}), + } +} diff --git a/aibridge/passthrough.go b/aibridge/passthrough.go new file mode 100644 index 0000000000..3fcb1b34dd --- /dev/null +++ b/aibridge/passthrough.go @@ -0,0 +1,117 @@ +package aibridge + +import ( + "context" + "net/http" + "net/http/httputil" + "net/url" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/intercept/apidump" + "github.com/coder/coder/v2/aibridge/metrics" + "github.com/coder/coder/v2/aibridge/provider" + "github.com/coder/coder/v2/aibridge/tracing" + "github.com/coder/quartz" +) + +// newPassthroughRouter returns a simple reverse-proxy implementation which will be used when a route is not handled specifically +// by a [intercept.Provider]. +// A single reverse proxy is created per provider and reused across all requests. +func newPassthroughRouter(prov provider.Provider, logger slog.Logger, m *metrics.Metrics, tracer trace.Tracer) http.HandlerFunc { + provBaseURL, err := url.Parse(prov.BaseURL()) + if err != nil { + return newInvalidBaseURLHandler(prov, logger, m, tracer, err) + } + if _, err := url.JoinPath(provBaseURL.Path, "/"); err != nil { + return newInvalidBaseURLHandler(prov, logger, m, tracer, err) + } + + // Transport tuned for streaming (no response header timeout). + t := &http.Transport{ + Proxy: http.ProxyFromEnvironment, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + + // Build a reverse proxy to the upstream, reused across all requests for this provider. + // All request modifications happen in Rewrite. + proxy := &httputil.ReverseProxy{ + Rewrite: func(pr *httputil.ProxyRequest) { + rewritePassthroughRequest(pr, provBaseURL, prov) + }, + Transport: apidump.NewPassthroughMiddleware(t, prov.APIDumpDir(), prov.Name(), logger, quartz.NewReal()), + ErrorHandler: func(rw http.ResponseWriter, req *http.Request, e error) { + logger.Warn(req.Context(), "reverse proxy error", slog.Error(e), slog.F("path", req.URL.Path)) + http.Error(rw, "upstream proxy error", http.StatusBadGateway) + }, + } + + return func(w http.ResponseWriter, r *http.Request) { + if m != nil { + m.PassthroughCount.WithLabelValues(prov.Name(), r.URL.Path, r.Method).Add(1) + } + + ctx, span := startSpan(r, tracer) + defer span.End() + + proxy.ServeHTTP(w, r.WithContext(ctx)) + } +} + +// rewritePassthroughRequest configures the outbound request for the upstream and +// applies proxy headers and provider auth. +func rewritePassthroughRequest(pr *httputil.ProxyRequest, provBaseURL *url.URL, prov provider.Provider) { + pr.SetURL(provBaseURL) + + // Rewrite sets "X-Forwarded-For" to just last hop (clients IP address). + // To preserve old Director behavior pr.In "X-Forwarded-For" header + // values need to be copied manually. + // https://pkg.go.dev/net/http/httputil#ProxyRequest.SetXForwarded + if prior, ok := pr.In.Header["X-Forwarded-For"]; ok { + pr.Out.Header["X-Forwarded-For"] = append([]string(nil), prior...) + } + pr.SetXForwarded() + + span := trace.SpanFromContext(pr.Out.Context()) + span.SetAttributes(attribute.String(tracing.PassthroughUpstreamURL, pr.Out.URL.String())) + + // Avoid default Go user-agent if none provided. + if _, ok := pr.Out.Header["User-Agent"]; !ok { + pr.Out.Header.Set("User-Agent", "aibridge") // TODO: use build tag. + } + + // Inject provider auth. + prov.InjectAuthHeader(&pr.Out.Header) +} + +// newInvalidBaseURLHandler returns a handler that always returns 502 +// when the provider's base URL is invalid. +func newInvalidBaseURLHandler(prov provider.Provider, logger slog.Logger, m *metrics.Metrics, tracer trace.Tracer, baseURLErr error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ctx, span := startSpan(r, tracer) + defer span.End() + + if m != nil { + m.PassthroughCount.WithLabelValues(prov.Name(), r.URL.Path, r.Method).Add(1) + } + + logger.Warn(ctx, "invalid provider base URL", slog.Error(baseURLErr)) + http.Error(w, "invalid provider base URL", http.StatusBadGateway) + span.SetStatus(codes.Error, "invalid provider base URL: "+baseURLErr.Error()) + } +} + +func startSpan(r *http.Request, tracer trace.Tracer) (context.Context, trace.Span) { + return tracer.Start(r.Context(), "Passthrough", trace.WithAttributes( + attribute.String(tracing.PassthroughURL, r.URL.String()), + attribute.String(tracing.PassthroughMethod, r.Method), + )) +} diff --git a/aibridge/passthrough_test.go b/aibridge/passthrough_test.go new file mode 100644 index 0000000000..33a8c62b6b --- /dev/null +++ b/aibridge/passthrough_test.go @@ -0,0 +1,303 @@ +package aibridge //nolint:testpackage // tests unexported newPassthroughRouter + +import ( + "crypto/tls" + "maps" + "net" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "go.opentelemetry.io/otel" + + "cdr.dev/slog/v3/sloggers/slogtest" + "github.com/coder/coder/v2/aibridge/internal/testutil" +) + +var testTracer = otel.Tracer("bridge_test") + +func TestPassthroughRoutes(t *testing.T) { + t.Parallel() + + upstreamRespBody := "upstream response" + tests := []struct { + name string + baseURLPath string + reqPath string + reqHost string + reqRemoteAddr string + reqHeaders http.Header + expectRequestPath string + expectQuery string + expectHeaders http.Header + expectRespStatus int + expectRespBody string + }{ + { + name: "passthrough_route_no_path", + reqPath: "/v1/conversations", + expectRequestPath: "/v1/conversations", + expectRespStatus: http.StatusOK, + expectRespBody: upstreamRespBody, + }, + { + name: "base_URL_path_is_preserved_in_passthrough_routes", + baseURLPath: "/api/v2", + reqPath: "/v1/models", + expectRequestPath: "/api/v2/v1/models", + expectRespStatus: http.StatusOK, + expectRespBody: upstreamRespBody, + }, + { + name: "passthrough_route_break_parse_base_url", + baseURLPath: "/%zz", + reqPath: "/v1/models/", + expectRespStatus: http.StatusBadGateway, + expectRespBody: "invalid provider base URL", + }, + { + name: "passthrough_route_rejects_invalid_base_url_path", + baseURLPath: "/%25", + reqPath: "/v1/models", + expectRespStatus: http.StatusBadGateway, + expectRespBody: "invalid provider base URL", + }, + { + name: "proxy_headers_are_set_and_forwarded_chain_is_appended", + reqPath: "/v1/models", + reqHost: "client.example.com", + reqRemoteAddr: "1.1.1.1:1111", + reqHeaders: http.Header{ + "X-Forwarded-For": {"2.2.2.2, 3.3.3.3"}, + }, + expectRequestPath: "/v1/models", + expectRespStatus: http.StatusOK, + expectRespBody: upstreamRespBody, + expectHeaders: http.Header{ + "Accept-Encoding": {"gzip"}, + "User-Agent": {"aibridge"}, + "X-Forwarded-For": {"2.2.2.2, 3.3.3.3, 1.1.1.1"}, + "X-Forwarded-Host": {"client.example.com"}, + "X-Forwarded-Proto": {"http"}, + }, + }, + { + name: "query_string_is_preserved", + reqPath: "/v1/models?search=gpt&limit=10", + expectRequestPath: "/v1/models", + expectQuery: "search=gpt&limit=10", + expectRespStatus: http.StatusOK, + expectRespBody: upstreamRespBody, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + logger := slogtest.Make(t, nil) + + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, tc.expectRequestPath, r.URL.Path) + assert.Equal(t, tc.expectQuery, r.URL.RawQuery) + if tc.expectHeaders != nil { + assert.Equal(t, tc.expectHeaders, r.Header) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(upstreamRespBody)) + })) + t.Cleanup(upstream.Close) + + prov := &testutil.MockProvider{ + URL: upstream.URL + tc.baseURLPath, + } + + handler := newPassthroughRouter(prov, logger, nil, testTracer) + + req := httptest.NewRequest("", tc.reqPath, nil) + maps.Copy(req.Header, tc.reqHeaders) + req.Host = tc.reqHost + req.RemoteAddr = tc.reqRemoteAddr + resp := httptest.NewRecorder() + handler.ServeHTTP(resp, req) + + assert.Equal(t, tc.expectRespStatus, resp.Code) + assert.Contains(t, resp.Body.String(), tc.expectRespBody) + }) + } +} + +func TestRewritePassthroughRequest(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + reqPath string + reqRemoteAddr string + reqHeaders http.Header + reqTLS bool + provider *testutil.MockProvider + expectURL string + expectHeaders http.Header + }{ + { + name: "sets_upstream_url_and_forwarded_headers_from_client_peer", + reqPath: "http://client-host/chat?stream=true", + reqRemoteAddr: "1.1.1.1:1111", + provider: &testutil.MockProvider{URL: "https://upstream-host/base"}, + expectURL: "https://upstream-host/base/chat?stream=true", + expectHeaders: http.Header{ + "X-Forwarded-Host": {"client-host"}, + "X-Forwarded-Proto": {"http"}, + "X-Forwarded-For": {"1.1.1.1"}, + "User-Agent": {"aibridge"}, + }, + }, + { + name: "preserves_client_user_agent", + reqPath: "http://client-host/chat", + reqRemoteAddr: "1.1.1.1:1111", + reqHeaders: http.Header{"User-Agent": {"custom-agent/1.0"}}, + provider: &testutil.MockProvider{URL: "https://upstream-host/base"}, + expectURL: "https://upstream-host/base/chat", + expectHeaders: http.Header{ + "X-Forwarded-Host": {"client-host"}, + "X-Forwarded-Proto": {"http"}, + "X-Forwarded-For": {"1.1.1.1"}, + "User-Agent": {"custom-agent/1.0"}, + }, + }, + { + name: "injects_auth_header", + reqPath: "http://client-host/chat", + reqRemoteAddr: "1.1.1.1:1111", + provider: &testutil.MockProvider{ + URL: "https://upstream-host/base", + InjectAuthHeaderFunc: func(h *http.Header) { + h.Set("Authorization", "Bearer test-token") + }, + }, + expectURL: "https://upstream-host/base/chat", + expectHeaders: http.Header{ + "X-Forwarded-Host": {"client-host"}, + "X-Forwarded-Proto": {"http"}, + "X-Forwarded-For": {"1.1.1.1"}, + "User-Agent": {"aibridge"}, + "Authorization": {"Bearer test-token"}, + }, + }, + { + name: "appends_remote_addr_to_existing_forwarded_for_chain", + reqPath: "http://client-host/chat", + reqRemoteAddr: "1.1.1.1:1111", + reqHeaders: http.Header{ + "X-Forwarded-For": {"2.2.2.2, 3.3.3.3"}, + }, + provider: &testutil.MockProvider{URL: "https://upstream-host/base"}, + expectURL: "https://upstream-host/base/chat", + expectHeaders: http.Header{ + "X-Forwarded-Host": {"client-host"}, + "X-Forwarded-Proto": {"http"}, + "X-Forwarded-For": {"2.2.2.2, 3.3.3.3, 1.1.1.1"}, + "User-Agent": {"aibridge"}, + }, + }, + { + name: "tls_request_sets_forwarded_proto_to_https", + reqPath: "http://client-host/chat", + reqRemoteAddr: "1.1.1.1:1111", + reqTLS: true, + provider: &testutil.MockProvider{URL: "https://upstream-host/base"}, + expectURL: "https://upstream-host/base/chat", + expectHeaders: http.Header{ + "X-Forwarded-Host": {"client-host"}, + "X-Forwarded-Proto": {"https"}, + "X-Forwarded-For": {"1.1.1.1"}, + "User-Agent": {"aibridge"}, + }, + }, + { + // This is an edge case where whole `X-Forwarded-For` header + // is dropped if last hop (remote addr) is not parseable. + // This is how library handles this case and is not directly + // related to our code. Added it to verify that we + // don't accidentally break this behavior. + name: "omits_forwarded_for_when_remote_addr_is_not_parseable", + reqPath: "http://client-host/chat", + reqRemoteAddr: "not-a-socket-address", + reqHeaders: http.Header{ + "X-Forwarded-For": {"1.1.1.1"}, + }, + provider: &testutil.MockProvider{URL: "https://upstream-host/base"}, + expectURL: "https://upstream-host/base/chat", + expectHeaders: http.Header{ + "X-Forwarded-Host": {"client-host"}, + "X-Forwarded-Proto": {"http"}, + "User-Agent": {"aibridge"}, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + r := httptest.NewRequest(http.MethodGet, tc.reqPath, nil) + maps.Copy(r.Header, tc.reqHeaders) + r.RemoteAddr = tc.reqRemoteAddr + if tc.reqTLS { + r.TLS = &tls.ConnectionState{} + } + provBaseURL, err := url.Parse(tc.provider.URL) + assert.NoError(t, err) + + pr := &httputil.ProxyRequest{ + In: r, + Out: r.Clone(r.Context()), + } + + rewritePassthroughRequest(pr, provBaseURL, tc.provider) + + assert.Equal(t, tc.expectURL, pr.Out.URL.String()) + assert.Equal(t, "", pr.Out.Host) + assert.Equal(t, tc.expectHeaders, pr.Out.Header) + }) + } +} + +func TestPassthroughRouterReusesProxyInstance(t *testing.T) { + t.Parallel() + + var newConnections atomic.Int32 + upstream := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + })) + upstream.Config.ConnState = func(_ net.Conn, state http.ConnState) { + if state == http.StateNew { + newConnections.Add(1) + } + } + upstream.Start() + t.Cleanup(upstream.Close) + + logger := slogtest.Make(t, nil) + prov := &testutil.MockProvider{URL: upstream.URL} + handler := newPassthroughRouter(prov, logger, nil, testTracer) + + for i := range 2 { + req := httptest.NewRequest(http.MethodGet, "http://proxy.example.test/v1/models", nil) + resp := httptest.NewRecorder() + + handler.ServeHTTP(resp, req) + + assert.Equalf(t, http.StatusOK, resp.Code, "request %d", i+1) + assert.Equal(t, "ok", resp.Body.String()) + } + + assert.EqualValues(t, 1, newConnections.Load()) +} diff --git a/aibridge/provider/anthropic.go b/aibridge/provider/anthropic.go new file mode 100644 index 0000000000..88d94a78ca --- /dev/null +++ b/aibridge/provider/anthropic.go @@ -0,0 +1,215 @@ +package provider + +import ( + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + + "github.com/google/uuid" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/aibridge/circuitbreaker" + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/intercept/messages" + "github.com/coder/coder/v2/aibridge/tracing" + "github.com/coder/coder/v2/aibridge/utils" +) + +// anthropicForwardHeaders lists headers from incoming requests that should be +// forwarded to the Anthropic API. +// TODO(ssncferreira): remove as part of https://github.com/coder/aibridge/issues/192 +var anthropicForwardHeaders = []string{ + "Anthropic-Beta", +} + +var _ Provider = &Anthropic{} + +// Anthropic allows for interactions with the Anthropic API. +type Anthropic struct { + cfg config.Anthropic + bedrockCfg *config.AWSBedrock +} + +const routeMessages = "/v1/messages" // https://docs.anthropic.com/en/api/messages + +var anthropicOpenErrorResponse = func() []byte { + return []byte(`{"type":"error","error":{"type":"overloaded_error","message":"circuit breaker is open"}}`) +} + +var anthropicIsFailure = func(statusCode int) bool { + // https://platform.claude.com/docs/en/api/errors + if statusCode == 529 { + return true + } + return circuitbreaker.DefaultIsFailure(statusCode) +} + +func NewAnthropic(cfg config.Anthropic, bedrockCfg *config.AWSBedrock) *Anthropic { + if cfg.Name == "" { + cfg.Name = config.ProviderAnthropic + } + if cfg.BaseURL == "" { + cfg.BaseURL = "https://api.anthropic.com/" + } + if cfg.Key == "" { + cfg.Key = os.Getenv("ANTHROPIC_API_KEY") + } + if cfg.APIDumpDir == "" { + cfg.APIDumpDir = os.Getenv("BRIDGE_DUMP_DIR") + } + if cfg.MaxRetries == nil { + if v := os.Getenv("ANTHROPIC_MAX_RETRIES"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + cfg.MaxRetries = &n + } + } + } + if cfg.CircuitBreaker != nil { + cfg.CircuitBreaker.IsFailure = anthropicIsFailure + cfg.CircuitBreaker.OpenErrorResponse = anthropicOpenErrorResponse + } + + return &Anthropic{ + cfg: cfg, + bedrockCfg: bedrockCfg, + } +} + +func (*Anthropic) Type() string { + return config.ProviderAnthropic +} + +func (p *Anthropic) Name() string { + return p.cfg.Name +} + +func (p *Anthropic) RoutePrefix() string { + return fmt.Sprintf("/%s", p.Name()) +} + +func (*Anthropic) BridgedRoutes() []string { + return []string{routeMessages} +} + +func (*Anthropic) PassthroughRoutes() []string { + return []string{ + "/v1/models", + "/v1/models/", // See https://pkg.go.dev/net/http#hdr-Trailing_slash_redirection-ServeMux. + "/v1/messages/count_tokens", + "/api/event_logging/", + } +} + +func (p *Anthropic) CreateInterceptor(_ http.ResponseWriter, r *http.Request, tracer trace.Tracer) (_ intercept.Interceptor, outErr error) { + id := uuid.New() + _, span := tracer.Start(r.Context(), "Intercept.CreateInterceptor") + defer tracing.EndSpanErr(span, &outErr) + + path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix()) + if path != routeMessages { + span.SetStatus(codes.Error, "unknown route: "+r.URL.Path) + return nil, ErrUnknownRoute + } + + payload, err := io.ReadAll(r.Body) + if err != nil { + return nil, xerrors.Errorf("read body: %w", err) + } + + reqPayload, err := messages.NewRequestPayload(payload) + if err != nil { + return nil, xerrors.Errorf("unmarshal request body: %w", err) + } + + cfg := p.cfg + cfg.ExtraHeaders = extractAnthropicHeaders(r) + + // At this point the request contains only LLM provider headers. + // Any Coder-specific authentication has already been stripped. + // + // In centralized mode neither Authorization nor X-Api-Key is + // present, so cfg keeps the centralized key unchanged. + // + // In BYOK mode the user's LLM credentials survive intact. + // If X-Api-Key is present the user has a personal API key; + // overwrite the centralized key with it. If Authorization is + // present the user authenticated directly with provider; + // set BYOKBearerToken and clear the centralized key. + // When both are present, X-Api-Key takes priority to match + // claude-code behavior. + credKind := intercept.CredentialKindCentralized + credSecret := cfg.Key + authHeaderName := p.AuthHeader() + if apiKey := r.Header.Get("X-Api-Key"); apiKey != "" { + cfg.Key = apiKey + authHeaderName = "X-Api-Key" + credKind = intercept.CredentialKindBYOK + credSecret = apiKey + } else if token := utils.ExtractBearerToken(r.Header.Get("Authorization")); token != "" { + cfg.BYOKBearerToken = token + cfg.Key = "" + authHeaderName = "Authorization" + credKind = intercept.CredentialKindBYOK + credSecret = token + } + + cred := intercept.NewCredentialInfo(credKind, credSecret) + + var interceptor intercept.Interceptor + if reqPayload.Stream() { + interceptor = messages.NewStreamingInterceptor(id, reqPayload, p.Name(), cfg, p.bedrockCfg, r.Header, authHeaderName, tracer, cred) + } else { + interceptor = messages.NewBlockingInterceptor(id, reqPayload, p.Name(), cfg, p.bedrockCfg, r.Header, authHeaderName, tracer, cred) + } + span.SetAttributes(interceptor.TraceAttributes(r)...) + return interceptor, nil +} + +func (p *Anthropic) BaseURL() string { + return p.cfg.BaseURL +} + +func (*Anthropic) AuthHeader() string { + return "X-Api-Key" +} + +func (p *Anthropic) InjectAuthHeader(headers *http.Header) { + if headers == nil { + headers = &http.Header{} + } + + // BYOK: if the request already carries user-supplied credentials, + // do not overwrite them with the centralized key. + if headers.Get("X-Api-Key") != "" || headers.Get("Authorization") != "" { + return + } + + headers.Set(p.AuthHeader(), p.cfg.Key) +} + +func (p *Anthropic) CircuitBreakerConfig() *config.CircuitBreaker { + return p.cfg.CircuitBreaker +} + +func (p *Anthropic) APIDumpDir() string { + return p.cfg.APIDumpDir +} + +// extractAnthropicHeaders extracts headers required by the Anthropic API from +// the incoming request. +// TODO(ssncferreira): remove as part of https://github.com/coder/aibridge/issues/192 +func extractAnthropicHeaders(r *http.Request) map[string]string { + headers := make(map[string]string, len(anthropicForwardHeaders)) + for _, h := range anthropicForwardHeaders { + if v := r.Header.Get(h); v != "" { + headers[h] = v + } + } + return headers +} diff --git a/aibridge/provider/anthropic_test.go b/aibridge/provider/anthropic_test.go new file mode 100644 index 0000000000..75eef0e0d0 --- /dev/null +++ b/aibridge/provider/anthropic_test.go @@ -0,0 +1,374 @@ +package provider //nolint:testpackage // tests unexported internals + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/internal/testutil" +) + +func TestAnthropic_TypeAndName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg config.Anthropic + expectType string + expectName string + }{ + { + name: "defaults", + cfg: config.Anthropic{}, + expectType: config.ProviderAnthropic, + expectName: config.ProviderAnthropic, + }, + { + name: "custom_name", + cfg: config.Anthropic{Name: "anthropic-custom"}, + expectType: config.ProviderAnthropic, + expectName: "anthropic-custom", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := NewAnthropic(tc.cfg, nil) + assert.Equal(t, tc.expectType, p.Type()) + assert.Equal(t, tc.expectName, p.Name()) + }) + } +} + +func TestAnthropic_CreateInterceptor(t *testing.T) { + t.Parallel() + + provider := NewAnthropic(config.Anthropic{Key: "test-key"}, nil) + + t.Run("Messages_NonStreamingRequest_BlockingInterceptor", func(t *testing.T) { + t.Parallel() + + body := `{"model": "claude-opus-4-5", "max_tokens": 1024, "messages": [{"role": "user", "content": "hello"}], "stream": false}` + req := httptest.NewRequest(http.MethodPost, routeMessages, bytes.NewBufferString(body)) + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.NoError(t, err) + require.NotNil(t, interceptor) + assert.False(t, interceptor.Streaming()) + }) + + t.Run("Messages_StreamingRequest_StreamingInterceptor", func(t *testing.T) { + t.Parallel() + + body := `{"model": "claude-opus-4-5", "max_tokens": 1024, "messages": [{"role": "user", "content": "hello"}], "stream": true}` + req := httptest.NewRequest(http.MethodPost, routeMessages, bytes.NewBufferString(body)) + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.NoError(t, err) + require.NotNil(t, interceptor) + assert.True(t, interceptor.Streaming()) + }) + + t.Run("Messages_InvalidRequestBody", func(t *testing.T) { + t.Parallel() + + body := `invalid json` + req := httptest.NewRequest(http.MethodPost, routeMessages, bytes.NewBufferString(body)) + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.Error(t, err) + require.Nil(t, interceptor) + assert.Contains(t, err.Error(), "unmarshal request body") + }) + + t.Run("Messages_ClientHeaders", func(t *testing.T) { + t.Parallel() + + var receivedHeaders http.Header + + // Mock upstream that captures headers. + mockUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"msg-123","type":"message","role":"assistant","content":[{"type":"text","text":"Hello!"}],"model":"claude-opus-4-5","stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":5}}`)) + })) + t.Cleanup(mockUpstream.Close) + + provider := NewAnthropic(config.Anthropic{ + BaseURL: mockUpstream.URL, + Key: "test-key", + }, nil) + + // Use a realistic multi-beta value as sent by Claude Code clients. + betaHeader := "claude-code-20250219,adaptive-thinking-2026-01-28,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24" + + body := `{"model": "claude-opus-4-5", "max_tokens": 1024, "messages": [{"role": "user", "content": "hello"}], "stream": false}` + req := httptest.NewRequest(http.MethodPost, routeMessages, bytes.NewBufferString(body)) + req.Header.Set("Anthropic-Beta", betaHeader) + // Simulate a client sending both Authorization and X-Api-Key headers. + // In this case, only the X-Api-Key header is preserved. + req.Header.Set("Authorization", "Bearer fake-client-bearer") + req.Header.Set("X-Api-Key", "personal user key") + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + require.NoError(t, err) + require.NotNil(t, interceptor) + + logger := slog.Make() + interceptor.Setup(logger, &testutil.MockRecorder{}, nil) + + processReq := httptest.NewRequest(http.MethodPost, routeMessages, nil) + err = interceptor.ProcessRequest(w, processReq) + require.NoError(t, err) + + // Verify the full Anthropic-Beta header (all betas) was forwarded unchanged. + assert.Equal(t, betaHeader, receivedHeaders.Get("Anthropic-Beta"), "Anthropic-Beta header must be forwarded unchanged to upstream") + + // Verify user's personal key was used and the authorization header was not forwarded. + assert.Equal(t, "personal user key", receivedHeaders.Get("X-Api-Key"), "upstream must receive personal user key") + assert.Empty(t, receivedHeaders.Get("Authorization"), "client Authorization header must not reach upstream") + }) + + t.Run("ErrUnknownRoute", func(t *testing.T) { + t.Parallel() + + body := `{"model": "claude-opus-4-5", "max_tokens": 1024, "messages": [{"role": "user", "content": "hello"}]}` + req := httptest.NewRequest(http.MethodPost, "/anthropic/unknown/route", bytes.NewBufferString(body)) + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.ErrorIs(t, err, ErrUnknownRoute) + require.Nil(t, interceptor) + }) +} + +func TestAnthropic_CreateInterceptor_BYOK(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setHeaders map[string]string + wantXApiKey string + wantAuthorization string + wantCredentialKind intercept.CredentialKind + wantCredentialHint string + }{ + { + name: "Messages_BYOK_BearerToken", + setHeaders: map[string]string{"Authorization": "Bearer user-access-token"}, + wantAuthorization: "Bearer user-access-token", + wantCredentialKind: intercept.CredentialKindBYOK, + wantCredentialHint: "us...en", + }, + { + name: "Messages_BYOK_APIKey", + setHeaders: map[string]string{"X-Api-Key": "user-api-key"}, + wantXApiKey: "user-api-key", + wantCredentialKind: intercept.CredentialKindBYOK, + wantCredentialHint: "us...ey", + }, + { + name: "Messages_Centralized", + setHeaders: map[string]string{}, + wantXApiKey: "test-key", + wantCredentialKind: intercept.CredentialKindCentralized, + wantCredentialHint: "t...y", + }, + { + name: "Messages_BYOK_BearerToken_And_APIKey", + setHeaders: map[string]string{ + "Authorization": "Bearer user-access-token", + "X-Api-Key": "user-api-key", + }, + wantXApiKey: "user-api-key", + wantCredentialKind: intercept.CredentialKindBYOK, + wantCredentialHint: "us...ey", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var receivedHeaders http.Header + + mockUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"msg-123","type":"message","role":"assistant","content":[{"type":"text","text":"Hello!"}],"model":"claude-opus-4-5","stop_reason":"end_turn","usage":{"input_tokens":10,"output_tokens":5}}`)) + })) + t.Cleanup(mockUpstream.Close) + + provider := NewAnthropic(config.Anthropic{ + BaseURL: mockUpstream.URL, + Key: "test-key", + }, nil) + + body := `{"model": "claude-opus-4-5", "max_tokens": 1024, "messages": [{"role": "user", "content": "hello"}], "stream": false}` + req := httptest.NewRequest(http.MethodPost, routeMessages, bytes.NewBufferString(body)) + for k, v := range tc.setHeaders { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + require.NoError(t, err) + require.NotNil(t, interceptor) + + cred := interceptor.Credential() + assert.Equal(t, tc.wantCredentialKind, cred.Kind, "credential kind mismatch") + assert.Equal(t, tc.wantCredentialHint, cred.Hint, "credential hint mismatch") + + logger := slog.Make() + interceptor.Setup(logger, &testutil.MockRecorder{}, nil) + + processReq := httptest.NewRequest(http.MethodPost, routeMessages, nil) + err = interceptor.ProcessRequest(w, processReq) + require.NoError(t, err) + + assert.Equal(t, tc.wantXApiKey, receivedHeaders.Get("X-Api-Key")) + assert.Equal(t, tc.wantAuthorization, receivedHeaders.Get("Authorization")) + }) + } +} + +func TestAnthropic_InjectAuthHeader(t *testing.T) { + t.Parallel() + + provider := NewAnthropic(config.Anthropic{Key: "centralized-key"}, nil) + + tests := []struct { + name string + presetHeaders map[string]string + wantXApiKey string + wantAuthorization string + }{ + { + name: "when no auth headers are provided, inject centralized key", + presetHeaders: map[string]string{}, + wantXApiKey: "centralized-key", + }, + { + name: "when X-Api-Key header is provided, use it", + presetHeaders: map[string]string{"X-Api-Key": "user-api-key"}, + wantXApiKey: "user-api-key", + }, + { + name: "when Authorization header is provided, use it", + presetHeaders: map[string]string{"Authorization": "Bearer user-access-token"}, + wantAuthorization: "Bearer user-access-token", + }, + { + name: "when both headers are provided, keep both", + presetHeaders: map[string]string{ + "Authorization": "Bearer user-access-token", + "X-Api-Key": "user-api-key", + }, + wantXApiKey: "user-api-key", + wantAuthorization: "Bearer user-access-token", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + headers := http.Header{} + for k, v := range tc.presetHeaders { + headers.Set(k, v) + } + + provider.InjectAuthHeader(&headers) + + assert.Equal(t, tc.wantXApiKey, headers.Get("X-Api-Key")) + assert.Equal(t, tc.wantAuthorization, headers.Get("Authorization")) + }) + } +} + +func TestExtractAnthropicHeaders(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + headers map[string]string + expected map[string]string + }{ + { + name: "no headers", + headers: map[string]string{}, + expected: map[string]string{}, + }, + { + name: "single beta", + headers: map[string]string{"Anthropic-Beta": "claude-code-20250219"}, + expected: map[string]string{"Anthropic-Beta": "claude-code-20250219"}, + }, + { + name: "multiple betas in single header", + headers: map[string]string{"Anthropic-Beta": "claude-code-20250219,adaptive-thinking-2026-01-28,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24"}, + expected: map[string]string{"Anthropic-Beta": "claude-code-20250219,adaptive-thinking-2026-01-28,context-management-2025-06-27,prompt-caching-scope-2026-01-05,effort-2025-11-24"}, + }, + { + name: "ignores other headers", + headers: map[string]string{"Anthropic-Beta": "claude-code-20250219,context-management-2025-06-27", "X-Api-Key": "secret"}, + expected: map[string]string{"Anthropic-Beta": "claude-code-20250219,context-management-2025-06-27"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/", nil) + for header, value := range tc.headers { + req.Header.Set(header, value) + } + + result := extractAnthropicHeaders(req) + assert.Equal(t, tc.expected, result) + }) + } +} + +func Test_anthropicIsFailure(t *testing.T) { + t.Parallel() + + tests := []struct { + statusCode int + isFailure bool + }{ + {http.StatusOK, false}, + {http.StatusBadRequest, false}, + {http.StatusUnauthorized, false}, + {http.StatusTooManyRequests, true}, // 429 + {http.StatusInternalServerError, false}, + {http.StatusBadGateway, false}, + {http.StatusServiceUnavailable, true}, // 503 + {http.StatusGatewayTimeout, true}, // 504 + {529, true}, // Anthropic Overloaded + } + + for _, tt := range tests { + assert.Equal(t, tt.isFailure, anthropicIsFailure(tt.statusCode), "status code %d", tt.statusCode) + } +} diff --git a/aibridge/provider/copilot.go b/aibridge/provider/copilot.go new file mode 100644 index 0000000000..153514e979 --- /dev/null +++ b/aibridge/provider/copilot.go @@ -0,0 +1,212 @@ +package provider + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + + "github.com/google/uuid" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/intercept/chatcompletions" + "github.com/coder/coder/v2/aibridge/intercept/responses" + "github.com/coder/coder/v2/aibridge/tracing" + "github.com/coder/coder/v2/aibridge/utils" +) + +const ( + copilotBaseURL = "https://api.individual.githubcopilot.com" + + // Copilot exposes an OpenAI-compatible API, including for Anthropic models. + routeCopilotChatCompletions = "/chat/completions" + routeCopilotResponses = "/responses" +) + +var copilotOpenErrorResponse = func() []byte { + return []byte(`{"error":{"message":"circuit breaker is open","type":"server_error","code":"service_unavailable"}}`) +} + +// Headers that need to be forwarded to Copilot API. +// These were determined through manual testing as there is no reference +// of the headers in the official documentation. +// LiteLLM uses the same headers: +// https://docs.litellm.ai/docs/providers/github_copilot +var copilotForwardHeaders = []string{ + "Editor-Version", + "Copilot-Integration-Id", +} + +// Copilot implements the Provider interface for GitHub Copilot. +// Unlike other providers, Copilot uses per-user API keys that are passed through +// the request headers rather than configured statically. +type Copilot struct { + cfg config.Copilot + circuitBreaker *config.CircuitBreaker +} + +var _ Provider = &Copilot{} + +func NewCopilot(cfg config.Copilot) *Copilot { + if cfg.Name == "" { + cfg.Name = config.ProviderCopilot + } + if cfg.BaseURL == "" { + cfg.BaseURL = copilotBaseURL + } + if cfg.APIDumpDir == "" { + cfg.APIDumpDir = os.Getenv("BRIDGE_DUMP_DIR") + } + if cfg.MaxRetries == nil { + if v := os.Getenv("COPILOT_MAX_RETRIES"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + cfg.MaxRetries = &n + } + } + } + if cfg.CircuitBreaker != nil { + cfg.CircuitBreaker.OpenErrorResponse = copilotOpenErrorResponse + } + return &Copilot{ + cfg: cfg, + circuitBreaker: cfg.CircuitBreaker, + } +} + +func (*Copilot) Type() string { + return config.ProviderCopilot +} + +func (p *Copilot) Name() string { + return p.cfg.Name +} + +func (p *Copilot) BaseURL() string { + return p.cfg.BaseURL +} + +func (p *Copilot) RoutePrefix() string { + return fmt.Sprintf("/%s", p.Name()) +} + +func (*Copilot) BridgedRoutes() []string { + return []string{ + routeCopilotChatCompletions, + routeCopilotResponses, + } +} + +func (*Copilot) PassthroughRoutes() []string { + return []string{ + "/models", + "/models/", + "/agents/", + "/mcp/", + "/.well-known/", + } +} + +func (*Copilot) AuthHeader() string { + return "Authorization" +} + +// InjectAuthHeader is a no-op for Copilot. +// Copilot uses per-user tokens passed in the original Authorization header, +// rather than a global key configured at the provider level. +// The original Authorization header flows through untouched from the client. +func (*Copilot) InjectAuthHeader(_ *http.Header) {} + +func (p *Copilot) CircuitBreakerConfig() *config.CircuitBreaker { + return p.circuitBreaker +} + +func (p *Copilot) APIDumpDir() string { + return p.cfg.APIDumpDir +} + +func (p *Copilot) CreateInterceptor(_ http.ResponseWriter, r *http.Request, tracer trace.Tracer) (_ intercept.Interceptor, outErr error) { + _, span := tracer.Start(r.Context(), "Intercept.CreateInterceptor") + defer tracing.EndSpanErr(span, &outErr) + + // Extract the per-user Copilot key from the Authorization header. + key := utils.ExtractBearerToken(r.Header.Get("Authorization")) + if key == "" { + span.SetStatus(codes.Error, "missing authorization") + return nil, xerrors.New("missing Copilot authorization: Authorization header not found or invalid") + } + + id := uuid.New() + + // Build config for the interceptor using the per-request key. + // Copilot's API is OpenAI-compatible, so it uses the OpenAI interceptors + // that require a config.OpenAI. + cfg := config.OpenAI{ + BaseURL: p.cfg.BaseURL, + Key: key, + APIDumpDir: p.cfg.APIDumpDir, + CircuitBreaker: p.cfg.CircuitBreaker, + ExtraHeaders: extractCopilotHeaders(r), + MaxRetries: p.cfg.MaxRetries, + } + + cred := intercept.NewCredentialInfo(intercept.CredentialKindBYOK, key) + + var interceptor intercept.Interceptor + + path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix()) + switch path { + case routeCopilotChatCompletions: + var req chatcompletions.ChatCompletionNewParamsWrapper + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return nil, xerrors.Errorf("unmarshal chat completions request body: %w", err) + } + + if req.Stream { + interceptor = chatcompletions.NewStreamingInterceptor(id, &req, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) + } else { + interceptor = chatcompletions.NewBlockingInterceptor(id, &req, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) + } + + case routeCopilotResponses: + payload, err := io.ReadAll(r.Body) + if err != nil { + return nil, xerrors.Errorf("read body: %w", err) + } + reqPayload, err := responses.NewRequestPayload(payload) + if err != nil { + return nil, xerrors.Errorf("unmarshal request body: %w", err) + } + + if reqPayload.Stream() { + interceptor = responses.NewStreamingInterceptor(id, reqPayload, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) + } else { + interceptor = responses.NewBlockingInterceptor(id, reqPayload, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) + } + + default: + span.SetStatus(codes.Error, "unknown route: "+r.URL.Path) + return nil, ErrUnknownRoute + } + + span.SetAttributes(interceptor.TraceAttributes(r)...) + return interceptor, nil +} + +// extractCopilotHeaders extracts headers required by the Copilot API from the +// incoming request. Copilot requires certain client headers to be forwarded. +func extractCopilotHeaders(r *http.Request) map[string]string { + headers := make(map[string]string, len(copilotForwardHeaders)) + for _, h := range copilotForwardHeaders { + if v := r.Header.Get(h); v != "" { + headers[h] = v + } + } + return headers +} diff --git a/aibridge/provider/copilot_test.go b/aibridge/provider/copilot_test.go new file mode 100644 index 0000000000..cd30a83350 --- /dev/null +++ b/aibridge/provider/copilot_test.go @@ -0,0 +1,361 @@ +package provider //nolint:testpackage // tests unexported internals + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/internal/testutil" +) + +var testTracer = otel.Tracer("copilot_test") + +func TestCopilot_TypeAndName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg config.Copilot + expectType string + expectName string + }{ + { + name: "defaults", + cfg: config.Copilot{}, + expectType: config.ProviderCopilot, + expectName: config.ProviderCopilot, + }, + { + name: "custom_name", + cfg: config.Copilot{Name: "copilot-business"}, + expectType: config.ProviderCopilot, + expectName: "copilot-business", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := NewCopilot(tc.cfg) + assert.Equal(t, tc.expectType, p.Type()) + assert.Equal(t, tc.expectName, p.Name()) + }) + } +} + +func TestCopilot_InjectAuthHeader(t *testing.T) { + t.Parallel() + + // Copilot uses per-user key passed in the Authorization header, + // so InjectAuthHeader should not modify any headers. + provider := NewCopilot(config.Copilot{}) + + t.Run("ExistingHeaders_Unchanged", func(t *testing.T) { + t.Parallel() + + headers := http.Header{} + headers.Set("Authorization", "Bearer user-token") + headers.Set("X-Custom-Header", "custom-value") + + provider.InjectAuthHeader(&headers) + + assert.Equal(t, "Bearer user-token", headers.Get("Authorization"), + "Authorization header should remain unchanged") + assert.Equal(t, "custom-value", headers.Get("X-Custom-Header"), + "other headers should remain unchanged") + }) + + t.Run("EmptyHeaders_NoneAdded", func(t *testing.T) { + t.Parallel() + + headers := http.Header{} + + provider.InjectAuthHeader(&headers) + + assert.Empty(t, headers, "no headers should be added") + }) +} + +func TestCopilot_CreateInterceptor(t *testing.T) { + t.Parallel() + + provider := NewCopilot(config.Copilot{}) + + t.Run("MissingAuthorizationHeader", func(t *testing.T) { + t.Parallel() + + body := `{"model": "gpt-4.1", "messages": [{"role": "user", "content": "hello"}]}` + req := httptest.NewRequest(http.MethodPost, routeCopilotChatCompletions, bytes.NewBufferString(body)) + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.Error(t, err) + require.Nil(t, interceptor) + assert.Contains(t, err.Error(), "missing Copilot authorization: Authorization header not found or invalid") + }) + + t.Run("InvalidAuthorizationFormat", func(t *testing.T) { + t.Parallel() + + body := `{"model": "claude-haiku-4.5", "messages": [{"role": "user", "content": "hello"}]}` + req := httptest.NewRequest(http.MethodPost, routeCopilotChatCompletions, bytes.NewBufferString(body)) + req.Header.Set("Authorization", "InvalidFormat") + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.Error(t, err) + require.Nil(t, interceptor) + assert.Contains(t, err.Error(), "missing Copilot authorization: Authorization header not found or invalid") + }) + + t.Run("ChatCompletions_NonStreamingRequest_BlockingInterceptor", func(t *testing.T) { + t.Parallel() + + body := `{"model": "claude-haiku-4.5", "messages": [{"role": "user", "content": "hello"}], "stream": false}` + req := httptest.NewRequest(http.MethodPost, routeCopilotChatCompletions, bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.NoError(t, err) + require.NotNil(t, interceptor) + assert.False(t, interceptor.Streaming()) + }) + + t.Run("ChatCompletions_StreamingRequest_StreamingInterceptor", func(t *testing.T) { + t.Parallel() + + body := `{"model": "gpt-4.1", "messages": [{"role": "user", "content": "hello"}], "stream": true}` + req := httptest.NewRequest(http.MethodPost, routeCopilotChatCompletions, bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.NoError(t, err) + require.NotNil(t, interceptor) + assert.True(t, interceptor.Streaming()) + }) + + t.Run("ChatCompletions_InvalidRequestBody", func(t *testing.T) { + t.Parallel() + + body := `invalid json` + req := httptest.NewRequest(http.MethodPost, routeCopilotChatCompletions, bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.Error(t, err) + require.Nil(t, interceptor) + assert.Contains(t, err.Error(), "unmarshal chat completions request body") + }) + + t.Run("ChatCompletions_ClientHeaders", func(t *testing.T) { + t.Parallel() + + var receivedHeaders http.Header + + // Mock upstream that captures headers + mockUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"chatcmpl-123","object":"chat.completion","created":1677652288,"model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"Hello!"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":12,"total_tokens":21}}`)) + })) + t.Cleanup(mockUpstream.Close) + + // Create provider with mock upstream URL + provider := NewCopilot(config.Copilot{ + BaseURL: mockUpstream.URL, + }) + + body := `{"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "stream": false}` + req := httptest.NewRequest(http.MethodPost, routeCopilotChatCompletions, bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("Editor-Version", "vscode/1.85.0") + req.Header.Set("Copilot-Integration-Id", "test-integration") + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + require.NoError(t, err) + require.NotNil(t, interceptor) + + // Setup and process request + logger := slog.Make() + interceptor.Setup(logger, &testutil.MockRecorder{}, nil) + + processReq := httptest.NewRequest(http.MethodPost, routeCopilotChatCompletions, nil) + err = interceptor.ProcessRequest(w, processReq) + require.NoError(t, err) + + // Verify Copilot-specific headers were forwarded. + assert.Equal(t, "vscode/1.85.0", receivedHeaders.Get("Editor-Version")) + assert.Equal(t, "test-integration", receivedHeaders.Get("Copilot-Integration-Id")) + // Copilot uses per-user tokens: the client's Authorization must reach upstream as-is. + assert.Equal(t, "Bearer test-token", receivedHeaders.Get("Authorization"), "client Authorization must be used as provider key") + assert.Empty(t, receivedHeaders.Get("X-Api-Key"), "X-Api-Key must not be set upstream") + }) + + t.Run("Responses_NonStreamingRequest_BlockingInterceptor", func(t *testing.T) { + t.Parallel() + + body := `{"model": "gpt-5-mini", "input": "hello", "stream": false}` + req := httptest.NewRequest(http.MethodPost, routeCopilotResponses, bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.NoError(t, err) + require.NotNil(t, interceptor) + assert.False(t, interceptor.Streaming()) + }) + + t.Run("Responses_StreamingRequest_StreamingInterceptor", func(t *testing.T) { + t.Parallel() + + body := `{"model": "gpt-5-mini", "input": "hello", "stream": true}` + req := httptest.NewRequest(http.MethodPost, routeCopilotResponses, bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.NoError(t, err) + require.NotNil(t, interceptor) + assert.True(t, interceptor.Streaming()) + }) + + t.Run("Responses_InvalidRequestBody", func(t *testing.T) { + t.Parallel() + + body := `invalid json` + req := httptest.NewRequest(http.MethodPost, routeCopilotResponses, bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.Error(t, err) + require.Nil(t, interceptor) + assert.Contains(t, err.Error(), "invalid JSON payload") + }) + + t.Run("Responses_ClientHeaders", func(t *testing.T) { + t.Parallel() + + var receivedHeaders http.Header + + // Mock upstream that captures headers + mockUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"resp-123","object":"responses.response","created":1677652288,"model":"gpt-5-mini","output":[],"usage":{"input_tokens":5,"output_tokens":10,"total_tokens":15}}`)) + })) + t.Cleanup(mockUpstream.Close) + + // Create provider with mock upstream URL + provider := NewCopilot(config.Copilot{ + BaseURL: mockUpstream.URL, + }) + + body := `{"model": "gpt-5-mini", "input": "hello", "stream": false}` + req := httptest.NewRequest(http.MethodPost, routeCopilotResponses, bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("Editor-Version", "vscode/1.85.0") + req.Header.Set("Copilot-Integration-Id", "test-integration") + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + require.NoError(t, err) + require.NotNil(t, interceptor) + + // Setup and process request + logger := slog.Make() + interceptor.Setup(logger, &testutil.MockRecorder{}, nil) + + processReq := httptest.NewRequest(http.MethodPost, routeCopilotResponses, nil) + err = interceptor.ProcessRequest(w, processReq) + require.NoError(t, err) + + // Verify Copilot-specific headers were forwarded. + assert.Equal(t, "vscode/1.85.0", receivedHeaders.Get("Editor-Version")) + assert.Equal(t, "test-integration", receivedHeaders.Get("Copilot-Integration-Id")) + // Copilot uses per-user tokens: the client's Authorization must reach upstream as-is. + assert.Equal(t, "Bearer test-token", receivedHeaders.Get("Authorization"), "client Authorization must be used as provider key") + assert.Empty(t, receivedHeaders.Get("X-Api-Key"), "X-Api-Key must not be set upstream") + }) + + t.Run("ErrUnknownRoute", func(t *testing.T) { + t.Parallel() + + body := `{"model": "gpt-4.1", "messages": [{"role": "user", "content": "hello"}]}` + req := httptest.NewRequest(http.MethodPost, "/copilot/unknown/route", bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + + require.ErrorIs(t, err, ErrUnknownRoute) + require.Nil(t, interceptor) + }) +} + +func TestExtractCopilotHeaders(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + headers map[string]string + expected map[string]string + }{ + { + name: "all headers present", + headers: map[string]string{"Editor-Version": "vscode/1.85.0", "Copilot-Integration-Id": "some-id"}, + expected: map[string]string{"Editor-Version": "vscode/1.85.0", "Copilot-Integration-Id": "some-id"}, + }, + { + name: "some headers present", + headers: map[string]string{"Editor-Version": "vscode/1.85.0"}, + expected: map[string]string{"Editor-Version": "vscode/1.85.0"}, + }, + { + name: "no headers", + headers: map[string]string{}, + expected: map[string]string{}, + }, + { + name: "ignores other headers", + headers: map[string]string{"Editor-Version": "vscode/1.85.0", "Authorization": "Bearer token"}, + expected: map[string]string{"Editor-Version": "vscode/1.85.0"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodPost, "/", nil) + for header, value := range tc.headers { + req.Header.Set(header, value) + } + + result := extractCopilotHeaders(req) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/aibridge/provider/openai.go b/aibridge/provider/openai.go new file mode 100644 index 0000000000..2e37da5a1b --- /dev/null +++ b/aibridge/provider/openai.go @@ -0,0 +1,198 @@ +package provider + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + + "github.com/google/uuid" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/intercept/chatcompletions" + "github.com/coder/coder/v2/aibridge/intercept/responses" + "github.com/coder/coder/v2/aibridge/tracing" + "github.com/coder/coder/v2/aibridge/utils" +) + +const ( + routeChatCompletions = "/chat/completions" // https://platform.openai.com/docs/api-reference/chat + routeResponses = "/responses" // https://platform.openai.com/docs/api-reference/responses +) + +var openAIOpenErrorResponse = func() []byte { + return []byte(`{"error":{"message":"circuit breaker is open","type":"server_error","code":"service_unavailable"}}`) +} + +// OpenAI allows for interactions with the OpenAI API. +type OpenAI struct { + cfg config.OpenAI + circuitBreaker *config.CircuitBreaker +} + +var _ Provider = &OpenAI{} + +func NewOpenAI(cfg config.OpenAI) *OpenAI { + if cfg.Name == "" { + cfg.Name = config.ProviderOpenAI + } + if cfg.BaseURL == "" { + cfg.BaseURL = "https://api.openai.com/v1/" + } + if cfg.Key == "" { + cfg.Key = os.Getenv("OPENAI_API_KEY") + } + if cfg.APIDumpDir == "" { + cfg.APIDumpDir = os.Getenv("BRIDGE_DUMP_DIR") + } + if cfg.MaxRetries == nil { + if v := os.Getenv("OPENAI_MAX_RETRIES"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + cfg.MaxRetries = &n + } + } + } + if cfg.CircuitBreaker != nil { + cfg.CircuitBreaker.OpenErrorResponse = openAIOpenErrorResponse + } + + return &OpenAI{ + cfg: cfg, + circuitBreaker: cfg.CircuitBreaker, + } +} + +func (*OpenAI) Type() string { + return config.ProviderOpenAI +} + +func (p *OpenAI) Name() string { + return p.cfg.Name +} + +func (p *OpenAI) RoutePrefix() string { + // Route prefix includes version to match default OpenAI base URL. + // More detailed explanation: https://github.com/coder/aibridge/pull/174#discussion_r2782320152 + return fmt.Sprintf("/%s/v1", p.Name()) +} + +func (*OpenAI) BridgedRoutes() []string { + return []string{ + routeChatCompletions, + routeResponses, + } +} + +// PassthroughRoutes define the routes which are not currently intercepted +// but must be passed through to the upstream. +// The /v1/completions legacy API is deprecated and will not be passed through. +// See https://platform.openai.com/docs/api-reference/completions. +func (*OpenAI) PassthroughRoutes() []string { + return []string{ + // See https://pkg.go.dev/net/http#hdr-Trailing_slash_redirection-ServeMux. + // but without non trailing slash route requests to `/v1/conversations` are going to catch all + "/conversations", + "/conversations/", + "/models", + "/models/", + "/responses/", // Forwards other responses API endpoints, eg: https://platform.openai.com/docs/api-reference/responses/get + } +} + +func (p *OpenAI) CreateInterceptor(_ http.ResponseWriter, r *http.Request, tracer trace.Tracer) (_ intercept.Interceptor, outErr error) { + id := uuid.New() + + _, span := tracer.Start(r.Context(), "Intercept.CreateInterceptor") + defer tracing.EndSpanErr(span, &outErr) + + var interceptor intercept.Interceptor + + cfg := p.cfg + // At this point the request contains only LLM provider headers. Any + // Coder-specific authentication has already been stripped. + // + // In centralized mode Authorization is absent, so cfg keeps the + // centralized key unchanged. + // + // In BYOK mode the user's credential is in Authorization. Replace + // the centralized key with it so it is forwarded upstream. + credKind := intercept.CredentialKindCentralized + if token := utils.ExtractBearerToken(r.Header.Get("Authorization")); token != "" { + cfg.Key = token + credKind = intercept.CredentialKindBYOK + } + cred := intercept.NewCredentialInfo(credKind, cfg.Key) + + path := strings.TrimPrefix(r.URL.Path, p.RoutePrefix()) + switch path { + case routeChatCompletions: + var req chatcompletions.ChatCompletionNewParamsWrapper + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return nil, xerrors.Errorf("unmarshal request body: %w", err) + } + + if req.Stream { + interceptor = chatcompletions.NewStreamingInterceptor(id, &req, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) + } else { + interceptor = chatcompletions.NewBlockingInterceptor(id, &req, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) + } + + case routeResponses: + payload, err := io.ReadAll(r.Body) + if err != nil { + return nil, xerrors.Errorf("read body: %w", err) + } + reqPayload, err := responses.NewRequestPayload(payload) + if err != nil { + return nil, xerrors.Errorf("unmarshal request body: %w", err) + } + if reqPayload.Stream() { + interceptor = responses.NewStreamingInterceptor(id, reqPayload, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) + } else { + interceptor = responses.NewBlockingInterceptor(id, reqPayload, p.Name(), cfg, r.Header, p.AuthHeader(), tracer, cred) + } + + default: + span.SetStatus(codes.Error, "unknown route: "+r.URL.Path) + return nil, ErrUnknownRoute + } + span.SetAttributes(interceptor.TraceAttributes(r)...) + return interceptor, nil +} + +func (p *OpenAI) BaseURL() string { + return p.cfg.BaseURL +} + +func (*OpenAI) AuthHeader() string { + return "Authorization" +} + +func (p *OpenAI) InjectAuthHeader(headers *http.Header) { + if headers == nil { + headers = &http.Header{} + } + + // BYOK: if the request already carries user-supplied credentials, + // do not overwrite them with the centralized key. + if headers.Get("Authorization") != "" { + return + } + + headers.Set(p.AuthHeader(), "Bearer "+p.cfg.Key) +} + +func (p *OpenAI) CircuitBreakerConfig() *config.CircuitBreaker { + return p.circuitBreaker +} + +func (p *OpenAI) APIDumpDir() string { + return p.cfg.APIDumpDir +} diff --git a/aibridge/provider/openai_test.go b/aibridge/provider/openai_test.go new file mode 100644 index 0000000000..d739a2dc20 --- /dev/null +++ b/aibridge/provider/openai_test.go @@ -0,0 +1,445 @@ +package provider //nolint:testpackage // tests unexported internals + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace/noop" + "golang.org/x/sync/errgroup" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/intercept" + "github.com/coder/coder/v2/aibridge/internal/testutil" +) + +const ( + chatCompletionResponse = `{"id":"chatcmpl-123","object":"chat.completion","created":1677652288,"model":"gpt-4","choices":[{"index":0,"message":{"role":"assistant","content":"Hello!"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":12,"total_tokens":21}}` + responsesAPIResponse = `{"id":"resp-123","object":"response","created_at":1677652288,"model":"gpt-5","output":[],"usage":{"input_tokens":5,"output_tokens":10,"total_tokens":15}}` +) + +type message struct { + Role string + Content string +} + +type providerStrategy interface { + DefaultModel() string + formatMessages(messages []message) []any + buildRequestBody(model string, messages []any, stream bool) map[string]any +} +type responsesProvider struct{} + +func (*responsesProvider) DefaultModel() string { + return "gpt-5" +} + +func (*responsesProvider) formatMessages(messages []message) []any { + formatted := make([]any, 0, len(messages)) + for _, msg := range messages { + formatted = append(formatted, map[string]any{ + "type": "message", + "role": msg.Role, + "content": msg.Content, + }) + } + return formatted +} + +func (*responsesProvider) buildRequestBody(model string, messages []any, stream bool) map[string]any { + return map[string]any{ + "model": model, + "input": messages, + "stream": stream, + } +} + +type chatCompletionsProvider struct{} + +func (*chatCompletionsProvider) DefaultModel() string { + return "gpt-4" +} + +func (*chatCompletionsProvider) formatMessages(messages []message) []any { + formatted := make([]any, 0, len(messages)) + for _, msg := range messages { + formatted = append(formatted, map[string]string{ + "role": msg.Role, + "content": msg.Content, + }) + } + return formatted +} + +func (*chatCompletionsProvider) buildRequestBody(model string, messages []any, stream bool) map[string]any { + return map[string]any{ + "model": model, + "messages": messages, + "stream": stream, + } +} + +func generateConversation(provider providerStrategy, targetSize int, numMessages int) []any { + if targetSize <= 0 { + return nil + } + if numMessages < 1 { + numMessages = 1 + } + + roles := []string{"user", "assistant"} + messages := make([]message, numMessages) + for i := range messages { + messages[i].Role = roles[i%2] + } + // Ensure last message is from user (required for LLM APIs). + if messages[len(messages)-1].Role != "user" { + messages[len(messages)-1].Role = "user" + } + + overhead := measureJSONSize(provider.formatMessages(messages)) + + bytesPerMessage := targetSize - overhead + if bytesPerMessage < 0 { + bytesPerMessage = 0 + } + + perMessage := bytesPerMessage / len(messages) + remainder := bytesPerMessage % len(messages) + + for i := range messages { + size := perMessage + if i == len(messages)-1 { + size += remainder + } + messages[i].Content = strings.Repeat("x", size) + } + + return provider.formatMessages(messages) +} + +func measureJSONSize(v any) int { + data, err := json.Marshal(v) + if err != nil { + return 0 + } + return len(data) +} + +// generateChatCompletionsPayload creates a JSON payload with the specified number of messages. +// Messages alternate between user and assistant roles to simulate a conversation. +func generateChatCompletionsPayload(payloadSize int, messageCount int, stream bool) []byte { + provider := &chatCompletionsProvider{} + messages := generateConversation(provider, payloadSize, messageCount) + + body := provider.buildRequestBody(provider.DefaultModel(), messages, stream) + bodyBytes, err := json.Marshal(body) + if err != nil { + panic(err) + } + return bodyBytes +} + +// generateResponsesPayload creates a JSON payload for the responses API with the specified number of input items. +// Input items alternate between user and assistant roles to simulate a conversation. +func generateResponsesPayload(payloadSize int, inputCount int, stream bool) []byte { + provider := &responsesProvider{} + inputs := generateConversation(provider, payloadSize, inputCount) + + body := provider.buildRequestBody(provider.DefaultModel(), inputs, stream) + bodyBytes, err := json.Marshal(body) + if err != nil { + panic(err) + } + return bodyBytes +} + +func TestOpenAI_TypeAndName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg config.OpenAI + expectType string + expectName string + }{ + { + name: "defaults", + cfg: config.OpenAI{}, + expectType: config.ProviderOpenAI, + expectName: config.ProviderOpenAI, + }, + { + name: "custom_name", + cfg: config.OpenAI{Name: "openai-custom"}, + expectType: config.ProviderOpenAI, + expectName: "openai-custom", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + p := NewOpenAI(tc.cfg) + assert.Equal(t, tc.expectType, p.Type()) + assert.Equal(t, tc.expectName, p.Name()) + }) + } +} + +func TestOpenAI_CreateInterceptor(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + route string + requestBody string + responseBody string + setHeaders map[string]string + wantAuthorization string + wantCredentialKind intercept.CredentialKind + wantCredentialHint string + }{ + { + name: "ChatCompletions_BYOK", + route: routeChatCompletions, + requestBody: `{"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "stream": false}`, + responseBody: chatCompletionResponse, + setHeaders: map[string]string{"Authorization": "Bearer user-token"}, + wantAuthorization: "Bearer user-token", + wantCredentialKind: intercept.CredentialKindBYOK, + wantCredentialHint: "us...en", + }, + { + name: "ChatCompletions_Centralized", + route: routeChatCompletions, + requestBody: `{"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "stream": false}`, + responseBody: chatCompletionResponse, + setHeaders: map[string]string{}, + wantAuthorization: "Bearer centralized-key", + wantCredentialKind: intercept.CredentialKindCentralized, + wantCredentialHint: "ce...ey", + }, + { + name: "Responses_BYOK", + route: routeResponses, + requestBody: `{"model": "gpt-5", "input": "hello", "stream": false}`, + responseBody: responsesAPIResponse, + setHeaders: map[string]string{"Authorization": "Bearer user-token"}, + wantAuthorization: "Bearer user-token", + wantCredentialKind: intercept.CredentialKindBYOK, + wantCredentialHint: "us...en", + }, + { + name: "Responses_Centralized", + route: routeResponses, + requestBody: `{"model": "gpt-5", "input": "hello", "stream": false}`, + responseBody: responsesAPIResponse, + setHeaders: map[string]string{}, + wantAuthorization: "Bearer centralized-key", + wantCredentialKind: intercept.CredentialKindCentralized, + wantCredentialHint: "ce...ey", + }, + // X-Api-Key should not appear in production since clients use Authorization, + // but ensure it is stripped if it does arrive. + { + name: "ChatCompletions_BYOK_XApiKeyStripped", + route: routeChatCompletions, + requestBody: `{"model": "gpt-4", "messages": [{"role": "user", "content": "hello"}], "stream": false}`, + responseBody: chatCompletionResponse, + setHeaders: map[string]string{ + "Authorization": "Bearer user-token", + "X-Api-Key": "some-key", + }, + wantAuthorization: "Bearer user-token", + wantCredentialKind: intercept.CredentialKindBYOK, + wantCredentialHint: "us...en", + }, + { + name: "Responses_BYOK_XApiKeyStripped", + route: routeResponses, + requestBody: `{"model": "gpt-5", "input": "hello", "stream": false}`, + responseBody: responsesAPIResponse, + setHeaders: map[string]string{ + "Authorization": "Bearer user-token", + "X-Api-Key": "some-key", + }, + wantAuthorization: "Bearer user-token", + wantCredentialKind: intercept.CredentialKindBYOK, + wantCredentialHint: "us...en", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var receivedHeaders http.Header + + mockUpstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeaders = r.Header.Clone() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(tc.responseBody)) + require.NoError(t, err) + })) + t.Cleanup(mockUpstream.Close) + + provider := NewOpenAI(config.OpenAI{ + BaseURL: mockUpstream.URL, + Key: "centralized-key", + }) + + req := httptest.NewRequest(http.MethodPost, provider.RoutePrefix()+tc.route, bytes.NewBufferString(tc.requestBody)) + for k, v := range tc.setHeaders { + req.Header.Set(k, v) + } + w := httptest.NewRecorder() + + interceptor, err := provider.CreateInterceptor(w, req, testTracer) + require.NoError(t, err) + require.NotNil(t, interceptor) + + cred := interceptor.Credential() + assert.Equal(t, tc.wantCredentialKind, cred.Kind, "credential kind mismatch") + assert.Equal(t, tc.wantCredentialHint, cred.Hint, "credential hint mismatch") + + logger := slog.Make() + interceptor.Setup(logger, &testutil.MockRecorder{}, nil) + + processReq := httptest.NewRequest(http.MethodPost, provider.RoutePrefix()+tc.route, nil) + err = interceptor.ProcessRequest(w, processReq) + require.NoError(t, err) + + assert.Equal(t, tc.wantAuthorization, receivedHeaders.Get("Authorization")) + assert.Empty(t, receivedHeaders.Get("X-Api-Key"), "X-Api-Key must not be set upstream") + }) + } +} + +func TestOpenAI_InjectAuthHeader(t *testing.T) { + t.Parallel() + + provider := NewOpenAI(config.OpenAI{Key: "centralized-key"}) + + tests := []struct { + name string + presetHeaders map[string]string + wantAuthorization string + }{ + { + name: "when no Authorization header is provided, inject centralized key", + presetHeaders: map[string]string{}, + wantAuthorization: "Bearer centralized-key", + }, + { + name: "when Authorization header is provided, do not overwrite it", + presetHeaders: map[string]string{"Authorization": "Bearer user-token"}, + wantAuthorization: "Bearer user-token", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + headers := http.Header{} + for k, v := range tc.presetHeaders { + headers.Set(k, v) + } + + provider.InjectAuthHeader(&headers) + + assert.Equal(t, tc.wantAuthorization, headers.Get("Authorization")) + }) + } +} + +func BenchmarkOpenAI_CreateInterceptor_ChatCompletions(b *testing.B) { + provider := NewOpenAI(config.OpenAI{ + BaseURL: "https://api.openai.com/v1/", + Key: "test-key", + }) + + tracer := noop.NewTracerProvider().Tracer("test") + messagesPerRequest := 50 + requestCount := 100 + maxConcurrentRequests := 10 + payloadSizes := []int{2000, 10000, 50000, 100000, 2000000} + for _, payloadSize := range payloadSizes { + for _, stream := range []bool{true, false} { + payload := generateChatCompletionsPayload(payloadSize, messagesPerRequest, stream) + name := fmt.Sprintf("stream=%t/payloadSize=%d/requests=%d", stream, payloadSize, requestCount) + + b.Run(name, func(b *testing.B) { + b.ResetTimer() + for range b.N { + eg := errgroup.Group{} + eg.SetLimit(maxConcurrentRequests) + for i := 0; i < requestCount; i++ { + eg.Go(func() error { + req := httptest.NewRequest(http.MethodPost, routeChatCompletions, bytes.NewReader(payload)) + w := httptest.NewRecorder() + _, err := provider.CreateInterceptor(w, req, tracer) + if err != nil { + return err + } + return nil + }) + } + } + }) + } + } +} + +func BenchmarkOpenAI_CreateInterceptor_Responses(b *testing.B) { + provider := NewOpenAI(config.OpenAI{ + BaseURL: "https://api.openai.com/v1/", + Key: "test-key", + }) + + tracer := noop.NewTracerProvider().Tracer("test") + messagesPerRequest := 50 + requestCount := 100 + maxConcurrentRequests := 10 + // payloadSizes := []int{2000, 10000, 50000, 100000, 2000000} + payloadSizes := []int{2000000} + for _, payloadSize := range payloadSizes { + for _, stream := range []bool{true, false} { + payload := generateResponsesPayload(payloadSize, messagesPerRequest, stream) + name := fmt.Sprintf("stream=%t/payloadSize=%d/requests=%d", stream, payloadSize, requestCount) + + b.Run(name, func(b *testing.B) { + b.ResetTimer() + for range b.N { + eg := errgroup.Group{} + eg.SetLimit(maxConcurrentRequests) + for i := 0; i < requestCount; i++ { + eg.Go(func() error { + req := httptest.NewRequest(http.MethodPost, routeResponses, bytes.NewReader(payload)) + w := httptest.NewRecorder() + interceptor, err := provider.CreateInterceptor(w, req, tracer) + if err != nil { + return err + } + err = interceptor.ProcessRequest(w, req) + if err != nil { + return err + } + return nil + }) + } + } + }) + } + } +} diff --git a/aibridge/provider/provider.go b/aibridge/provider/provider.go new file mode 100644 index 0000000000..cd09b6fc31 --- /dev/null +++ b/aibridge/provider/provider.go @@ -0,0 +1,87 @@ +package provider + +import ( + "net/http" + + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/aibridge/config" + "github.com/coder/coder/v2/aibridge/intercept" +) + +var ErrUnknownRoute = xerrors.New("unknown route") + +// Provider defines routes (bridged and passed through) for given provider. +// Bridged routes are processed by dedicated interceptors. +// +// All routes have following pattern: +// - https://coder.host.com/api/v2 + /aibridge + /{provider.RoutePrefix()} + /{bridged or passthrough route} +// {host} {aibridge root} {provider prefix} {provider route} +// +// {host} + {aibridge root} + {provider prefix} form the base URL used in tools/clients using AI Bridge (eg. Claude/Codex). +// +// When request is bridged, interceptor created based on route processes the request. +// When request is passed through the {host} + {aibridge root} + {provider prefix} URL part +// is replaced by provider's base URL and request is forwarded. +// This mirrors behavior in bridged routes and SDKs used by interceptors. +// +// Example: +// +// - OpenAI chat completions +// AI Bridge base URL (set in Codex): "https://host.coder.com/api/v2/aibridge/openai/v1" +// Upstream base URl (set in coder config): http://api.openai.com/v1 +// Request: Codex -> https://host.coder.com/api/v2/aibridge/openai/v1/chat/completions -> AI Bridge -> http://api.openai.com/v1/chat/completions +// url change: 'https://host.coder.com/api/v2/aibridge/openai/v1' -> 'http://api.openai.com/v1' | '/chat/completions' suffix remains the same +// +// - Anthropic messages +// AI Bridge base URL (set in Codex): "https://host.coder.com/api/v2/aibridge/anthropic" +// Upstream base URl (set in coder config): http://api.anthropic.com +// Request: Codex -> https://host.coder.com/api/v2/aibridge/anthropic/v1/messages -> AI Bridge -> http://api.anthropic.com/v1/messages +// url change: 'https://host.coder.com/api/v2/aibridge/anthropic' -> 'http://api.anthropic.com' | '/v1/messages' suffix remains the same +// +// !Note! +// OpenAI and Anthropic use different route patterns. +// OpenAI includes the version '/v1' in the base url while Anthropic does not. +// More details/examples: https://github.com/coder/aibridge/pull/174#discussion_r2782320152 +type Provider interface { + // Type returns the provider type: "copilot", "openai", or "anthropic". + // Multiple provider instances can share the same type. + Type() string + // Name returns the provider instance name. + // Defaults to Type() when not explicitly configured. + Name() string + // BaseURL defines the base URL endpoint for this provider's API. + BaseURL() string + + // CreateInterceptor starts a new [Interceptor] which is responsible for intercepting requests, + // communicating with the upstream provider and formulating a response to be sent to the requesting client. + CreateInterceptor(http.ResponseWriter, *http.Request, trace.Tracer) (intercept.Interceptor, error) + + // RoutePrefix returns a prefix on which the provider's bridged and passthroguh routes will be registered. + // Must be unique across providers to avoid conflicts. + RoutePrefix() string + + // BridgedRoutes returns a slice of [http.ServeMux]-compatible routes which will have special handling. + // See https://pkg.go.dev/net/http#hdr-Patterns-ServeMux. + BridgedRoutes() []string + // PassthroughRoutes returns a slice of whitelisted [http.ServeMux]-compatible* routes which are + // not currently intercepted and must be handled by the upstream directly. + // + // * only path routes can be specified, not ones containing HTTP methods. (i.e. GET /route). + // By default, these passthrough routes will accept any HTTP method. + PassthroughRoutes() []string + + // AuthHeader returns the name of the header which the provider expects to find its authentication + // token in. + AuthHeader() string + // InjectAuthHeader allows [Provider]s to set its authentication header. + InjectAuthHeader(*http.Header) + + // CircuitBreakerConfig returns the circuit breaker configuration for the provider. + CircuitBreakerConfig() *config.CircuitBreaker + + // APIDumpDir returns the directory path for dumping API requests and responses. + // Empty string is returned when API dumping is not enabled. + APIDumpDir() string +} diff --git a/aibridge/recorder/recorder.go b/aibridge/recorder/recorder.go new file mode 100644 index 0000000000..26a9f24b5d --- /dev/null +++ b/aibridge/recorder/recorder.go @@ -0,0 +1,300 @@ +package recorder + +import ( + "context" + "sync" + "time" + + "go.opentelemetry.io/otel/trace" + "golang.org/x/xerrors" + + "cdr.dev/slog/v3" + "github.com/coder/coder/v2/aibridge/metrics" + "github.com/coder/coder/v2/aibridge/tracing" +) + +var ( + _ Recorder = &WrappedRecorder{} + _ Recorder = &AsyncRecorder{} +) + +// WrappedRecorder is a convenience struct which implements RecorderClient and resolves a client before calling each method. +// It also sets the start/creation time of each record. +type WrappedRecorder struct { + logger slog.Logger + tracer trace.Tracer + clientFn func() (Recorder, error) +} + +func (r *WrappedRecorder) RecordInterception(ctx context.Context, req *InterceptionRecord) (outErr error) { + ctx, span := r.tracer.Start(ctx, "Intercept.RecordInterception", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...)) + defer tracing.EndSpanErr(span, &outErr) + + client, err := r.clientFn() + if err != nil { + return xerrors.Errorf("acquire client: %w", err) + } + + req.StartedAt = time.Now() + if err = client.RecordInterception(ctx, req); err == nil { + return nil + } + + r.logger.Warn(ctx, "failed to record interception", slog.Error(err), slog.F("interception_id", req.ID)) + return err +} + +func (r *WrappedRecorder) RecordInterceptionEnded(ctx context.Context, req *InterceptionRecordEnded) (outErr error) { + ctx, span := r.tracer.Start(ctx, "Intercept.RecordInterceptionEnded", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...)) + defer tracing.EndSpanErr(span, &outErr) + + client, err := r.clientFn() + if err != nil { + return xerrors.Errorf("acquire client: %w", err) + } + + req.EndedAt = time.Now().UTC() + if err = client.RecordInterceptionEnded(ctx, req); err == nil { + return nil + } + + r.logger.Warn(ctx, "failed to record that interception ended", slog.Error(err), slog.F("interception_id", req.ID)) + return err +} + +func (r *WrappedRecorder) RecordPromptUsage(ctx context.Context, req *PromptUsageRecord) (outErr error) { + ctx, span := r.tracer.Start(ctx, "Intercept.RecordPromptUsage", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...)) + defer tracing.EndSpanErr(span, &outErr) + + client, err := r.clientFn() + if err != nil { + return xerrors.Errorf("acquire client: %w", err) + } + + req.CreatedAt = time.Now() + if err = client.RecordPromptUsage(ctx, req); err == nil { + return nil + } + + r.logger.Warn(ctx, "failed to record prompt usage", slog.Error(err), slog.F("interception_id", req.InterceptionID)) + return err +} + +func (r *WrappedRecorder) RecordTokenUsage(ctx context.Context, req *TokenUsageRecord) (outErr error) { + ctx, span := r.tracer.Start(ctx, "Intercept.RecordTokenUsage", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...)) + defer tracing.EndSpanErr(span, &outErr) + + client, err := r.clientFn() + if err != nil { + return xerrors.Errorf("acquire client: %w", err) + } + + req.CreatedAt = time.Now() + if err = client.RecordTokenUsage(ctx, req); err == nil { + return nil + } + + r.logger.Warn(ctx, "failed to record token usage", slog.Error(err), slog.F("interception_id", req.InterceptionID)) + return err +} + +func (r *WrappedRecorder) RecordToolUsage(ctx context.Context, req *ToolUsageRecord) (outErr error) { + ctx, span := r.tracer.Start(ctx, "Intercept.RecordToolUsage", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...)) + defer tracing.EndSpanErr(span, &outErr) + + client, err := r.clientFn() + if err != nil { + return xerrors.Errorf("acquire client: %w", err) + } + + req.CreatedAt = time.Now() + if err = client.RecordToolUsage(ctx, req); err == nil { + return nil + } + + r.logger.Warn(ctx, "failed to record tool usage", slog.Error(err), slog.F("interception_id", req.InterceptionID)) + return err +} + +func (r *WrappedRecorder) RecordModelThought(ctx context.Context, req *ModelThoughtRecord) (outErr error) { + ctx, span := r.tracer.Start(ctx, "Intercept.RecordModelThought", trace.WithAttributes(tracing.InterceptionAttributesFromContext(ctx)...)) + defer tracing.EndSpanErr(span, &outErr) + + client, err := r.clientFn() + if err != nil { + return xerrors.Errorf("acquire client: %w", err) + } + + req.CreatedAt = time.Now() + if err = client.RecordModelThought(ctx, req); err == nil { + return nil + } + + r.logger.Warn(ctx, "failed to record model thought", slog.Error(err), slog.F("interception_id", req.InterceptionID)) + return err +} + +func NewWrappedRecorder(logger slog.Logger, tracer trace.Tracer, clientFn func() (Recorder, error)) *WrappedRecorder { + return &WrappedRecorder{ + logger: logger, + tracer: tracer, + clientFn: clientFn, + } +} + +// AsyncRecorder calls [Recorder] methods asynchronously and logs any errors which may occur. +type AsyncRecorder struct { + logger slog.Logger + wrapped Recorder + timeout time.Duration + metrics *metrics.Metrics + + provider string + model string + initiatorID string + client string + + wg sync.WaitGroup +} + +func NewAsyncRecorder(logger slog.Logger, wrapped Recorder, timeout time.Duration) *AsyncRecorder { + return &AsyncRecorder{logger: logger, wrapped: wrapped, timeout: timeout} +} + +func (a *AsyncRecorder) WithMetrics(m any) { + if m, ok := m.(*metrics.Metrics); ok { + a.metrics = m + } +} + +func (a *AsyncRecorder) WithProvider(provider string) { + a.provider = provider +} + +func (a *AsyncRecorder) WithModel(model string) { + a.model = model +} + +func (a *AsyncRecorder) WithInitiatorID(initiatorID string) { + a.initiatorID = initiatorID +} + +func (a *AsyncRecorder) WithClient(client string) { + a.client = client +} + +// RecordInterception must NOT be called asynchronously. +// If an interception cannot be recorded, the whole request should fail. +func (*AsyncRecorder) RecordInterception(context.Context, *InterceptionRecord) error { + panic("RecordInterception must not be called asynchronously") +} + +func (a *AsyncRecorder) RecordInterceptionEnded(ctx context.Context, req *InterceptionRecordEnded) error { + a.wg.Add(1) + go func() { + defer a.wg.Done() + timedCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), a.timeout) + defer cancel() + + err := a.wrapped.RecordInterceptionEnded(timedCtx, req) + if err != nil { + a.logger.Warn(timedCtx, "failed to record interception end", slog.F("type", "prompt"), slog.Error(err), slog.F("payload", req)) + } + }() + + return nil // Caller is not interested in error. +} + +func (a *AsyncRecorder) RecordPromptUsage(ctx context.Context, req *PromptUsageRecord) error { + a.wg.Add(1) + go func() { + defer a.wg.Done() + timedCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), a.timeout) + defer cancel() + + err := a.wrapped.RecordPromptUsage(timedCtx, req) + if err != nil { + a.logger.Warn(timedCtx, "failed to record usage", slog.F("type", "prompt"), slog.Error(err), slog.F("payload", req)) + } + + if a.metrics != nil && req.Prompt != "" { // TODO: will be irrelevant once https://github.com/coder/aibridge/issues/55 is fixed. + a.metrics.PromptCount.WithLabelValues(a.provider, a.model, a.initiatorID, a.client).Add(1) + } + }() + + return nil // Caller is not interested in error. +} + +func (a *AsyncRecorder) RecordTokenUsage(ctx context.Context, req *TokenUsageRecord) error { + a.wg.Add(1) + go func() { + defer a.wg.Done() + timedCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), a.timeout) + defer cancel() + + err := a.wrapped.RecordTokenUsage(timedCtx, req) + if err != nil { + a.logger.Warn(timedCtx, "failed to record usage", slog.F("type", "token"), slog.Error(err), slog.F("payload", req)) + } + + if a.metrics != nil { + a.metrics.TokenUseCount.WithLabelValues(a.provider, a.model, "input", a.initiatorID, a.client).Add(float64(req.Input)) + a.metrics.TokenUseCount.WithLabelValues(a.provider, a.model, "output", a.initiatorID, a.client).Add(float64(req.Output)) + a.metrics.TokenUseCount.WithLabelValues(a.provider, a.model, "cache_read_input_tokens", a.initiatorID, a.client).Add(float64(req.CacheReadInputTokens)) + a.metrics.TokenUseCount.WithLabelValues(a.provider, a.model, "cache_write_input_tokens", a.initiatorID, a.client).Add(float64(req.CacheWriteInputTokens)) + for k, v := range req.ExtraTokenTypes { + a.metrics.TokenUseCount.WithLabelValues(a.provider, a.model, k, a.initiatorID, a.client).Add(float64(v)) + } + } + }() + + return nil // Caller is not interested in error. +} + +func (a *AsyncRecorder) RecordToolUsage(ctx context.Context, req *ToolUsageRecord) error { + a.wg.Add(1) + go func() { + defer a.wg.Done() + timedCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), a.timeout) + defer cancel() + + err := a.wrapped.RecordToolUsage(timedCtx, req) + if err != nil { + a.logger.Warn(timedCtx, "failed to record usage", slog.F("type", "tool"), slog.Error(err), slog.F("payload", req)) + } + + if a.metrics != nil { + if req.Injected { + var srvURL string + if req.ServerURL != nil { + srvURL = *req.ServerURL + } + a.metrics.InjectedToolUseCount.WithLabelValues(a.provider, a.model, srvURL, req.Tool).Add(1) + } else { + a.metrics.NonInjectedToolUseCount.WithLabelValues(a.provider, a.model, req.Tool).Add(1) + } + } + }() + + return nil // Caller is not interested in error. +} + +func (a *AsyncRecorder) RecordModelThought(ctx context.Context, req *ModelThoughtRecord) error { + a.wg.Add(1) + go func() { + defer a.wg.Done() + timedCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), a.timeout) + defer cancel() + + err := a.wrapped.RecordModelThought(timedCtx, req) + if err != nil { + a.logger.Warn(timedCtx, "failed to record model thought", slog.F("type", "model_thought"), slog.Error(err), slog.F("payload", req)) + } + }() + + return nil // Caller is not interested in error. +} + +func (a *AsyncRecorder) Wait() { + a.wg.Wait() +} diff --git a/aibridge/recorder/types.go b/aibridge/recorder/types.go new file mode 100644 index 0000000000..cd541eebd4 --- /dev/null +++ b/aibridge/recorder/types.go @@ -0,0 +1,99 @@ +package recorder + +import ( + "context" + "time" +) + +// Recorder describes all the possible usage information we need to capture during interactions with AI providers. +// Additionally, it introduces the concept of an "Interception", which includes information about which provider/model was +// used and by whom. All usage records should reference this Interception by ID. +type Recorder interface { + // RecordInterception records metadata about an interception with an upstream AI provider. + RecordInterception(ctx context.Context, req *InterceptionRecord) error + // RecordInterceptionEnded records that given interception has completed. + RecordInterceptionEnded(ctx context.Context, req *InterceptionRecordEnded) error + // RecordTokenUsage records the tokens used in an interception with an upstream AI provider. + RecordTokenUsage(ctx context.Context, req *TokenUsageRecord) error + // RecordPromptUsage records the prompts used in an interception with an upstream AI provider. + RecordPromptUsage(ctx context.Context, req *PromptUsageRecord) error + // RecordToolUsage records the tools used in an interception with an upstream AI provider. + RecordToolUsage(ctx context.Context, req *ToolUsageRecord) error + // RecordModelThought records model thoughts produced in an interception with an upstream AI provider. + RecordModelThought(ctx context.Context, req *ModelThoughtRecord) error +} + +type ToolArgs any + +type Metadata map[string]any + +type InterceptionRecord struct { + ID string + InitiatorID string + Metadata Metadata + Model string + Provider string + ProviderName string + StartedAt time.Time + ClientSessionID *string + Client string + UserAgent string + CorrelatingToolCallID *string + CredentialKind string + CredentialHint string +} + +type InterceptionRecordEnded struct { + ID string + EndedAt time.Time +} + +type TokenUsageRecord struct { + InterceptionID string + MsgID string + Input int64 + Output int64 + CacheReadInputTokens int64 + CacheWriteInputTokens int64 + // ExtraTokenTypes holds token types which *may* exist over and above input/output. + // These should ultimately get merged into [Metadata], but it's useful to keep these + // with their actual type (int64) since [Metadata] is a map[string]any. + ExtraTokenTypes map[string]int64 + Metadata Metadata + CreatedAt time.Time +} + +type PromptUsageRecord struct { + InterceptionID string + MsgID string + Prompt string + Metadata Metadata + CreatedAt time.Time +} + +type ToolUsageRecord struct { + InterceptionID string + MsgID string + Tool string + ToolCallID string + ServerURL *string + Args ToolArgs + Injected bool + InvocationError error + Metadata Metadata + CreatedAt time.Time +} + +// Model thought source constants. +const ( + ThoughtSourceThinking = "thinking" + ThoughtSourceReasoningSummary = "reasoning_summary" + ThoughtSourceCommentary = "commentary" +) + +type ModelThoughtRecord struct { + InterceptionID string + Content string + Metadata Metadata + CreatedAt time.Time +} diff --git a/aibridge/session.go b/aibridge/session.go new file mode 100644 index 0000000000..34c45d2158 --- /dev/null +++ b/aibridge/session.go @@ -0,0 +1,94 @@ +package aibridge + +import ( + "bytes" + "io" + "net/http" + "regexp" + "strings" + + "github.com/tidwall/gjson" + + "github.com/coder/coder/v2/aibridge/utils" +) + +var claudeCodePattern = regexp.MustCompile(`_session_(.+)$`) // Legacy format: save compilation on each call. + +// GuessSessionID attempts to retrieve a session ID which may have been sent by +// the client. We only attempt to retrieve sessions using methods recognized for +// the given client. +func GuessSessionID(client Client, r *http.Request) *string { + switch client { + case ClientClaudeCode: + // Prefer the dedicated header (added in Claude Code v2.1.86+). + if sid := cleanRef(r.Header.Get("X-Claude-Code-Session-Id")); sid != nil { + return sid + } + + // Fall back to extracting from the metadata.user_id field in the JSON body. + // Newer format: JSON-encoded object with a "session_id" field. + // Legacy format: "user_{sha256}_account_{id}_session_{uuid}" + payload, err := io.ReadAll(r.Body) + if err != nil { + return nil + } + _ = r.Body.Close() + + // Restore the request body. + r.Body = io.NopCloser(bytes.NewReader(payload)) + userID := gjson.GetBytes(payload, "metadata.user_id") + if userID.Type != gjson.String { + return nil + } + + raw := userID.String() + + // Newer body format: user_id is a JSON-encoded object with a session_id field. + if sessionID := gjson.Get(raw, "session_id"); sessionID.Exists() { + return cleanRef(sessionID.String()) + } + + // Legacy body format: "user_{sha256}_account_{id}_session_{uuid}" + matches := claudeCodePattern.FindStringSubmatch(raw) + if len(matches) < 2 { + return nil + } + return cleanRef(matches[1]) + case ClientCodex: + return cleanRef(r.Header.Get("session_id")) + case ClientMux: + return cleanRef(r.Header.Get("X-Mux-Workspace-Id")) + case ClientZed: + return nil // Zed does not send a session ID from Zed Agent or Text Thread. + case ClientCopilotVSC: + // This does not map precisely to what we consider a session, but it's close enough. + // Most other providers' equivalent of this would persist for the duration of a + // conversation; it does seem to persist across an agentic loop though, which is + // all we really need. + // + // There's also `vscode-sessionid` but that's persistent for the duration of the + // VS Code window. + return cleanRef(r.Header.Get("x-interaction-id")) + case ClientCopilotCLI: + return cleanRef(r.Header.Get("X-Client-Session-Id")) + case ClientKilo: + return cleanRef(r.Header.Get("X-KILOCODE-TASKID")) + case ClientCoderAgents: + return cleanRef(r.Header.Get("X-Coder-Chat-Id")) + case ClientRoo: + return nil // RooCode doesn't send a session ID. + case ClientCursor: + return nil // Cursor is not currently supported. + default: + return nil + } +} + +func cleanRef(str string) *string { + str = strings.TrimSpace(str) + if str == "" { + return nil + } + + return utils.PtrTo(str) +} diff --git a/aibridge/session_test.go b/aibridge/session_test.go new file mode 100644 index 0000000000..7592dc5c54 --- /dev/null +++ b/aibridge/session_test.go @@ -0,0 +1,242 @@ +package aibridge_test + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/aibridge/utils" +) + +func TestGuessSessionID(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + client aibridge.Client + body string + headers map[string]string + sessionID *string + }{ + // Claude Code. + { + name: "claude_code_header_takes_precedence", + client: aibridge.ClientClaudeCode, + headers: map[string]string{"X-Claude-Code-Session-Id": "header-session-id"}, + body: `{"metadata":{"user_id":"user_abc123_account_456_session_body-session-id"}}`, + sessionID: utils.PtrTo("header-session-id"), + }, + { + name: "claude_code_header_only", + client: aibridge.ClientClaudeCode, + headers: map[string]string{"X-Claude-Code-Session-Id": "aabb-ccdd"}, + body: `{"model":"claude-3"}`, + sessionID: utils.PtrTo("aabb-ccdd"), + }, + { + name: "claude_code_empty_header_falls_back_to_body", + client: aibridge.ClientClaudeCode, + headers: map[string]string{"X-Claude-Code-Session-Id": ""}, + body: `{"metadata":{"user_id":"user_abc123_account_456_session_f47ac10b-58cc-4372-a567-0e02b2c3d479"}}`, + sessionID: utils.PtrTo("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + }, + { + name: "claude_code_whitespace_header_falls_back_to_body", + client: aibridge.ClientClaudeCode, + headers: map[string]string{"X-Claude-Code-Session-Id": " "}, + body: `{"metadata":{"user_id":"user_abc123_account_456_session_f47ac10b-58cc-4372-a567-0e02b2c3d479"}}`, + sessionID: utils.PtrTo("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + }, + { + name: "claude_code_with_valid_session", + client: aibridge.ClientClaudeCode, + body: `{"metadata":{"user_id":"user_abc123_account_456_session_f47ac10b-58cc-4372-a567-0e02b2c3d479"}}`, + sessionID: utils.PtrTo("f47ac10b-58cc-4372-a567-0e02b2c3d479"), + }, + { + name: "claude_code_with_valid_session_new_format", + client: aibridge.ClientClaudeCode, + body: `{"metadata":{"user_id":"{\"device_id\":\"45aa15c8c244ea2582f8144dde91a50ec3815851f6f648abef4ee15b173cc927\",\"account_uuid\":\"\",\"session_id\":\"54c1eb09-bc4c-4d2f-98eb-6d2ab2d5e2fe\"}"}}`, + sessionID: utils.PtrTo("54c1eb09-bc4c-4d2f-98eb-6d2ab2d5e2fe"), + }, + { + name: "claude_code_new_format_empty_session_id", + client: aibridge.ClientClaudeCode, + body: `{"metadata":{"user_id":"{\"device_id\":\"abc\",\"account_uuid\":\"\",\"session_id\":\"\"}"}}`, + }, + { + name: "claude_code_new_format_no_session_id_field", + client: aibridge.ClientClaudeCode, + body: `{"metadata":{"user_id":"{\"device_id\":\"abc\",\"account_uuid\":\"\"}"}}`, + }, + { + name: "claude_code_missing_metadata", + client: aibridge.ClientClaudeCode, + body: `{"model":"claude-3"}`, + }, + { + name: "claude_code_missing_user_id", + client: aibridge.ClientClaudeCode, + body: `{"metadata":{}}`, + }, + { + name: "claude_code_user_id_without_session", + client: aibridge.ClientClaudeCode, + body: `{"metadata":{"user_id":"user_abc123_account_456"}}`, + }, + { + name: "claude_code_empty_body", + client: aibridge.ClientClaudeCode, + body: ``, + }, + { + name: "claude_code_invalid_json", + client: aibridge.ClientClaudeCode, + body: `not json at all`, + }, + // Codex. + { + name: "codex_with_session_header", + client: aibridge.ClientCodex, + headers: map[string]string{"session_id": "codex-session-123"}, + sessionID: utils.PtrTo("codex-session-123"), + }, + { + name: "codex_with_whitespace_in_header", + client: aibridge.ClientCodex, + headers: map[string]string{"session_id": " codex-session-123 "}, + sessionID: utils.PtrTo("codex-session-123"), + }, + { + name: "codex_without_session_header", + client: aibridge.ClientCodex, + }, + // Other clients shouldn't use others' logic. + { + name: "unknown_client_returns_empty", + client: aibridge.ClientUnknown, + body: `{"metadata":{"user_id":"user_abc_account_456_session_some-id"}}`, + }, + { + name: "zed_returns_empty", + client: aibridge.ClientZed, + headers: map[string]string{"session_id": "zed-session"}, + body: `{"metadata":{"user_id":"user_abc_account_456_session_some-id"}}`, + }, + // Mux. + { + name: "mux_with_workspace_header", + client: aibridge.ClientMux, + headers: map[string]string{"X-Mux-Workspace-Id": "ws-abc-123"}, + sessionID: utils.PtrTo("ws-abc-123"), + }, + { + name: "mux_without_workspace_header", + client: aibridge.ClientMux, + }, + // Copilot VS Code. + { + name: "copilot_vsc_with_interaction_id", + client: aibridge.ClientCopilotVSC, + headers: map[string]string{"x-interaction-id": "interaction-xyz"}, + sessionID: utils.PtrTo("interaction-xyz"), + }, + { + name: "copilot_vsc_without_interaction_id", + client: aibridge.ClientCopilotVSC, + }, + // Copilot CLI. + { + name: "copilot_cli_with_session_header", + client: aibridge.ClientCopilotCLI, + headers: map[string]string{"X-Client-Session-Id": "cli-sess-456"}, + sessionID: utils.PtrTo("cli-sess-456"), + }, + { + name: "copilot_cli_without_session_header", + client: aibridge.ClientCopilotCLI, + }, + // Kilo. + { + name: "kilo_with_task_id", + client: aibridge.ClientKilo, + headers: map[string]string{"X-KILOCODE-TASKID": "task-789"}, + sessionID: utils.PtrTo("task-789"), + }, + { + name: "kilo_without_task_id", + client: aibridge.ClientKilo, + }, + // Coder Agents. + { + name: "coder_agents_with_chat_id", + client: aibridge.ClientCoderAgents, + headers: map[string]string{"X-Coder-Chat-Id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"}, + sessionID: utils.PtrTo("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), + }, + { + name: "coder_agents_without_chat_id", + client: aibridge.ClientCoderAgents, + }, + // Roo. + { + name: "roo_returns_empty", + client: aibridge.ClientRoo, + }, + // Cursor. + { + name: "cursor_returns_empty", + client: aibridge.ClientCursor, + }, + // Other cases. + { + name: "empty session ID value", + client: aibridge.ClientKilo, + headers: map[string]string{"X-KILOCODE-TASKID": " "}, + sessionID: nil, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + body := tc.body + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "http://localhost", strings.NewReader(body)) + require.NoError(t, err) + + for key, value := range tc.headers { + req.Header.Set(key, value) + } + + got := aibridge.GuessSessionID(tc.client, req) + require.Equal(t, tc.sessionID, got) + + // Verify the body was restored and can be read again. + restored, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Equal(t, body, string(restored)) + }) + } +} + +func TestUnreadableBody(t *testing.T) { + t.Parallel() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "http://localhost", &errReader{}) + require.NoError(t, err) + + got := aibridge.GuessSessionID(aibridge.ClientClaudeCode, req) + require.Nil(t, got) +} + +// errReader is an io.Reader that always returns an error. +type errReader struct{} + +func (*errReader) Read([]byte) (int, error) { + return 0, io.ErrUnexpectedEOF +} diff --git a/aibridge/sse_parser.go b/aibridge/sse_parser.go new file mode 100644 index 0000000000..42c1cb0eb6 --- /dev/null +++ b/aibridge/sse_parser.go @@ -0,0 +1,124 @@ +package aibridge + +import ( + "bufio" + "io" + "strconv" + "strings" + "sync" +) + +const ( + SSEEventTypeMessage = "message" + SSEEventTypeError = "error" + SSEEventTypePing = "ping" +) + +type SSEEvent struct { + Type string + Data string + ID string + Retry int +} + +type SSEParser struct { + events map[string][]SSEEvent + mu sync.RWMutex +} + +func NewSSEParser() *SSEParser { + return &SSEParser{ + events: make(map[string][]SSEEvent), + } +} + +func (p *SSEParser) Parse(reader io.Reader) error { + scanner := bufio.NewScanner(reader) + + var currentEvent SSEEvent + var dataLines []string + + for scanner.Scan() { + line := scanner.Text() + + // Empty line indicates end of event + if line == "" { + if len(dataLines) > 0 { + currentEvent.Data = strings.Join(dataLines, "\n") + } + + // Default to message type if no event type specified + if currentEvent.Type == "" { + currentEvent.Type = SSEEventTypeMessage + } + + // Store the event + p.mu.Lock() + p.events[currentEvent.Type] = append(p.events[currentEvent.Type], currentEvent) + p.mu.Unlock() + + // Reset for next event + currentEvent = SSEEvent{} + dataLines = nil + continue + } + + // Skip comments + if strings.HasPrefix(line, ":") { + continue + } + + // Parse field:value format + if colonIndex := strings.Index(line, ":"); colonIndex != -1 { + field := line[:colonIndex] + value := line[colonIndex+1:] + + // Remove leading space from value if present + if len(value) > 0 && value[0] == ' ' { + value = value[1:] + } + + switch field { + case "event": + currentEvent.Type = value + case "data": + dataLines = append(dataLines, value) + case "id": + currentEvent.ID = value + case "retry": + if retryMs, err := strconv.Atoi(value); err == nil { + currentEvent.Retry = retryMs + } + } + } + } + + return scanner.Err() +} + +func (p *SSEParser) EventsByType(eventType string) []SSEEvent { + p.mu.RLock() + defer p.mu.RUnlock() + + events := p.events[eventType] + result := make([]SSEEvent, len(events)) + copy(result, events) + return result +} + +func (p *SSEParser) MessageEvents() []SSEEvent { + return p.EventsByType(SSEEventTypeMessage) +} + +func (p *SSEParser) AllEvents() map[string][]SSEEvent { + p.mu.RLock() + defer p.mu.RUnlock() + + result := make(map[string][]SSEEvent) + for eventType, events := range p.events { + eventsCopy := make([]SSEEvent, len(events)) + copy(eventsCopy, events) + result[eventType] = eventsCopy + } + return result +} diff --git a/aibridge/tracing/tracing.go b/aibridge/tracing/tracing.go new file mode 100644 index 0000000000..7adaf3f65e --- /dev/null +++ b/aibridge/tracing/tracing.go @@ -0,0 +1,87 @@ +package tracing + +import ( + "context" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +type ( + traceInterceptionAttrsContextKey struct{} + traceRequestBridgeAttrsContextKey struct{} +) + +const ( + // trace attribute key constants + RequestPath = "request_path" + + InterceptionID = "interception_id" + InitiatorID = "user_id" + Provider = "provider" + Model = "model" + Streaming = "streaming" + IsBedrock = "aws_bedrock" + + PassthroughURL = "passthrough_url" + PassthroughUpstreamURL = "passthrough_upstream_url" + PassthroughMethod = "passthrough_method" + + MCPInput = "mcp_input" + MCPProxyName = "mcp_proxy_name" + MCPToolName = "mcp_tool_name" + MCPServerName = "mcp_server_name" + MCPServerURL = "mcp_server_url" + MCPToolCount = "mcp_tool_count" + + APIKeyID = "api_key_id" +) + +// EndSpanErr ends given span and sets Error status if error is not nil +// uses pointer to error because defer evaluates function arguments +// when defer statement is executed not when deferred function is called +// +// example usage: +// +// func Example() (result any, outErr error) { +// _, span := tracer.Start(...) +// defer tracing.EndSpanErr(span, &outErr) +// +// } +func EndSpanErr(span trace.Span, err *error) { + if span == nil { + return + } + + if err != nil && *err != nil { + span.SetStatus(codes.Error, (*err).Error()) + } + span.End() +} + +func WithInterceptionAttributesInContext(ctx context.Context, traceAttrs []attribute.KeyValue) context.Context { + return context.WithValue(ctx, traceInterceptionAttrsContextKey{}, traceAttrs) +} + +func InterceptionAttributesFromContext(ctx context.Context) []attribute.KeyValue { + attrs, ok := ctx.Value(traceInterceptionAttrsContextKey{}).([]attribute.KeyValue) + if !ok { + return nil + } + + return attrs +} + +func WithRequestBridgeAttributesInContext(ctx context.Context, traceAttrs []attribute.KeyValue) context.Context { + return context.WithValue(ctx, traceRequestBridgeAttrsContextKey{}, traceAttrs) +} + +func RequestBridgeAttributesFromContext(ctx context.Context) []attribute.KeyValue { + attrs, ok := ctx.Value(traceRequestBridgeAttrsContextKey{}).([]attribute.KeyValue) + if !ok { + return nil + } + + return attrs +} diff --git a/aibridge/utils/auth.go b/aibridge/utils/auth.go new file mode 100644 index 0000000000..acc5849bc4 --- /dev/null +++ b/aibridge/utils/auth.go @@ -0,0 +1,14 @@ +package utils + +import "strings" + +// ExtractBearerToken extracts the token from a "Bearer " authorization header. +func ExtractBearerToken(auth string) string { + if auth := strings.TrimSpace(auth); auth != "" { + fields := strings.Fields(auth) + if len(fields) == 2 && strings.EqualFold(fields[0], "Bearer") { + return fields[1] + } + } + return "" +} diff --git a/aibridge/utils/auth_test.go b/aibridge/utils/auth_test.go new file mode 100644 index 0000000000..00ee9a264f --- /dev/null +++ b/aibridge/utils/auth_test.go @@ -0,0 +1,74 @@ +package utils_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/aibridge/utils" +) + +func TestExtractBearerToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + { + name: "Empty", + input: "", + expected: "", + }, + { + name: "Whitespace", + input: " ", + expected: "", + }, + { + name: "InvalidFormat", + input: "some-token", + expected: "", + }, + { + name: "BearerOnly", + input: "Bearer", + expected: "", + }, + { + name: "Valid", + input: "Bearer my-secret-token", + expected: "my-secret-token", + }, + { + name: "BearerMixedCase", + input: "BeArEr my-secret-token", + expected: "my-secret-token", + }, + { + name: "LeadingWhitespace", + input: " Bearer my-secret-token", + expected: "my-secret-token", + }, + { + name: "TrailingWhitespace", + input: "Bearer my-secret-token ", + expected: "my-secret-token", + }, + { + name: "TooManyParts", + input: "Bearer token extra", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result := utils.ExtractBearerToken(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/aibridge/utils/concurrent_group.go b/aibridge/utils/concurrent_group.go new file mode 100644 index 0000000000..5fba68928f --- /dev/null +++ b/aibridge/utils/concurrent_group.go @@ -0,0 +1,38 @@ +package utils + +import ( + "sync" + + "github.com/hashicorp/go-multierror" +) + +// ConcurrentGroup is like errgroup.Group but differs in that an error in one +// goroutine will not interrupt the functioning of another. +// See https://pkg.go.dev/golang.org/x/sync/errgroup#Group.Go. +type ConcurrentGroup struct { + wg sync.WaitGroup + + errsMu sync.Mutex + errs error +} + +func NewConcurrentGroup() *ConcurrentGroup { + return &ConcurrentGroup{} +} + +func (c *ConcurrentGroup) Go(fn func() error) { + c.wg.Add(1) + go func() { + defer c.wg.Done() + if err := fn(); err != nil { + c.errsMu.Lock() + c.errs = multierror.Append(c.errs, err) + c.errsMu.Unlock() + } + }() +} + +func (c *ConcurrentGroup) Wait() error { + c.wg.Wait() + return c.errs +} diff --git a/aibridge/utils/concurrent_group_test.go b/aibridge/utils/concurrent_group_test.go new file mode 100644 index 0000000000..22b0cb93d7 --- /dev/null +++ b/aibridge/utils/concurrent_group_test.go @@ -0,0 +1,81 @@ +package utils_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/aibridge/utils" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +func TestConcurrentGroup(t *testing.T) { + t.Parallel() + + t.Run("no goroutines", func(t *testing.T) { + t.Parallel() + + cg := utils.NewConcurrentGroup() + require.NoError(t, cg.Wait()) + }) + + t.Run("multiple goroutines, all ok", func(t *testing.T) { + t.Parallel() + + cg := utils.NewConcurrentGroup() + cg.Go(func() error { + return nil + }) + cg.Go(func() error { + return nil + }) + require.NoError(t, cg.Wait()) + }) + + t.Run("multiple goroutines, one err", func(t *testing.T) { + t.Parallel() + + cg := utils.NewConcurrentGroup() + oops := xerrors.New("oops") + cg.Go(func() error { + return oops + }) + cg.Go(func() error { + return nil + }) + require.ErrorIs(t, cg.Wait(), oops) + }) + + t.Run("multiple goroutines, multiple errs", func(t *testing.T) { + t.Parallel() + + cg := utils.NewConcurrentGroup() + oops := xerrors.New("oops") + eek := xerrors.New("eek") + cg.Go(func() error { + return oops + }) + cg.Go(func() error { + return eek + }) + + errs := cg.Wait() + require.ErrorIs(t, errs, oops) + require.ErrorIs(t, errs, eek) + }) +} + +func BenchmarkConcurrentGroup(b *testing.B) { + for i := 0; i < b.N; i++ { + cg := utils.NewConcurrentGroup() + for j := 0; j < 10; j++ { + cg.Go(func() error { return nil }) + } + _ = cg.Wait() + } +} diff --git a/aibridge/utils/mask.go b/aibridge/utils/mask.go new file mode 100644 index 0000000000..dc36af2295 --- /dev/null +++ b/aibridge/utils/mask.go @@ -0,0 +1,35 @@ +package utils + +// MaskSecret masks the middle of a secret string, revealing a small +// prefix and suffix for identification. The number of characters +// revealed scales with string length. +func MaskSecret(s string) string { + if s == "" { + return "" + } + + runes := []rune(s) + reveal := revealLength(len(runes)) + + if len(runes) <= reveal*2 { + return "..." + } + + prefix := string(runes[:reveal]) + suffix := string(runes[len(runes)-reveal:]) + return prefix + "..." + suffix +} + +// revealLength returns the number of runes to show at each end. +func revealLength(n int) int { + switch { + case n >= 20: + return 4 + case n >= 10: + return 2 + case n >= 5: + return 1 + default: + return 0 + } +} diff --git a/aibridge/utils/mask_test.go b/aibridge/utils/mask_test.go new file mode 100644 index 0000000000..7c0333515b --- /dev/null +++ b/aibridge/utils/mask_test.go @@ -0,0 +1,37 @@ +package utils_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/coder/coder/v2/aibridge/utils" +) + +func TestMaskSecret(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + {"empty", "", ""}, + {"single_char", "x", "..."}, + {"two_chars", "ab", "..."}, + {"four_chars", "abcd", "..."}, + {"short", "short", "s...t"}, + {"short_9_chars", "veryshort", "v...t"}, + {"medium_15_chars", "thisisquitelong", "th...ng"}, + {"long_api_key", "sk-ant-api03-abcdefgh", "sk-a...efgh"}, + {"unicode", "hélloworld🌍!", "hé...🌍!"}, + {"github_token", "ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefgh", "ghp_...efgh"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.expected, utils.MaskSecret(tc.input)) + }) + } +} diff --git a/aibridge/utils/ptr.go b/aibridge/utils/ptr.go new file mode 100644 index 0000000000..956178b947 --- /dev/null +++ b/aibridge/utils/ptr.go @@ -0,0 +1,6 @@ +package utils + +// PtrTo returns a reference to v. +func PtrTo[T any](v T) *T { + return &v +} diff --git a/cli/server.go b/cli/server.go index 7054bd42cb..19c4c841ab 100644 --- a/cli/server.go +++ b/cli/server.go @@ -56,7 +56,7 @@ import ( "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/sloghuman" - "github.com/coder/aibridge" + "github.com/coder/coder/v2/aibridge" "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/cli/clilog" "github.com/coder/coder/v2/cli/cliui" diff --git a/cli/server_aibridge_internal_test.go b/cli/server_aibridge_internal_test.go index bfa3d2d765..d0833070ef 100644 --- a/cli/server_aibridge_internal_test.go +++ b/cli/server_aibridge_internal_test.go @@ -9,7 +9,7 @@ import ( "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" - "github.com/coder/aibridge" + "github.com/coder/coder/v2/aibridge" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) diff --git a/enterprise/aibridged/aibridged_integration_test.go b/enterprise/aibridged/aibridged_integration_test.go index a9e423938a..23579c4530 100644 --- a/enterprise/aibridged/aibridged_integration_test.go +++ b/enterprise/aibridged/aibridged_integration_test.go @@ -19,9 +19,9 @@ import ( sdktrace "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" - "github.com/coder/aibridge" - "github.com/coder/aibridge/config" - aibtracing "github.com/coder/aibridge/tracing" + "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/aibridge/config" + aibtracing "github.com/coder/coder/v2/aibridge/tracing" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtestutil" diff --git a/enterprise/aibridged/aibridged_test.go b/enterprise/aibridged/aibridged_test.go index b44c42551d..b640de415d 100644 --- a/enterprise/aibridged/aibridged_test.go +++ b/enterprise/aibridged/aibridged_test.go @@ -16,8 +16,8 @@ import ( "storj.io/drpc" "cdr.dev/slog/v3/sloggers/slogtest" - "github.com/coder/aibridge" - "github.com/coder/aibridge/intercept" + "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/aibridge/intercept" agplaibridge "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/aibridged" diff --git a/enterprise/aibridged/http.go b/enterprise/aibridged/http.go index 701396040a..3856e7ad75 100644 --- a/enterprise/aibridged/http.go +++ b/enterprise/aibridged/http.go @@ -8,8 +8,8 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog/v3" - "github.com/coder/aibridge" - "github.com/coder/aibridge/recorder" + "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/aibridge/recorder" agplaibridge "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/enterprise/aibridged/proto" ) diff --git a/enterprise/aibridged/mcp.go b/enterprise/aibridged/mcp.go index 800149f727..8b9f068102 100644 --- a/enterprise/aibridged/mcp.go +++ b/enterprise/aibridged/mcp.go @@ -10,7 +10,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog/v3" - "github.com/coder/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/mcp" "github.com/coder/coder/v2/enterprise/aibridged/proto" ) diff --git a/enterprise/aibridged/pool.go b/enterprise/aibridged/pool.go index 978eeffd77..0468acb582 100644 --- a/enterprise/aibridged/pool.go +++ b/enterprise/aibridged/pool.go @@ -13,9 +13,9 @@ import ( "tailscale.com/util/singleflight" "cdr.dev/slog/v3" - "github.com/coder/aibridge" - "github.com/coder/aibridge/mcp" - "github.com/coder/aibridge/tracing" + "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/tracing" ) const ( diff --git a/enterprise/aibridged/pool_test.go b/enterprise/aibridged/pool_test.go index 10ff0667fb..78d92a9349 100644 --- a/enterprise/aibridged/pool_test.go +++ b/enterprise/aibridged/pool_test.go @@ -12,8 +12,8 @@ import ( "go.uber.org/mock/gomock" "cdr.dev/slog/v3/sloggers/slogtest" - "github.com/coder/aibridge/mcp" - "github.com/coder/aibridge/mcpmock" + "github.com/coder/coder/v2/aibridge/mcp" + "github.com/coder/coder/v2/aibridge/mcpmock" "github.com/coder/coder/v2/enterprise/aibridged" mock "github.com/coder/coder/v2/enterprise/aibridged/aibridgedmock" ) diff --git a/enterprise/aibridged/translator.go b/enterprise/aibridged/translator.go index d55d478a8c..27786ec95b 100644 --- a/enterprise/aibridged/translator.go +++ b/enterprise/aibridged/translator.go @@ -10,7 +10,7 @@ import ( "google.golang.org/protobuf/types/known/structpb" "google.golang.org/protobuf/types/known/timestamppb" - "github.com/coder/aibridge" + "github.com/coder/coder/v2/aibridge" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/enterprise/aibridged/proto" ) diff --git a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go index 0f966f3ff0..516912df62 100644 --- a/enterprise/aibridgeproxyd/aibridgeproxyd_test.go +++ b/enterprise/aibridgeproxyd/aibridgeproxyd_test.go @@ -31,7 +31,7 @@ import ( "cdr.dev/slog/v3" "cdr.dev/slog/v3/sloggers/slogtest" - "github.com/coder/aibridge" + "github.com/coder/coder/v2/aibridge" agplaibridge "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/enterprise/aibridgeproxyd" "github.com/coder/coder/v2/testutil" diff --git a/enterprise/cli/aibridged.go b/enterprise/cli/aibridged.go index 163b3036a8..4c2d1d0687 100644 --- a/enterprise/cli/aibridged.go +++ b/enterprise/cli/aibridged.go @@ -8,8 +8,8 @@ import ( "github.com/prometheus/client_golang/prometheus" "golang.org/x/xerrors" - "github.com/coder/aibridge" - "github.com/coder/aibridge/config" + "github.com/coder/coder/v2/aibridge" + "github.com/coder/coder/v2/aibridge/config" "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/aibridged" diff --git a/enterprise/cli/aibridged_internal_test.go b/enterprise/cli/aibridged_internal_test.go index ec1f2e64fd..4f446b6616 100644 --- a/enterprise/cli/aibridged_internal_test.go +++ b/enterprise/cli/aibridged_internal_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/coder/aibridge" + "github.com/coder/coder/v2/aibridge" agplaibridge "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/codersdk" "github.com/coder/serpent" diff --git a/enterprise/cli/aibridgeproxyd.go b/enterprise/cli/aibridgeproxyd.go index fb96e541a9..7f26a68b09 100644 --- a/enterprise/cli/aibridgeproxyd.go +++ b/enterprise/cli/aibridgeproxyd.go @@ -10,7 +10,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "golang.org/x/xerrors" - "github.com/coder/aibridge" + "github.com/coder/coder/v2/aibridge" "github.com/coder/coder/v2/enterprise/aibridgeproxyd" "github.com/coder/coder/v2/enterprise/coderd" ) diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index b06bc361a4..8d52c1d642 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -11,7 +11,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - aiblib "github.com/coder/aibridge" + aiblib "github.com/coder/coder/v2/aibridge" agplaibridge "github.com/coder/coder/v2/coderd/aibridge" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" diff --git a/go.mod b/go.mod index c31cff7535..a00d567c9e 100644 --- a/go.mod +++ b/go.mod @@ -281,7 +281,7 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2 v1.41.5 github.com/aws/aws-sdk-go-v2/config v1.32.12 - github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.12 github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.14 github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect @@ -433,7 +433,7 @@ require ( github.com/tcnksm/go-httpstat v0.2.0 // indirect github.com/tdewolff/parse/v2 v2.8.11 // indirect github.com/tidwall/match v1.2.0 // indirect - github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/pretty v1.2.1 github.com/tinylib/msgp v1.2.5 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect @@ -499,7 +499,6 @@ require ( github.com/aymanbagabas/go-udiff v0.4.1 github.com/brianvoe/gofakeit/v7 v7.14.0 github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 - github.com/coder/aibridge v1.1.3-0.20260420072253-d474d6813420 github.com/coder/aisdk-go v0.0.9 github.com/coder/boundary v0.8.4-0.20260304164748-566aeea939ab github.com/coder/preview v1.0.8 @@ -510,7 +509,10 @@ require ( github.com/go-git/go-git/v5 v5.18.0 github.com/invopop/jsonschema v0.13.0 github.com/mark3labs/mcp-go v0.38.0 + github.com/openai/openai-go/v3 v3.28.0 github.com/shopspring/decimal v1.4.0 + github.com/sony/gobreaker/v2 v2.3.0 + github.com/tidwall/sjson v1.2.5 gonum.org/v1/gonum v0.17.0 ) @@ -605,7 +607,6 @@ require ( github.com/moby/sys/user v0.4.0 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/openai/openai-go v1.12.0 // indirect - github.com/openai/openai-go/v3 v3.28.0 // indirect github.com/package-url/packageurl-go v0.1.3 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect @@ -615,10 +616,8 @@ require ( github.com/segmentio/asm v1.2.1 // indirect github.com/sergeymakinen/go-bmp v1.0.0 // indirect github.com/sergeymakinen/go-ico v1.0.0-beta.0 // indirect - github.com/sony/gobreaker/v2 v2.3.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect - github.com/tidwall/sjson v1.2.5 // indirect github.com/tmaxmax/go-sse v0.11.0 // indirect github.com/ulikunitz/xz v0.5.15 // indirect github.com/urfave/cli/v2 v2.27.5 // indirect diff --git a/go.sum b/go.sum index c56c47ca2b..21c919e00b 100644 --- a/go.sum +++ b/go.sum @@ -312,8 +312,6 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8= github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4= -github.com/coder/aibridge v1.1.3-0.20260420072253-d474d6813420 h1:vSgHLbHvty3Y9DXAKZimP/GXKI23o52PlTC+EJYh6tM= -github.com/coder/aibridge v1.1.3-0.20260420072253-d474d6813420/go.mod h1:k2/JzBF/jW2h4xKYyXXy9gbD7qaf0kKvfXxwA2JCYxE= github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo= github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M= github.com/coder/anthropic-sdk-go v0.0.0-20260415160422-a31d7d0e7067 h1:v1RAkUO21u0QH6UlUueSHMbgFf++BZZW41Rj6LM2eWo= diff --git a/scripts/check_emdash.sh b/scripts/check_emdash.sh index c41bf34111..4001ac2408 100755 --- a/scripts/check_emdash.sh +++ b/scripts/check_emdash.sh @@ -20,9 +20,14 @@ emdash=$'\xE2\x80\x94' endash=$'\xE2\x80\x93' pattern="${emdash}|${endash}" +# Git exclude_pathspecs excluded from the check. Used in both ls-files and diff comparison. +exclude_pathspecs=( + ":(exclude)aibridge/fixtures/**/*.txtar" +) + scan_all_files() { local output - output=$(git ls-files -z | xargs -0 grep -IEn "$pattern" 2>/dev/null || true) + output=$(git ls-files -z -- "${exclude_pathspecs[@]}" | xargs -0 grep -IEn "$pattern" 2>/dev/null || true) if [[ -n "$output" ]]; then echo "$output" found=1 @@ -58,7 +63,7 @@ else fi found=0 - if ! diff_output=$(git diff "$base" -U0 -- . 2>&1); then + if ! diff_output=$(git diff "$base" -U0 -- . "${exclude_pathspecs[@]}" 2>&1); then echo "ERROR: git diff against $base failed:" echo "$diff_output" exit 1