Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,46 @@ prism usage
Each provider is fetched independently, so an unavailable login does not hide
usage from the other providers.

## Non-interactive API calls

Use `prism exec` when a shell pipeline or CI job needs one request through
Prism. The API type is explicit and the JSON request can come from stdin or a
file:

```sh
cat request.json | prism exec --api chat --model gpt-4.1 --provider copilot
prism exec --api responses --body request.json --output-format text
```

The supported API mappings are:

| `--api` | Endpoint |
| --- | --- |
| `chat` | `/v1/chat/completions` |
| `completions` | `/v1/completions` |
| `responses` | `/v1/responses` |
| `messages` | `/v1/messages` |

Output is JSON by default. Use `--output-format text` for textual assistant
output or `--output-format stream-json` for one JSON object per SSE event.
`--json` is shorthand for the default JSON mode. `--model` overrides a model in
the request body, and `--provider` is sent as Prism's routing hint.

For headless CI, provide the normal Circles credential through the environment
and keep it scoped to the command. For example, with a cvlt-backed reference:

```dotenv
CIRCLES_AUTH_TOKEN=vlt://github.com/example-org/example-repo/PRISM_CI_CIRCLES_KEY
```

```sh
cvlt run --env-file=.cvlt.env -- prism exec --api responses --output-format json < request.json
```

The CLI never accepts credentials as command-line arguments or reads GitHub
OIDC variables directly. See `prism exec --help` for all options and the
documented exit-code classes.

## Cursor

Install the official Cursor Agent without replacing an existing
Expand Down
66 changes: 66 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,69 @@ func TestCredentialLifecycleUsesPrismAPIWithoutLeakingSecretsInURL(t *testing.T)
t.Fatalf("usage = %#v, err = %v", usage, err)
}
}

func TestInferenceMapsAPIPathAndProviderHeader(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/v1/messages" {
t.Errorf("path = %q", request.URL.Path)
}
if request.Header.Get("Authorization") != "Bearer test-token" {
t.Errorf("authorization = %q", request.Header.Get("Authorization"))
}
if request.Header.Get("X-Prism-Provider") != "anthropic" {
t.Errorf("provider = %q", request.Header.Get("X-Prism-Provider"))
}
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write([]byte(`{"content":[{"type":"text","text":"ok"}]}`))
}))
defer server.Close()

client := Client{BaseURL: server.URL, Token: "test-token", HTTPClient: server.Client()}
response, err := client.Inference(context.Background(), InferenceRequest{
API: "messages", Provider: "anthropic", Body: []byte(`{"model":"claude-test"}`),
})
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("status = %d", response.StatusCode)
}
}

func TestInferencePathMapsEverySupportedAPI(t *testing.T) {
for apiName, want := range map[string]string{
"chat": "/v1/chat/completions",
"completions": "/v1/completions",
"responses": "/v1/responses",
"messages": "/v1/messages",
} {
if got, ok := InferencePath(apiName); !ok || got != want {
t.Fatalf("InferencePath(%q) = %q, %v; want %q", apiName, got, ok, want)
}
}
if _, ok := InferencePath("unknown"); ok {
t.Fatal("unknown API was accepted")
}
}

func TestInferenceRejectsNonHTTPSAndClearsLegacyTotalTimeout(t *testing.T) {
client := Client{BaseURL: "http://example.com", Token: "test-token"}
if _, err := client.Inference(context.Background(), InferenceRequest{API: "chat", Body: []byte(`{}`)}); err == nil {
t.Fatal("HTTP endpoint was accepted")
}

server := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
response.Header().Set("Content-Type", "application/json")
_, _ = response.Write([]byte(`{}`))
}))
defer server.Close()
legacy := *server.Client()
legacy.Timeout = 1
client = Client{BaseURL: server.URL, Token: "test-token", HTTPClient: &legacy}
response, err := client.Inference(context.Background(), InferenceRequest{API: "chat", Body: []byte(`{}`)})
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
}
100 changes: 100 additions & 0 deletions internal/api/inference.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package api

import (
"bytes"
"context"
"errors"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)

// InferenceRequest describes one raw Prism inference request. The caller owns
// response-body parsing because the four Prism APIs have different event and
// text shapes.
type InferenceRequest struct {
API string
Provider string
Body []byte
}

const (
InferenceConnectTimeout = 10 * time.Second
InferenceFirstByteTimeout = 30 * time.Second
InferenceIdleTimeout = 2 * time.Minute
)

var inferencePaths = map[string]string{
"chat": "/v1/chat/completions",
"completions": "/v1/completions",
"responses": "/v1/responses",
"messages": "/v1/messages",
}

func InferencePath(apiName string) (string, bool) {
path, ok := inferencePaths[apiName]
return path, ok
}

// Inference sends a request without a total timeout. The default transport
// limits connection establishment and response headers; stream readers must
// enforce the idle timeout while consuming the body.
func (c Client) Inference(ctx context.Context, request InferenceRequest) (*http.Response, error) {
path, ok := InferencePath(request.API)
if !ok {
return nil, errors.New("unsupported Prism API")
}
endpoint, err := url.Parse(c.BaseURL)
if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" {
return nil, errors.New("Prism URL is invalid")
}
if strings.TrimSpace(c.Token) == "" || strings.ContainsAny(c.Token, " \t\r\n") {
return nil, errors.New("Circles credential is invalid")
}
requestURL := strings.TrimSuffix(c.BaseURL, "/") + path
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewReader(request.Body))
if err != nil {
return nil, errors.New("could not create the Prism request")
}
httpRequest.Header.Set("Authorization", "Bearer "+c.Token)
httpRequest.Header.Set("Content-Type", "application/json")
if request.Provider != "" {
httpRequest.Header.Set("X-Prism-Provider", request.Provider)
}

client := c.HTTPClient
if client == nil {
client = &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: InferenceConnectTimeout, KeepAlive: 30 * time.Second}).DialContext,
TLSHandshakeTimeout: InferenceConnectTimeout,
ResponseHeaderTimeout: InferenceFirstByteTimeout,
IdleConnTimeout: 90 * time.Second,
},
}
} else {
// A caller-provided client may have a legacy total timeout. Inference
// streams are allowed to run longer, so clear that one setting while
// preserving the caller's transport, jar, and redirect policy.
copy := *client
copy.Timeout = 0
client = &copy
}
response, err := client.Do(httpRequest)
if err != nil {
return nil, err
}
return response, nil
}

func CloseResponse(response *http.Response) {
if response == nil || response.Body == nil {
return
}
_, _ = io.Copy(io.Discard, response.Body)
_ = response.Body.Close()
}
Loading
Loading