From 05a4330e21b858b7e20456e66cd7c7be91115aff Mon Sep 17 00:00:00 2001 From: beilsteinernest215-cmd Date: Wed, 28 Oct 2026 07:28:19 +0000 Subject: [PATCH] feat(cli): add the OrcaRouter Cyber Inference model provider Signed-off-by: beilsteinernest215-cmd --- app/cli/README.md | 16 + app/cli/cmd/orcarouter.go | 374 ++++++++++++++++++++ app/cli/cmd/orcarouter_test.go | 96 ++++++ app/cli/cmd/root.go | 2 +- app/cli/documentation/cli-reference.md | 312 +++++++++++++++++ app/cli/internal/trace/README.md | 2 + app/cli/internal/trace/e2e/codex.go | 20 +- app/cli/internal/trace/e2e/codex_test.go | 28 ++ internal/orcarouter/catalog.go | 365 ++++++++++++++++++++ internal/orcarouter/catalog_test.go | 212 ++++++++++++ internal/orcarouter/credential.go | 96 ++++++ internal/orcarouter/credential_test.go | 107 ++++++ internal/orcarouter/inference.go | 158 +++++++++ internal/orcarouter/inference_test.go | 114 ++++++ internal/orcarouter/live_test.go | 98 ++++++ internal/orcarouter/manager.go | 193 +++++++++++ internal/orcarouter/manager_test.go | 195 +++++++++++ internal/orcarouter/pkce.go | 422 +++++++++++++++++++++++ internal/orcarouter/pkce_test.go | 411 ++++++++++++++++++++++ internal/orcarouter/provider.go | 106 ++++++ 20 files changed, 3325 insertions(+), 2 deletions(-) create mode 100644 app/cli/cmd/orcarouter.go create mode 100644 app/cli/cmd/orcarouter_test.go create mode 100644 internal/orcarouter/catalog.go create mode 100644 internal/orcarouter/catalog_test.go create mode 100644 internal/orcarouter/credential.go create mode 100644 internal/orcarouter/credential_test.go create mode 100644 internal/orcarouter/inference.go create mode 100644 internal/orcarouter/inference_test.go create mode 100644 internal/orcarouter/live_test.go create mode 100644 internal/orcarouter/manager.go create mode 100644 internal/orcarouter/manager_test.go create mode 100644 internal/orcarouter/pkce.go create mode 100644 internal/orcarouter/pkce_test.go create mode 100644 internal/orcarouter/provider.go diff --git a/app/cli/README.md b/app/cli/README.md index e89f6b777..035f52e16 100644 --- a/app/cli/README.md +++ b/app/cli/README.md @@ -15,6 +15,22 @@ b) [Attestation Crafting Process](https://docs.chainloop.dev/getting-started/att The project is a [Go](https://go.dev/) CLI that leverages [Cobra](https://github.com/spf13/cobra) for CLI scaffolding, [Viper](https://github.com/spf13/viper) for configuration handling, [gRPC](https://grpc.io/) to communicate with both the control plane and the Artifact CAS APIs, and the [cosign](https://github.com/sigstore/cosign), [in-toto](https://github.com/in-toto/in-toto), [DSEE](https://github.com/secure-systems-lab/dsse/) and [SLSA](https://github.com/slsa-framework/slsa) projects for the attestation process. +## OrcaRouter Cyber Inference + +`chainloop orcarouter` configures [OrcaRouter](https://www.orcarouter.ai), an OpenAI-compatible inference gateway for security teams, so `chainloop trace run --codex` and the Trace end-to-end suite can route a Codex session through `https://api.orcarouter.ai/v1` with `model_providers.orcarouter`. The provider is `OrcaRouter Cyber Inference`: cyber models for an authorized red team, gateway controls for a blue team. + +Two credential paths end at the same durable `sk-orca-…` API key, and either works alone: + +``` +chainloop orcarouter login # paste an existing key, or read $ORCAROUTER_API_KEY +chainloop orcarouter connect # OAuth 2.0 + PKCE, loopback redirect +chainloop orcarouter status # masked credential and its generation +chainloop orcarouter models # live GET /v1/models, filtered per capability +chainloop orcarouter logout # remove the stored key +``` + +The key is stored in the CLI configuration file, next to `auth.token`, and is never printed. `connect` opens `https://www.orcarouter.ai/auth` and receives the code on `127.0.0.1`; the exchange posts to `https://www.orcarouter.ai/api/v1/auth/keys`, never to the inference origin. See the [provider page](https://www.orcarouter.ai/cyber) and the [cyber model catalog](https://www.orcarouter.ai/models?tier=cyber). + ## Runbook We use `make` for most development tasks. Run `make -C app/cli` to see a list of the available tasks. diff --git a/app/cli/cmd/orcarouter.go b/app/cli/cmd/orcarouter.go new file mode 100644 index 000000000..dd7bdf1f3 --- /dev/null +++ b/app/cli/cmd/orcarouter.go @@ -0,0 +1,374 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/chainloop-dev/chainloop/internal/orcarouter" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// Configuration keys of the OrcaRouter credential. It lives next to the +// existing CLI settings, in the same file the other credentials use, so no +// second secret store is introduced. +const ( + // viperKeyOrcaKey is the stored API key. + viperKeyOrcaKey = "orcarouter.api-key" + // viperKeyOrcaSource records which adapter produced the stored key. + viperKeyOrcaSource = "orcarouter.credential-source" +) + +// newOrcaRouterCmd creates the `orcarouter` command group. Both credential +// paths are discoverable subcommands: `login` pastes an existing key, `connect` +// runs the OAuth 2.0 + PKCE flow. They end at the same stored credential. +func newOrcaRouterCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: orcarouter.ID, + Short: "Configure " + orcarouter.Name, + Long: orcarouter.Name + ` is an OpenAI-compatible inference gateway for security teams. + +Cyber models for your red team, gateway controls for your blue team: it admits +authorized, scoped security inference that general gateways often refuse or +silently downgrade. See ` + orcarouter.DocsURL + ` and the cyber model catalog at +` + orcarouter.ModelsURL + `. + +Two credential paths end at the same durable API key, and either one works on +its own: + + chainloop orcarouter login paste an existing sk-orca-... key + chainloop orcarouter connect authorize in a browser with OAuth 2.0 + PKCE + +Authentication uses ` + orcarouter.AuthBaseURL + `; inference and model discovery use +` + orcarouter.BaseURL + `.`, + Annotations: map[string]string{skipActionOptsInit: trueString}, + } + + cmd.AddCommand( + newOrcaRouterLoginCmd(), + newOrcaRouterConnectCmd(), + newOrcaRouterStatusCmd(), + newOrcaRouterLogoutCmd(), + newOrcaRouterModelsCmd(), + newOrcaRouterChatCmd(), + ) + + return cmd +} + +// orcaRouterManager builds the credential manager over the CLI configuration +// file. +func orcaRouterManager() (*orcarouter.Manager, error) { + return orcarouter.NewManager(viperStore{}) +} + +// viperStore persists the credential in the CLI configuration file, the store +// the other credentials already use. +type viperStore struct{} + +// Load implements orcarouter.Store. +func (viperStore) Load() (string, orcarouter.Source, error) { + key := strings.TrimSpace(viper.GetString(viperKeyOrcaKey)) + source := orcarouter.Source(viper.GetString(viperKeyOrcaSource)) + return key, source, nil +} + +// Save implements orcarouter.Store. +func (viperStore) Save(key string, source orcarouter.Source) error { + viper.Set(viperKeyOrcaKey, key) + viper.Set(viperKeyOrcaSource, string(source)) + return viper.WriteConfig() +} + +// Clear implements orcarouter.Store. The key is overwritten with an empty +// value and the source is dropped, so no credential remains in the file. +func (viperStore) Clear() error { + viper.Set(viperKeyOrcaKey, "") + viper.Set(viperKeyOrcaSource, "") + return viper.WriteConfig() +} + +// newOrcaRouterLoginCmd pastes an existing API key. +func newOrcaRouterLoginCmd() *cobra.Command { + var key string + cmd := &cobra.Command{ + Use: "login", + Short: "Store an existing " + orcarouter.Name + " API key", + Long: `Store an existing ` + orcarouter.Name + ` API key. + +Create or copy a key at ` + orcarouter.DocsURL + `. The key is checked for its +documented shape only: the first request is what proves it works. It is written +to the CLI configuration file and is never printed. +`, + Annotations: map[string]string{skipActionOptsInit: trueString}, + RunE: func(cmd *cobra.Command, _ []string) error { + secret := key + if secret == "" { + secret = strings.TrimSpace(os.Getenv(orcarouter.EnvKey)) + } + if secret == "" { + fmt.Fprintf(cmd.OutOrStdout(), "Paste the %s API key: ", orcarouter.Name) + entered, err := readPasswordFromTerminal() + if err != nil { + return fmt.Errorf("read the API key: %w", err) + } + secret = strings.TrimSpace(string(entered)) + } + + manager, err := orcaRouterManager() + if err != nil { + return err + } + cred, err := manager.SetAPIKey(secret) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Stored %s credential %s (source: %s)\n", orcarouter.Name, cred.Redacted().Key, cred.Source) + return nil + }, + } + cmd.Flags().StringVar(&key, "key", "", "API key to store; reads $"+orcarouter.EnvKey+" or prompts when empty") + return cmd +} + +// newOrcaRouterConnectCmd runs the OAuth 2.0 + PKCE login. +func newOrcaRouterConnectCmd() *cobra.Command { + var noBrowser bool + cmd := &cobra.Command{ + Use: "connect", + Short: "Authorize with an " + orcarouter.Name + " account (OAuth 2.0 + PKCE)", + Long: `Authorize this machine with an ` + orcarouter.Name + ` account. + +The login opens a browser, receives the code on a loopback redirect, and +exchanges it for a normal API key that belongs to your account: it is billed to +you and revocable at ` + orcarouter.ConsoleURL + `. No client secret is involved, +because PKCE binds the code to this process. + +The key is durable and is reused until it is revoked. Re-authorizing on every +launch would exhaust the per-user issuance limit. + +When no browser can be opened, the authorization URL is printed so it can be +visited elsewhere; the loopback callback still receives the result. +`, + Annotations: map[string]string{skipActionOptsInit: trueString}, + RunE: func(cmd *cobra.Command, _ []string) error { + manager, err := orcaRouterManager() + if err != nil { + return err + } + + opts := orcarouter.ConnectOptions{AppName: "Chainloop"} + if noBrowser { + opts.OpenBrowser = func(u string) error { + fmt.Fprintf(cmd.OutOrStdout(), "Open this URL to authorize:\n\n %s\n\n", u) + return nil + } + } + cred, err := orcarouter.Connect(cmd.Context(), opts) + if err != nil { + return describeConnectError(err) + } + stored, err := manager.SetPKCE(cred) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Connected. Stored %s credential %s (scope: %s)\n", orcarouter.Name, stored.Redacted().Key, stored.Scope) + return nil + }, + } + cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "print the authorization URL instead of opening a browser") + return cmd +} + +// describeConnectError turns a login failure into an actionable message without +// echoing any response body. +func describeConnectError(err error) error { + switch { + case errors.Is(err, orcarouter.ErrAccessDenied): + return errors.New("authorization was declined; nothing was stored") + case errors.Is(err, orcarouter.ErrStateMismatch): + return errors.New("the authorization response did not belong to this attempt; run the command again") + case errors.Is(err, orcarouter.ErrExchangeRejected): + return errors.New("the authorization code was rejected or already used; run the command again") + case errors.Is(err, orcarouter.ErrScopeDowngrade): + return fmt.Errorf("%w; the account cannot grant the requested scope", err) + case errors.Is(err, context.DeadlineExceeded): + return errors.New("timed out waiting for authorization; run the command again") + default: + return err + } +} + +// newOrcaRouterStatusCmd shows the credential without revealing it. +func newOrcaRouterStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show the configured " + orcarouter.Name + " credential", + Annotations: map[string]string{skipActionOptsInit: trueString}, + RunE: func(cmd *cobra.Command, _ []string) error { + manager, err := orcaRouterManager() + if err != nil { + return err + } + cred, ok := manager.Current() + if !ok { + fmt.Fprintf(cmd.OutOrStdout(), "No %s credential. Run `chainloop orcarouter login` or `chainloop orcarouter connect`.\n", orcarouter.Name) + return nil + } + fmt.Fprintf(cmd.OutOrStdout(), "Provider: %s\nBase URL: %s\nCredential: %s\nSource: %s\nGeneration: %d\nNeeds reauth: %t\n", + orcarouter.Name, orcarouter.APIOrigin(), cred.Redacted().Key, cred.Source, cred.Generation, manager.NeedsReauth()) + return nil + }, + } +} + +// newOrcaRouterLogoutCmd clears the stored credential. +func newOrcaRouterLogoutCmd() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Remove the stored " + orcarouter.Name + " credential", + Annotations: map[string]string{skipActionOptsInit: trueString}, + RunE: func(cmd *cobra.Command, _ []string) error { + manager, err := orcaRouterManager() + if err != nil { + return err + } + if err := manager.Clear(); err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), "Removed the stored credential. Revoke the key itself at "+orcarouter.ConsoleURL+".") + return nil + }, + } +} + +// newOrcaRouterModelsCmd lists the live catalog for one capability, so the +// filtering rules are visible from the terminal. +func newOrcaRouterModelsCmd() *cobra.Command { + var ( + capability string + imageInput bool + ) + cmd := &cobra.Command{ + Use: "models", + Short: "List the " + orcarouter.Name + " models available to this credential", + Long: `List the models that ` + orcarouter.BaseURL + `/models returns for this credential, +filtered for one entry point. + +The live catalog is authoritative. When it cannot be reached, a small verified +fallback list is shown and marked degraded, so a fresh installation is not left +with an empty selector. +`, + Annotations: map[string]string{skipActionOptsInit: trueString}, + RunE: func(cmd *cobra.Command, _ []string) error { + manager, err := orcaRouterManager() + if err != nil { + return err + } + cred, ok := manager.Current() + if !ok { + return fmt.Errorf("%w; run `chainloop orcarouter login` or `chainloop orcarouter connect`", orcarouter.ErrNoCredential) + } + + cat := orcarouter.DiscoverCatalog(cmd.Context(), nil, orcarouter.APIOrigin(), cred) + if cat.Degraded { + fmt.Fprintf(cmd.OutOrStderr(), "Live discovery failed (%v); showing the verified fallback catalog.\n", cat.Err) + } + + var required []string + if imageInput { + required = append(required, "image") + } + models := orcarouter.ForCapability(cat.Models, orcarouter.Capability(capability), required) + + if len(models) == 0 { + fmt.Fprintf(cmd.OutOrStdout(), "No models for capability %q.\n", capability) + return nil + } + fmt.Fprintf(cmd.OutOrStdout(), "%s (%s, %d models)\n", orcarouter.Name, cat.Source, len(models)) + for _, model := range models { + fmt.Fprintln(cmd.OutOrStdout(), model.ID) + } + return nil + }, + } + cmd.Flags().StringVar(&capability, "capability", string(orcarouter.CapabilityChat), "capability to list: chat, embedding, image, video, rerank") + cmd.Flags().BoolVar(&imageInput, "image-input", false, "only models that declare image input") + return cmd +} + +// newOrcaRouterChatCmd sends one prompt through the provider. It is the +// smallest end-to-end path through the inference client, for verifying a +// credential and the wiring without an agent harness. +func newOrcaRouterChatCmd() *cobra.Command { + var model string + cmd := &cobra.Command{ + Use: "chat ", + Short: "Send one prompt through " + orcarouter.Name, + Long: `Send one prompt to ` + orcarouter.BaseURL + ` with the stored credential. + +The model is an ID from ` + orcarouter.ModelsURL + `; it defaults to +` + orcarouter.DefaultModel + `, which routes automatically. Inference and model +discovery use the inference origin, never the authentication origin. +`, + Args: cobra.ExactArgs(1), + Annotations: map[string]string{skipActionOptsInit: trueString}, + RunE: func(cmd *cobra.Command, args []string) error { + return orcaRouterChat(cmd.Context(), cmd.OutOrStdout(), model, args[0]) + }, + } + cmd.Flags().StringVar(&model, "model", "", "model ID (default "+orcarouter.DefaultModel+")") + return cmd +} + +// orcaRouterChat sends one chat request through the provider. It exists so the +// wiring of the inference path can be exercised without another harness. +func orcaRouterChat(ctx context.Context, out io.Writer, model, prompt string) error { + manager, err := orcaRouterManager() + if err != nil { + return err + } + cred, ok := manager.Current() + if !ok { + return fmt.Errorf("%w; run `chainloop orcarouter login` or `chainloop orcarouter connect`", orcarouter.ErrNoCredential) + } + if model == "" { + model = orcarouter.DefaultModel + } + + generation := manager.Generation() + client := &orcarouter.Client{} + answer, err := client.Chat(ctx, cred, orcarouter.ChatRequest{ + Model: model, + Messages: []orcarouter.Message{{Role: "user", Content: prompt}}, + }) + if err != nil { + // A rejected durable key needs a new login; it is not refreshable. + if errors.Is(err, orcarouter.ErrNeedsReauth) { + manager.Rejected(generation) + return fmt.Errorf("%w: run `chainloop orcarouter connect` or `chainloop orcarouter login`", err) + } + return err + } + fmt.Fprintln(out, answer) + return nil +} diff --git a/app/cli/cmd/orcarouter_test.go b/app/cli/cmd/orcarouter_test.go new file mode 100644 index 000000000..c91f280d9 --- /dev/null +++ b/app/cli/cmd/orcarouter_test.go @@ -0,0 +1,96 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "bytes" + "strings" + "testing" + + "github.com/chainloop-dev/chainloop/internal/orcarouter" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestOrcaRouterCommandsAreDiscoverable proves both credential paths are +// subcommands of the provider's command, so a user with a key and a user +// without one each find their way without reading the source. +func TestOrcaRouterCommandsAreDiscoverable(t *testing.T) { + cmd := newOrcaRouterCmd() + assert.Equal(t, "orcarouter", cmd.Use) + assert.Contains(t, cmd.Short, orcarouter.Name) + + names := map[string]bool{} + for _, sub := range cmd.Commands() { + names[sub.Name()] = true + } + for _, want := range []string{"login", "connect", "status", "logout", "models", "chat"} { + assert.True(t, names[want], "subcommand %q is missing", want) + } +} + +// TestOrcaRouterHelpNamesBothCredentialPaths checks the two choices are +// described side by side, not only in code. +func TestOrcaRouterHelpNamesBothCredentialPaths(t *testing.T) { + cmd := newOrcaRouterCmd() + var out bytes.Buffer + cmd.SetOut(&out) + require.NoError(t, cmd.Help()) + help := out.String() + + assert.Contains(t, help, "login") + assert.Contains(t, help, "connect") + assert.Contains(t, help, "OAuth 2.0 + PKCE") + assert.Contains(t, help, "sk-orca-") + assert.Contains(t, help, orcarouter.DocsURL) + assert.Contains(t, help, orcarouter.ModelsURL) + assert.Contains(t, help, orcarouter.BaseURL) + assert.Contains(t, help, orcarouter.AuthBaseURL) +} + +func TestDescribeConnectError(t *testing.T) { + cases := []struct { + name string + err error + want string + }{ + {name: "denied", err: orcarouter.ErrAccessDenied, want: "declined"}, + {name: "state", err: orcarouter.ErrStateMismatch, want: "did not belong"}, + {name: "exchange", err: orcarouter.ErrExchangeRejected, want: "rejected"}, + {name: "scope", err: orcarouter.ErrScopeDowngrade, want: "scope"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + msg := describeConnectError(tc.err).Error() + assert.Contains(t, strings.ToLower(msg), tc.want) + assert.NotContains(t, msg, "sk-orca-") + }) + } +} + +func TestOrcaRouterChatRequiresCredential(t *testing.T) { + // With no stored credential the command fails with an actionable error and + // never reaches the network. + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + viper.Reset() + t.Cleanup(viper.Reset) + + err := orcaRouterChat(t.Context(), &bytes.Buffer{}, orcarouter.DefaultModel, "hi") + require.Error(t, err) + assert.ErrorIs(t, err, orcarouter.ErrNoCredential) +} diff --git a/app/cli/cmd/root.go b/app/cli/cmd/root.go index 5a46c90df..ca729b346 100644 --- a/app/cli/cmd/root.go +++ b/app/cli/cmd/root.go @@ -329,7 +329,7 @@ Command reference: ` + cliReferenceURL, newAttestationCmd(), newArtifactCmd(), newConfigCmd(), newIntegrationCmd(), newOrganizationCmd(), newCASBackendCmd(), newReferrerDiscoverCmd(), newPolicyCmd(), newApplyCmd(), - newTraceCmd(), newTelemetryCmd(), + newTraceCmd(), newTelemetryCmd(), newOrcaRouterCmd(), ) return rootCmd diff --git a/app/cli/documentation/cli-reference.md b/app/cli/documentation/cli-reference.md index b6d08f84a..d463cf208 100644 --- a/app/cli/documentation/cli-reference.md +++ b/app/cli/documentation/cli-reference.md @@ -1964,6 +1964,318 @@ Options inherited from parent commands -y, --yes Skip confirmation ``` +## chainloop orcarouter + +Configure OrcaRouter Cyber Inference + +Synopsis + +OrcaRouter Cyber Inference is an OpenAI-compatible inference gateway for security teams. + +Cyber models for your red team, gateway controls for your blue team: it admits +authorized, scoped security inference that general gateways often refuse or +silently downgrade. See https://www.orcarouter.ai/cyber and the cyber model catalog at +https://www.orcarouter.ai/models?tier=cyber. + +Two credential paths end at the same durable API key, and either one works on +its own: + +chainloop orcarouter login paste an existing sk-orca-... key +chainloop orcarouter connect authorize in a browser with OAuth 2.0 + PKCE + +Authentication uses https://www.orcarouter.ai; inference and model discovery use +https://api.orcarouter.ai/v1. + +Options + +``` +-h, --help help for orcarouter +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop orcarouter chat + +Send one prompt through OrcaRouter Cyber Inference + +Synopsis + +Send one prompt to https://api.orcarouter.ai/v1 with the stored credential. + +The model is an ID from https://www.orcarouter.ai/models?tier=cyber; it defaults to +orcarouter/auto, which routes automatically. Inference and model +discovery use the inference origin, never the authentication origin. + +``` +chainloop orcarouter chat [flags] +``` + +Options + +``` +-h, --help help for chat +--model string model ID (default orcarouter/auto) +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop orcarouter connect + +Authorize with an OrcaRouter Cyber Inference account (OAuth 2.0 + PKCE) + +Synopsis + +Authorize this machine with an OrcaRouter Cyber Inference account. + +The login opens a browser, receives the code on a loopback redirect, and +exchanges it for a normal API key that belongs to your account: it is billed to +you and revocable at https://www.orcarouter.ai/console/authorized-apps. No client secret is involved, +because PKCE binds the code to this process. + +The key is durable and is reused until it is revoked. Re-authorizing on every +launch would exhaust the per-user issuance limit. + +When no browser can be opened, the authorization URL is printed so it can be +visited elsewhere; the loopback callback still receives the result. + +``` +chainloop orcarouter connect [flags] +``` + +Options + +``` +-h, --help help for connect +--no-browser print the authorization URL instead of opening a browser +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop orcarouter help + +Help about any command + +Synopsis + +Help provides help for any command in the application. +Simply type orcarouter help [path to command] for full details. + +``` +chainloop orcarouter help [command] [flags] +``` + +Options + +``` +-h, --help help for help +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop orcarouter login + +Store an existing OrcaRouter Cyber Inference API key + +Synopsis + +Store an existing OrcaRouter Cyber Inference API key. + +Create or copy a key at https://www.orcarouter.ai/cyber. The key is checked for its +documented shape only: the first request is what proves it works. It is written +to the CLI configuration file and is never printed. + +``` +chainloop orcarouter login [flags] +``` + +Options + +``` +-h, --help help for login +--key string API key to store; reads $ORCAROUTER_API_KEY or prompts when empty +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop orcarouter logout + +Remove the stored OrcaRouter Cyber Inference credential + +``` +chainloop orcarouter logout [flags] +``` + +Options + +``` +-h, --help help for logout +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop orcarouter models + +List the OrcaRouter Cyber Inference models available to this credential + +Synopsis + +List the models that https://api.orcarouter.ai/v1/models returns for this credential, +filtered for one entry point. + +The live catalog is authoritative. When it cannot be reached, a small verified +fallback list is shown and marked degraded, so a fresh installation is not left +with an empty selector. + +``` +chainloop orcarouter models [flags] +``` + +Options + +``` +--capability string capability to list: chat, embedding, image, video, rerank (default "chat") +-h, --help help for models +--image-input only models that declare image input +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + +### chainloop orcarouter status + +Show the configured OrcaRouter Cyber Inference credential + +``` +chainloop orcarouter status [flags] +``` + +Options + +``` +-h, --help help for status +``` + +Options inherited from parent commands + +``` +--artifact-cas string URL for the Artifacts Content Addressable Storage API ($CHAINLOOP_ARTIFACT_CAS_API) (default "cas.chainloop.dev:443") +--artifact-cas-ca string CUSTOM CA file for the Artifacts CAS API (optional) ($CHAINLOOP_ARTIFACT_CAS_API_CA) +-c, --config string Path to an existing config file (default is $HOME/.config/chainloop/config.toml) +--control-plane string URL for the Control Plane API ($CHAINLOOP_CONTROL_PLANE_API) (default "cp.chainloop.dev:443") +--control-plane-ca string CUSTOM CA file for the Control Plane API (optional) ($CHAINLOOP_CONTROL_PLANE_API_CA) +--debug Enable debug/verbose logging mode +-i, --insecure Skip TLS transport during connection to the control plane ($CHAINLOOP_API_INSECURE) +--max-recv-msg-size int Max size in bytes for incoming gRPC messages (0 = default 33554432) ($CHAINLOOP_API_MAX_RECV_MSG_SIZE) +-n, --org string organization name +-o, --output string Output format, valid options are json and table (default "table") +-t, --token string API token. NOTE: Alternatively use the env variable CHAINLOOP_TOKEN +-y, --yes Skip confirmation +``` + ## chainloop organization Organizations management diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index f1f648639..ec96a3aee 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -217,6 +217,8 @@ The harnesses run as follows: The configured OpenCode 2, Pi, and Codex models use OpenRouter and need `OPENROUTER_API_KEY`. A Codex model without the `openrouter/` prefix uses the default Codex provider and needs `CODEX_API_KEY`. The pinned Codex model is `openrouter/openai/gpt-6-luna`. GPT-5 Mini frequently ended the scripted task before committing and pushing, so it is not the default test model. +A Codex model can also run on [OrcaRouter](https://www.orcarouter.ai), the OpenAI-compatible gateway for security teams: write it as `orcarouter:`, for example `orcarouter:orcarouter/auto`, and set `ORCAROUTER_API_KEY`. The suite passes `model_providers.orcarouter` to Codex with `base_url = https://api.orcarouter.ai/v1`, the same definition `chainloop orcarouter` uses. `chainloop orcarouter login` stores a pasted key and `chainloop orcarouter connect` obtains one with OAuth 2.0 + PKCE; either credential works, and `chainloop orcarouter models` lists the live catalog. See the [Cyber Inference models](https://www.orcarouter.ai/models?tier=cyber). + Some scenarios add the git commands of a human after the session, for example a commit that replaces an AI line, an amend, or a rename, and then push again. They work on a feature branch, because the push reads only the commits that are not on the default branch of the remote. A feature row maps to its checks by the feature name. When you rename a feature in a table, rename it in `e2e/checks.go` too. The suite tests a cell marked **Yes**, **Partial**, or **Unknown** only when the row has a registered check. It reports the other cells as follows: diff --git a/app/cli/internal/trace/e2e/codex.go b/app/cli/internal/trace/e2e/codex.go index 565decb75..9375e2f0f 100644 --- a/app/cli/internal/trace/e2e/codex.go +++ b/app/cli/internal/trace/e2e/codex.go @@ -29,6 +29,7 @@ import ( "strconv" "strings" + "github.com/chainloop-dev/chainloop/internal/orcarouter" "github.com/pelletier/go-toml/v2" ) @@ -41,6 +42,14 @@ const ( // codexOpenRouterProvider defines the OpenRouter provider, which reads // its key from OPENROUTER_API_KEY. codexOpenRouterProvider = `model_providers.openrouter={name="OpenRouter",base_url="https://openrouter.ai/api/v1",env_key="OPENROUTER_API_KEY"}` + // codexOrcaRouterPrefix selects OrcaRouter Cyber Inference for a model of + // the suite config. The rest of the value is the model ID as the catalog + // lists it, for example "orcarouter:orcarouter/auto". + codexOrcaRouterPrefix = "orcarouter:" + // codexOrcaRouterProvider defines the OrcaRouter Cyber Inference provider, + // which reads its key from ORCAROUTER_API_KEY. The path mirrors the + // definition the CLI's `orcarouter` command uses. + codexOrcaRouterProvider = `model_providers.orcarouter={name="` + orcarouter.Name + `",base_url="` + orcarouter.BaseURL + `",env_key="` + orcarouter.EnvKey + `"}` ) var codexVersionRe = regexp.MustCompile(`\d+\.\d+\.\d+`) @@ -84,9 +93,18 @@ func parseCodexVersion(out []byte) (string, error) { } // codexModelArgs returns the arguments that select the model. A model with -// the "openrouter/" prefix runs on OpenRouter. Any other model runs on the +// the "orcarouter:" prefix runs on OrcaRouter Cyber Inference, a model with the +// "openrouter/" prefix runs on OpenRouter, and any other model runs on the // default provider of Codex, which reads its key from CODEX_API_KEY. func codexModelArgs(model string) []string { + if name, ok := strings.CutPrefix(model, codexOrcaRouterPrefix); ok { + return []string{ + flagModel, name, + "-c", "model_provider=orcarouter", + "-c", codexOrcaRouterProvider, + } + } + name, ok := strings.CutPrefix(model, codexOpenRouterPrefix) if !ok { return []string{flagModel, model} diff --git a/app/cli/internal/trace/e2e/codex_test.go b/app/cli/internal/trace/e2e/codex_test.go index cbc8738b3..bef62bf3e 100644 --- a/app/cli/internal/trace/e2e/codex_test.go +++ b/app/cli/internal/trace/e2e/codex_test.go @@ -69,6 +69,24 @@ func TestCodexModelArgs(t *testing.T) { "-c", codexOpenRouterProvider, }, }, + { + name: "orcarouter", + model: "orcarouter:orcarouter/auto", + want: []string{ + flagModel, "orcarouter/auto", + "-c", "model_provider=orcarouter", + "-c", codexOrcaRouterProvider, + }, + }, + { + name: "orcarouter cyber model", + model: "orcarouter:orca/orcacyber-ultra-1.0", + want: []string{ + flagModel, "orca/orcacyber-ultra-1.0", + "-c", "model_provider=orcarouter", + "-c", codexOrcaRouterProvider, + }, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -77,6 +95,16 @@ func TestCodexModelArgs(t *testing.T) { } } +func TestCodexOrcaRouterProviderDefinition(t *testing.T) { + // A named provider, not a generic base URL override: it keeps the + // OrcaRouter name, the documented inference base URL, and the environment + // variable the CLI's `orcarouter` commands use. + const want = `model_providers.orcarouter={name="OrcaRouter Cyber Inference",base_url="https://api.orcarouter.ai/v1",env_key="ORCAROUTER_API_KEY"}` + assert.Equal(t, want, codexOrcaRouterProvider) + assert.Contains(t, codexOrcaRouterProvider, "orcarouter") + assert.NotContains(t, codexOrcaRouterProvider, "openrouter.ai") +} + func TestCodexStreamErrors(t *testing.T) { raw := []byte("{\"type\":\"thread.started\"}\n{\"type\":\"error\",\"message\":\"missing key\"}\n{\"type\":\"turn.failed\",\"error\":{\"message\":\"turn failed\"}}\n") assert.Equal(t, "missing key\nturn failed", codexStreamErrors(raw)) diff --git a/internal/orcarouter/catalog.go b/internal/orcarouter/catalog.go new file mode 100644 index 000000000..91d8e98b7 --- /dev/null +++ b/internal/orcarouter/catalog.go @@ -0,0 +1,365 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package orcarouter + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "slices" + "strings" + "time" +) + +// Catalog limits. The response of a catalog endpoint is untrusted input, so it +// is bounded in bytes, items, and time before anything is kept. +const ( + // CatalogTimeout bounds a catalog request. + CatalogTimeout = 20 * time.Second + // MaxCatalogBytes bounds the catalog response. + MaxCatalogBytes = 4 << 20 + // MaxCatalogItems bounds the number of records kept. + MaxCatalogItems = 2000 +) + +// Capability is what an entry point can consume. A dropdown is built for one +// capability, so an embedding model never appears in a chat selector. +type Capability string + +const ( + // CapabilityChat is text conversation. + CapabilityChat Capability = "chat" + // CapabilityEmbedding is vector embedding. + CapabilityEmbedding Capability = "embedding" + // CapabilityImage is image generation. + CapabilityImage Capability = "image" + // CapabilityVideo is video generation. + CapabilityVideo Capability = "video" + // CapabilityRerank is reranking. + CapabilityRerank Capability = "rerank" +) + +// Endpoint types of the catalog. A record advertises the wire protocols it can +// speak; only the ones the client can actually use are accepted. +var ( + textEndpointTypes = []string{"openai", "anthropic", "gemini", "openai-response"} + // nonTextEndpoints mark models that are not general text chat even when + // they accept a chat-shaped request. + nonTextEndpoints = []string{"image-generation", "openai-video", "jina-rerank"} +) + +// Model is one catalog record reduced to what a selector needs. +type Model struct { + // ID is the model ID, with its vendor namespace kept verbatim. + ID string `json:"id"` + // Name is a human-readable label, when the catalog gives one. + Name string `json:"name,omitempty"` + // OwnedBy is the vendor the catalog attributes the model to. + OwnedBy string `json:"owned_by,omitempty"` + // EndpointTypes are the wire protocols the model speaks. + EndpointTypes []string `json:"supported_endpoint_types,omitempty"` + // EndpointCapabilities are the higher-level capabilities, when given. + EndpointCapabilities []string `json:"endpoint_capabilities,omitempty"` + // InputModalities are the modalities the model accepts. It is empty when + // the catalog does not say, which means "not declared", not "text only". + InputModalities []string `json:"input_modalities,omitempty"` + // ReasoningEfforts are the effort levels the catalog verifies, when it + // lists them. + ReasoningEfforts []string `json:"reasoning_efforts,omitempty"` +} + +// DeclaresInput reports whether the catalog explicitly declares a modality. +func (m Model) DeclaresInput(modality string) bool { + return slices.Contains(m.InputModalities, modality) +} + +// IsTextChat reports whether the model can serve a text chat entry point. +func (m Model) IsTextChat() bool { + if !slices.ContainsFunc(m.EndpointTypes, func(t string) bool { return slices.Contains(textEndpointTypes, t) }) { + return false + } + if slices.ContainsFunc(m.EndpointTypes, func(t string) bool { return slices.Contains(nonTextEndpoints, t) }) { + return false + } + // A model that declares output modalities and none of them is text is not + // a chat model, even if it accepts the chat wire format. + return true +} + +// Catalog is a model list plus where it came from. +type Catalog struct { + // Models are the models that passed the filters of the capability. + Models []Model + // Source is "live" or "seed". + Source CatalogSource + // Degraded says the live discovery failed and the seed is in use. + Degraded bool + // Err is the discovery failure, when Degraded is true. + Err error +} + +// CatalogSource says where a model list came from. +type CatalogSource string + +const ( + // SourceLive is the service catalog. + SourceLive CatalogSource = "live" + // SourceSeed is the verified cold-start list, used when discovery fails. + SourceSeed CatalogSource = "seed" +) + +// rawModel is the catalog record as received. It is a typed struct, and the +// nested architecture block is separate because the catalog may omit it. +type rawModel struct { + ID string `json:"id"` + Name string `json:"name"` + OwnedBy string `json:"owned_by"` + SupportedEndpointTypes []string `json:"supported_endpoint_types"` + EndpointCapabilities []string `json:"endpoint_capabilities"` + Architecture *struct { + InputModalities []string `json:"input_modalities"` + OutputModalities []string `json:"output_modalities"` + } `json:"architecture"` + Reasoning struct { + Efforts []string `json:"efforts"` + } `json:"reasoning"` +} + +// rawCatalog is the envelope of the catalog response. +type rawCatalog struct { + Data []rawModel `json:"data"` +} + +// DiscoverCatalog fetches the live catalog with a credential. A failure to reach +// the service is not fatal: the verified seed is returned, marked degraded, so a +// fresh installation still has a usable list. +func DiscoverCatalog(ctx context.Context, client *http.Client, baseURL string, cred Credential) Catalog { + live, err := fetchCatalog(ctx, client, baseURL, cred) + if err != nil { + return Catalog{Models: seedModels(), Source: SourceSeed, Degraded: true, Err: err} + } + return Catalog{Models: live, Source: SourceLive} +} + +// fetchCatalog performs the request and parses the records. It keeps the +// catalog authoritative on success and never merges the seed into it. +func fetchCatalog(ctx context.Context, client *http.Client, baseURL string, cred Credential) ([]Model, error) { + if cred.Key == "" { + return nil, errors.New("no OrcaRouter credential") + } + baseURL = NormalizeBase(baseURL) + if baseURL == "" { + baseURL = BaseURL + } + + ctx, cancel := context.WithTimeout(ctx, CatalogTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/models", nil) + if err != nil { + return nil, fmt.Errorf("build catalog request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+cred.Key) + req.Header.Set("Accept", "application/json") + + if client == nil { + client = &http.Client{Timeout: CatalogTimeout} + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch the model catalog: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + switch { + case resp.StatusCode == http.StatusOK: + case resp.StatusCode == http.StatusUnauthorized: + return nil, ErrNeedsReauth + case resp.StatusCode == http.StatusTooManyRequests: + return nil, errors.New("the model catalog was rate limited (status 429)") + default: + return nil, fmt.Errorf("fetch the model catalog: status %d", resp.StatusCode) + } + + raw, err := io.ReadAll(io.LimitReader(resp.Body, MaxCatalogBytes)) + if err != nil { + return nil, fmt.Errorf("read the model catalog: %w", err) + } + + var decoded rawCatalog + if err := json.Unmarshal(raw, &decoded); err != nil { + return nil, fmt.Errorf("decode the model catalog: %w", err) + } + + models := make([]Model, 0, min(len(decoded.Data), MaxCatalogItems)) + for _, record := range decoded.Data { + if len(models) == MaxCatalogItems { + break + } + model, ok := record.model() + if !ok { + continue + } + models = append(models, model) + } + return models, nil +} + +// model converts a raw record, rejecting one that cannot be used at all. +func (m rawModel) model() (Model, bool) { + id := strings.TrimSpace(m.ID) + if id == "" { + return Model{}, false + } + out := Model{ + ID: id, + Name: m.Name, + OwnedBy: m.OwnedBy, + EndpointTypes: slices.Clone(m.SupportedEndpointTypes), + EndpointCapabilities: slices.Clone(m.EndpointCapabilities), + ReasoningEfforts: slices.Clone(m.Reasoning.Efforts), + } + if m.Architecture != nil { + out.InputModalities = slices.Clone(m.Architecture.InputModalities) + } + return out, true +} + +// ForCapability filters a catalog for one entry point. An entry point that takes +// an attachment also requires the catalog to declare that input modality, so a +// model whose capabilities are unknown is left out rather than guessed at. +func ForCapability(models []Model, capability Capability, requiredInputs []string) []Model { + out := make([]Model, 0, len(models)) + for _, model := range models { + if !supports(model, capability) { + continue + } + if !declaresAll(model, requiredInputs) { + continue + } + out = append(out, model) + } + return out +} + +// supports reports whether a model serves a capability. +func supports(model Model, capability Capability) bool { + switch capability { + case CapabilityChat: + return model.IsTextChat() + case CapabilityEmbedding: + return matchesEndpoint(model, "embeddings") || slices.Contains(model.EndpointCapabilities, "embeddings") + case CapabilityImage: + return matchesEndpoint(model, "image-generation") || slices.Contains(model.EndpointCapabilities, "image_generation") + case CapabilityVideo: + return matchesEndpoint(model, "openai-video") + case CapabilityRerank: + return matchesEndpoint(model, "jina-rerank") + default: + return false + } +} + +// matchesEndpoint reports whether the model advertises an endpoint type. +func matchesEndpoint(model Model, endpoint string) bool { + return slices.Contains(model.EndpointTypes, endpoint) +} + +// declaresAll requires every modality to be explicitly declared. Empty +// requirements always pass. +func declaresAll(model Model, modalities []string) bool { + for _, modality := range modalities { + if !model.DeclaresInput(modality) { + return false + } + } + return true +} + +// ChatModels is the chat selector list: text chat only, plus the required +// input modalities of the entry point. +func ChatModels(models []Model, requiredInputs []string) []Model { + return ForCapability(models, CapabilityChat, requiredInputs) +} + +// Contains reports whether a model ID is in a list. +func Contains(models []Model, id string) bool { + return slices.ContainsFunc(models, func(m Model) bool { return m.ID == id }) +} + +// Revalidate returns the model ID if it is still offered by a filtered list, and +// the empty string otherwise. A restored selection must pass this before it is +// shown again. +func Revalidate(models []Model, id string) string { + if id == "" || !Contains(models, id) { + return "" + } + return id +} + +// seedModels is the verified cold-start list. It is small on purpose, carries +// the metadata the catalog is known to publish, and is only used when live +// discovery fails. When live discovery succeeds it is not merged in. +func seedModels() []Model { + return []Model{ + { + ID: "openai/gpt-5.5", + Name: "OpenAI: GPT-5.5", + OwnedBy: "OpenAI", + EndpointTypes: []string{"openai", "openai-response", "anthropic"}, + InputModalities: []string{"text", "image"}, + ReasoningEfforts: []string{"low", "medium", "high", "xhigh"}, + }, + { + ID: "anthropic/claude-opus-4.8", + Name: "Anthropic: Claude Opus 4.8", + OwnedBy: "Anthropic", + EndpointTypes: []string{"openai", "openai-response", "anthropic"}, + InputModalities: []string{"text", "image"}, + }, + { + ID: "google/gemini-3.5-flash", + Name: "Google: Gemini 3.5 Flash", + OwnedBy: "Google", + EndpointTypes: []string{"openai", "openai-response", "gemini"}, + InputModalities: []string{"text", "image"}, + }, + { + ID: "deepseek/deepseek-v4-pro", + Name: "DeepSeek: DeepSeek V4 Pro", + OwnedBy: "DeepSeek", + EndpointTypes: []string{"openai", "openai-response"}, + InputModalities: []string{"text"}, + }, + { + ID: "orcarouter/auto", + Name: "OrcaRouter: Auto", + OwnedBy: "OrcaRouter", + EndpointTypes: []string{"openai", "openai-response", "anthropic", "gemini"}, + InputModalities: []string{"text"}, + }, + { + ID: "orcarouter/fusion", + Name: "OrcaRouter: Fusion", + OwnedBy: "OrcaRouter", + EndpointTypes: []string{"openai", "openai-response", "anthropic", "gemini"}, + InputModalities: []string{"text"}, + }, + } +} diff --git a/internal/orcarouter/catalog_test.go b/internal/orcarouter/catalog_test.go new file mode 100644 index 000000000..a640516fa --- /dev/null +++ b/internal/orcarouter/catalog_test.go @@ -0,0 +1,212 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package orcarouter + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "slices" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fixtureCatalog covers one record of each capability the filters must tell +// apart: text-only chat, image-input chat, embedding, image generation, video, +// and rerank. +const fixtureCatalog = `{ + "object": "list", + "data": [ + {"id":"orcarouter/auto","owned_by":"OrcaRouter","supported_endpoint_types":["openai","anthropic"]}, + {"id":"openai/gpt-5.5","owned_by":"OpenAI","supported_endpoint_types":["openai","openai-response"], + "architecture":{"input_modalities":["text","image"],"output_modalities":["text"]}, + "reasoning":{"efforts":["low","medium","high","xhigh"]}}, + {"id":"deepseek/deepseek-v4-pro","owned_by":"DeepSeek","supported_endpoint_types":["openai","openai-response"], + "architecture":{"input_modalities":["text"],"output_modalities":["text"]}}, + {"id":"vendor/no-modalities","owned_by":"Vendor","supported_endpoint_types":["openai"]}, + {"id":"vendor/image-gen","owned_by":"Vendor","supported_endpoint_types":["image-generation"]}, + {"id":"vendor/video","owned_by":"Vendor","supported_endpoint_types":["openai-video"]}, + {"id":"vendor/rerank","owned_by":"Vendor","supported_endpoint_types":["jina-rerank"]}, + {"id":"vendor/embed","owned_by":"Vendor","supported_endpoint_types":["embeddings"]}, + {"id":"orca/orcacyber-ultra-1.0","owned_by":"OrcaRouter","supported_endpoint_types":["openai"], + "architecture":{"input_modalities":["text"],"output_modalities":["text"]}} + ] +}` + +func catalogServer(t *testing.T, body string, status int) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/models", r.URL.Path) + assert.Equal(t, "Bearer sk-orca-fixture", r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestDiscoverCatalogLive(t *testing.T) { + srv := catalogServer(t, fixtureCatalog, http.StatusOK) + cred := Credential{Key: "sk-orca-fixture"} + + cat := DiscoverCatalog(context.Background(), srv.Client(), srv.URL, cred) + require.False(t, cat.Degraded) + assert.Equal(t, SourceLive, cat.Source) + require.NoError(t, cat.Err) + assert.Len(t, cat.Models, 9) + assert.True(t, Contains(cat.Models, "openai/gpt-5.5")) + // The live result is authoritative: the seed is not merged into it. + assert.False(t, Contains(cat.Models, "anthropic/claude-opus-4.8")) +} + +func TestDiscoverCatalogFallsBack(t *testing.T) { + // A server that refuses the catalog: a fresh installation still gets a + // usable, clearly degraded list. + srv := catalogServer(t, `{}`, http.StatusInternalServerError) + cred := Credential{Key: "sk-orca-fixture"} + + cat := DiscoverCatalog(context.Background(), srv.Client(), srv.URL, cred) + assert.True(t, cat.Degraded) + assert.Equal(t, SourceSeed, cat.Source) + assert.Error(t, cat.Err) + assert.NotEmpty(t, cat.Models) + + // The verified metadata survives the fallback. + gpt := findModel(t, cat.Models, "openai/gpt-5.5") + assert.Equal(t, []string{"text", "image"}, gpt.InputModalities) + assert.Equal(t, []string{"low", "medium", "high", "xhigh"}, gpt.ReasoningEfforts) +} + +func TestDiscoverCatalogUnauthorized(t *testing.T) { + srv := catalogServer(t, `{"error":"unauthorized"}`, http.StatusUnauthorized) + cat := DiscoverCatalog(context.Background(), srv.Client(), srv.URL, Credential{Key: "sk-orca-fixture"}) + assert.True(t, cat.Degraded) + assert.True(t, errors.Is(cat.Err, ErrNeedsReauth)) +} + +func TestDiscoverCatalogNoCredential(t *testing.T) { + cat := DiscoverCatalog(context.Background(), nil, "https://api.orcarouter.ai/v1", Credential{}) + assert.True(t, cat.Degraded) + assert.Equal(t, SourceSeed, cat.Source) +} + +func TestChatModelsFilter(t *testing.T) { + cat := parseFixture(t) + + textIDs := ids(ChatModels(cat, nil)) + assert.Contains(t, textIDs, "orcarouter/auto") + assert.Contains(t, textIDs, "deepseek/deepseek-v4-pro") + assert.Contains(t, textIDs, "vendor/no-modalities", "a chat record with no modality block is still text chat") + assert.NotContains(t, textIDs, "vendor/image-gen") + assert.NotContains(t, textIDs, "vendor/video") + assert.NotContains(t, textIDs, "vendor/rerank") + assert.NotContains(t, textIDs, "vendor/embed") + + imageModels := ChatModels(cat, []string{"image"}) + imageIDs := ids(imageModels) + assert.Contains(t, imageIDs, "openai/gpt-5.5") + assert.NotContains(t, imageIDs, "deepseek/deepseek-v4-pro", "a text-only model must leave an image dropdown") + assert.NotContains(t, imageIDs, "orcarouter/auto", "an undeclared modality fails closed") + assert.NotContains(t, imageIDs, "vendor/no-modalities") +} + +func TestCapabilityFilters(t *testing.T) { + cat := parseFixture(t) + + cases := []struct { + capability Capability + want string + }{ + {capability: CapabilityEmbedding, want: "vendor/embed"}, + {capability: CapabilityImage, want: "vendor/image-gen"}, + {capability: CapabilityVideo, want: "vendor/video"}, + {capability: CapabilityRerank, want: "vendor/rerank"}, + } + for _, tc := range cases { + t.Run(string(tc.capability), func(t *testing.T) { + got := ids(ForCapability(cat, tc.capability, nil)) + assert.Equal(t, []string{tc.want}, got) + }) + } +} + +func TestRevalidate(t *testing.T) { + cat := parseFixture(t) + text := ChatModels(cat, nil) + + assert.Equal(t, "deepseek/deepseek-v4-pro", Revalidate(text, "deepseek/deepseek-v4-pro")) + assert.Empty(t, Revalidate(text, "vendor/image-gen"), "a stored model that no longer fits is cleared") + assert.Empty(t, Revalidate(text, "")) + + image := ChatModels(cat, []string{"image"}) + assert.Empty(t, Revalidate(image, "deepseek/deepseek-v4-pro"), "adding an image attachment invalidates a text model") +} + +func TestCatalogBounds(t *testing.T) { + var b strings.Builder + b.WriteString(`{"data":[`) + for i := 0; i < MaxCatalogItems+50; i++ { + if i > 0 { + b.WriteString(",") + } + b.WriteString(`{"id":"vendor/m`) + b.WriteString(strings.Repeat("x", 1)) + b.WriteString(strconv.Itoa(i)) + b.WriteString(`","supported_endpoint_types":["openai"]}`) + } + b.WriteString(`]}`) + + srv := catalogServer(t, b.String(), http.StatusOK) + cat := DiscoverCatalog(context.Background(), srv.Client(), srv.URL, Credential{Key: "sk-orca-fixture"}) + assert.Len(t, cat.Models, MaxCatalogItems) +} + +func TestCatalogRejectsMalformedRecords(t *testing.T) { + body := `{"data":[{"id":""},{"nosuchfield":"x"},{"id":"vendor/ok","supported_endpoint_types":["openai"]}]}` + srv := catalogServer(t, body, http.StatusOK) + cat := DiscoverCatalog(context.Background(), srv.Client(), srv.URL, Credential{Key: "sk-orca-fixture"}) + require.Len(t, cat.Models, 1) + assert.Equal(t, "vendor/ok", cat.Models[0].ID) +} + +func parseFixture(t *testing.T) []Model { + t.Helper() + srv := catalogServer(t, fixtureCatalog, http.StatusOK) + cat := DiscoverCatalog(context.Background(), srv.Client(), srv.URL, Credential{Key: "sk-orca-fixture"}) + require.False(t, cat.Degraded) + return cat.Models +} + +func ids(models []Model) []string { + out := make([]string, 0, len(models)) + for _, m := range models { + out = append(out, m.ID) + } + return out +} + +func findModel(t *testing.T, models []Model, id string) Model { + t.Helper() + i := slices.IndexFunc(models, func(m Model) bool { return m.ID == id }) + require.GreaterOrEqual(t, i, 0) + return models[i] +} diff --git a/internal/orcarouter/credential.go b/internal/orcarouter/credential.go new file mode 100644 index 000000000..0c005dece --- /dev/null +++ b/internal/orcarouter/credential.go @@ -0,0 +1,96 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package orcarouter + +import ( + "errors" + "fmt" + "strings" +) + +// Source says how a credential was obtained. Both adapters end at the same +// kind of value, a normal OrcaRouter API key, so nothing downstream branches on +// it beyond the status text. +type Source string + +const ( + // SourceAPIKey is a key the user pasted from the console. + SourceAPIKey Source = "api_key" + // SourcePKCE is a key minted by an OAuth 2.0 + PKCE login. + SourcePKCE Source = "pkce" +) + +// Credential is one OrcaRouter API key plus what the exchange said about it. +// It is the only shape the provider, the request path, and model discovery +// accept, whatever adapter produced it. +type Credential struct { + // Key is the API key. It is never logged, printed, or put in a URL. + Key string + // Source records which adapter produced it. + Source Source + // UserID is the account the exchange reported, when it did. + UserID string + // Scope is the scope that was granted, as read back from the response, + // not the scope that was requested. + Scope string + // Generation increases on every successful replacement. A late failure + // from an older generation must not mark a newer credential unusable. + Generation int +} + +// Redacted returns the credential with the key masked, for status output. +func (c Credential) Redacted() Credential { + c.Key = MaskKey(c.Key) + return c +} + +// MaskKey shows enough of a key to recognise it and no more. It never panics on +// a short or empty value. +func MaskKey(key string) string { + const keep = 4 + if key == "" { + return "" + } + if len(key) <= keep*2 { + return strings.Repeat("*", len(key)) + } + return key[:keep] + strings.Repeat("*", len(key)-keep*2) + key[len(key)-keep:] +} + +// ErrInvalidKey is returned by the API-key adapter for a value that cannot be +// an OrcaRouter key. +var ErrInvalidKey = errors.New("not an OrcaRouter API key") + +// APIKeyAdapter turns a pasted key into a Credential. It checks the documented +// prefix and that the value is not empty. It does not call the service: the +// prefix is a format check, not proof that the credential is valid, and there +// is no free validation endpoint. +type APIKeyAdapter struct{} + +// Acquire validates a pasted key. The key travels in memory only. +func (APIKeyAdapter) Acquire(key string) (Credential, error) { + trimmed := strings.TrimSpace(key) + if trimmed == "" { + return Credential{}, fmt.Errorf("%w: the key is empty", ErrInvalidKey) + } + if !strings.HasPrefix(trimmed, KeyPrefix) || len(trimmed) == len(KeyPrefix) { + return Credential{}, fmt.Errorf("%w: the key must start with %q", ErrInvalidKey, KeyPrefix) + } + if strings.ContainsAny(trimmed, " \t\r\n") { + return Credential{}, fmt.Errorf("%w: the key contains whitespace", ErrInvalidKey) + } + return Credential{Key: trimmed, Source: SourceAPIKey}, nil +} diff --git a/internal/orcarouter/credential_test.go b/internal/orcarouter/credential_test.go new file mode 100644 index 000000000..d09d773f2 --- /dev/null +++ b/internal/orcarouter/credential_test.go @@ -0,0 +1,107 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package orcarouter + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestProviderDefinition(t *testing.T) { + p := Default() + assert.Equal(t, ID, p.ID) + assert.Equal(t, "OrcaRouter Cyber Inference", p.Name) + assert.Equal(t, "https://api.orcarouter.ai/v1", p.BaseURL) + assert.Equal(t, "ORCAROUTER_API_KEY", p.EnvKey) + assert.Equal(t, "orcarouter/auto", p.DefaultModel) + // The brand name and the orcarouter keyword stay visible. + assert.Contains(t, strings.ToLower(p.Name), "orcarouter") + assert.NotContains(t, p.BaseURL, "/auth") +} + +func TestRecommendedModels(t *testing.T) { + // The default and the fallback are OrcaRouter's own routing models; the + // cyber models keep the namespace the live catalog publishes. + assert.Equal(t, []string{"orcarouter/auto", "orca/orcacyber-ultra-1.0", "orca/orcacyber-zero-1.5", "orcarouter/fusion"}, RecommendedModels()) + for _, id := range RecommendedModels() { + assert.True(t, strings.HasPrefix(id, "orcarouter/") || strings.HasPrefix(id, "orca/orcacyber-"), id) + } + assert.NotContains(t, RecommendedModels(), "deepseek/deepseek-v4.1-flash", "a verification-only model is never recommended to users") +} + +func TestMaskKey(t *testing.T) { + cases := []struct { + name string + key string + want string + }{ + {name: "empty", key: "", want: ""}, + {name: "short", key: "abc", want: "***"}, + {name: "typical", key: "sk-orca-0123456789abcdef", want: "sk-o****************cdef"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := MaskKey(tc.key) + assert.Equal(t, tc.want, got) + if tc.key != "" { + assert.NotEqual(t, tc.key, got) + assert.NotContains(t, got, "0123456789") + } + }) + } +} + +func TestAPIKeyAdapter(t *testing.T) { + cases := []struct { + name string + key string + wantErr bool + source Source + }{ + {name: "valid", key: "sk-orca-abcdef123456", source: SourceAPIKey}, + {name: "surrounding whitespace", key: " sk-orca-abcdef123456\n", source: SourceAPIKey}, + {name: "empty", key: "", wantErr: true}, + {name: "wrong prefix", key: "sk-ant-abcdef", wantErr: true}, + {name: "prefix only", key: "sk-orca-", wantErr: true}, + {name: "embedded space", key: "sk-orca-ab cd", wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cred, err := (APIKeyAdapter{}).Acquire(tc.key) + if tc.wantErr { + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidKey) + assert.Empty(t, cred.Key) + return + } + require.NoError(t, err) + assert.Equal(t, tc.source, cred.Source) + assert.Equal(t, strings.TrimSpace(tc.key), cred.Key) + }) + } +} + +func TestCredentialRedacted(t *testing.T) { + cred := Credential{Key: "sk-orca-secret-value-here", Source: SourcePKCE, UserID: "u1", Scope: "api"} + red := cred.Redacted() + assert.NotEqual(t, cred.Key, red.Key) + assert.NotContains(t, red.Key, "secret-value") + assert.Equal(t, cred.UserID, red.UserID) + assert.Equal(t, cred.Source, red.Source) +} diff --git a/internal/orcarouter/inference.go b/internal/orcarouter/inference.go new file mode 100644 index 000000000..7a164aaf8 --- /dev/null +++ b/internal/orcarouter/inference.go @@ -0,0 +1,158 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package orcarouter + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// Inference limits. A response is untrusted input, so it is bounded in bytes +// and time. +const ( + // InferenceTimeout bounds one chat request. + InferenceTimeout = 60 * time.Second + // MaxInferenceBytes bounds one chat response. + MaxInferenceBytes = 4 << 20 +) + +// Message is one chat message in the OpenAI wire format. +type Message struct { + // Role is system, user, or assistant. + Role string `json:"role"` + // Content is the text of the message. + Content string `json:"content"` +} + +// ChatRequest is a minimal OpenAI-compatible chat request. +type ChatRequest struct { + // Model is a model ID from the catalog, with its namespace kept. + Model string `json:"model"` + // Messages are the conversation so far. + Messages []Message `json:"messages"` + // MaxTokens optionally bounds the answer. + MaxTokens int `json:"max_tokens,omitempty"` +} + +// chatResponse is the part of the response the client reads. +type chatResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` +} + +// Client calls the OrcaRouter inference API with a credential, whatever adapter +// produced it. +type Client struct { + // BaseURL is the inference base URL. Empty uses APIOrigin(). + BaseURL string + // HTTP is the HTTP client. Empty uses one bounded by InferenceTimeout. + HTTP *http.Client +} + +// Chat sends a chat request. A rejected credential returns ErrNeedsReauth; the +// caller marks the exact generation that made the request, and does not retry +// or attempt a refresh, because the credential is durable rather than +// refreshable. +func (c *Client) Chat(ctx context.Context, cred Credential, req ChatRequest) (string, error) { + if cred.Key == "" { + return "", ErrNoCredential + } + if strings.TrimSpace(req.Model) == "" { + return "", errors.New("no model selected") + } + if len(req.Messages) == 0 { + return "", errors.New("no messages") + } + + base := c.BaseURL + if base == "" { + base = APIOrigin() + } + base = NormalizeBase(base) + if err := ValidateOrigin(base); err != nil { + return "", err + } + + payload, err := json.Marshal(req) + if err != nil { + return "", fmt.Errorf("encode the chat request: %w", err) + } + + ctx, cancel := context.WithTimeout(ctx, InferenceTimeout) + defer cancel() + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/chat/completions", bytes.NewReader(payload)) + if err != nil { + return "", fmt.Errorf("build the chat request: %w", err) + } + httpReq.Header.Set("Authorization", "Bearer "+cred.Key) + httpReq.Header.Set("Content-Type", "application/json") + + client := c.HTTP + if client == nil { + client = &http.Client{Timeout: InferenceTimeout} + } + resp, err := client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("send the chat request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, MaxInferenceBytes)) + if err != nil { + return "", fmt.Errorf("read the chat response: %w", err) + } + + switch { + case resp.StatusCode == http.StatusOK: + case resp.StatusCode == http.StatusUnauthorized: + return "", ErrNeedsReauth + case resp.StatusCode == http.StatusForbidden: + // The key is valid but is not allowed to use this model. Switching + // models or granting access is the fix; a new login would not help. + return "", fmt.Errorf("the credential is not allowed to use model %q; list the models it can use with `chainloop orcarouter models`, or grant access to it in the OrcaRouter console (status 403)", req.Model) + case resp.StatusCode == http.StatusTooManyRequests: + return "", errors.New("OrcaRouter rate limited the request (status 429)") + default: + return "", fmt.Errorf("chat request failed: status %d", resp.StatusCode) + } + + var decoded chatResponse + if err := json.Unmarshal(raw, &decoded); err != nil { + return "", fmt.Errorf("decode the chat response: %w", err) + } + if decoded.Error != nil && decoded.Error.Message != "" { + return "", fmt.Errorf("chat request failed: %s", decoded.Error.Message) + } + if len(decoded.Choices) == 0 { + return "", errors.New("the chat response has no choices") + } + return decoded.Choices[0].Message.Content, nil +} diff --git a/internal/orcarouter/inference_test.go b/internal/orcarouter/inference_test.go new file mode 100644 index 000000000..4cd75b916 --- /dev/null +++ b/internal/orcarouter/inference_test.go @@ -0,0 +1,114 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package orcarouter + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestChatSuccess(t *testing.T) { + var gotAuth, gotPath, gotModel string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotPath = r.URL.Path + var req ChatRequest + _ = json.NewDecoder(r.Body).Decode(&req) + gotModel = req.Model + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"role":"assistant","content":"hello there"}}]}`)) + })) + defer srv.Close() + + client := &Client{BaseURL: srv.URL, HTTP: srv.Client()} + answer, err := client.Chat(context.Background(), Credential{Key: "sk-orca-test"}, ChatRequest{ + Model: "orcarouter/auto", + Messages: []Message{{Role: "user", Content: "hi"}}, + }) + require.NoError(t, err) + assert.Equal(t, "hello there", answer) + assert.Equal(t, "/chat/completions", gotPath) + assert.Equal(t, "Bearer sk-orca-test", gotAuth) + assert.Equal(t, "orcarouter/auto", gotModel) +} + +func TestChatRejectedKeyNeedsReauth(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"error":"revoked"}`, http.StatusUnauthorized) + })) + defer srv.Close() + + client := &Client{BaseURL: srv.URL, HTTP: srv.Client()} + _, err := client.Chat(context.Background(), Credential{Key: "sk-orca-revoked"}, ChatRequest{ + Model: "orcarouter/auto", + Messages: []Message{{Role: "user", Content: "hi"}}, + }) + assert.ErrorIs(t, err, ErrNeedsReauth) + assert.NotContains(t, err.Error(), "sk-orca-revoked", "the key must never appear in an error") +} + +func TestChatRateLimited(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{}`, http.StatusTooManyRequests) + })) + defer srv.Close() + + client := &Client{BaseURL: srv.URL, HTTP: srv.Client()} + _, err := client.Chat(context.Background(), Credential{Key: "sk-orca-test"}, ChatRequest{ + Model: "orcarouter/auto", + Messages: []Message{{Role: "user", Content: "hi"}}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "429") +} + +func TestChatModelNotAllowed(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"error":{"code":"model_access_denied"}}`, http.StatusForbidden) + })) + defer srv.Close() + + client := &Client{BaseURL: srv.URL, HTTP: srv.Client()} + _, err := client.Chat(context.Background(), Credential{Key: "sk-orca-test"}, ChatRequest{ + Model: "orcarouter/auto", + Messages: []Message{{Role: "user", Content: "hi"}}, + }) + require.Error(t, err) + // A forbidden model is not a rejected credential: a new login would not + // help, so the caller must not mark the key as needing reauth. + assert.NotErrorIs(t, err, ErrNeedsReauth) + assert.Contains(t, err.Error(), "orcarouter/auto") + assert.Contains(t, err.Error(), "403") + assert.NotContains(t, err.Error(), "sk-orca-test") +} + +func TestChatValidation(t *testing.T) { + client := &Client{} + _, err := client.Chat(context.Background(), Credential{}, ChatRequest{Model: "m", Messages: []Message{{Role: "user"}}}) + assert.ErrorIs(t, err, ErrNoCredential) + + _, err = client.Chat(context.Background(), Credential{Key: "sk-orca-x"}, ChatRequest{Messages: []Message{{Role: "user"}}}) + require.Error(t, err) + + _, err = client.Chat(context.Background(), Credential{Key: "sk-orca-x"}, ChatRequest{Model: "m"}) + require.Error(t, err) +} diff --git a/internal/orcarouter/live_test.go b/internal/orcarouter/live_test.go new file mode 100644 index 000000000..74dcd02e2 --- /dev/null +++ b/internal/orcarouter/live_test.go @@ -0,0 +1,98 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package orcarouter + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// liveModelEnv selects the model of the live check. It defaults to the +// verification model this repository's key can call; the provider's own +// recommended models are listed in RecommendedModels. +const ( + liveModelEnv = "ORCAROUTER_LIVE_MODEL" + liveModelDefault = "deepseek/deepseek-v4.1-flash" + livePrompt = "Reply with the single word: ok" +) + +// TestLiveCatalogAndChat exercises the real provider through the code paths +// this package ships: the credential adapter, the catalog request, the +// capability filter, and the chat request. It is skipped unless a key is in +// the environment, so the offline suite stays hermetic. It never prints the +// key. +func TestLiveCatalogAndChat(t *testing.T) { + key := os.Getenv(EnvKey) + if key == "" { + t.Skipf("%s is not set; skipping the live check", EnvKey) + } + + manager, err := NewManager(&MemoryStore{}) + require.NoError(t, err) + cred, err := manager.SetAPIKey(key) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + // 1. The live catalog comes from the configured origin, not a fixture. + cat := DiscoverCatalog(ctx, nil, APIOrigin(), cred) + require.False(t, cat.Degraded, "live discovery failed: %v", cat.Err) + require.Equal(t, SourceLive, cat.Source) + require.NotEmpty(t, cat.Models) + t.Logf("live catalog: %d models", len(cat.Models)) + + // 2. The chat filter yields only text models, and the model under test is + // among them. + chat := ChatModels(cat.Models, nil) + require.NotEmpty(t, chat) + for _, model := range chat { + assert.True(t, model.IsTextChat(), model.ID) + } + + model := os.Getenv(liveModelEnv) + if model == "" { + model = liveModelDefault + } + require.True(t, Contains(chat, model), "model %q is not in the filtered chat catalog", model) + assert.Equal(t, model, Revalidate(chat, model)) + + // 3. A real request through the client, with the credential the adapter + // produced. + client := &Client{} + answer, err := client.Chat(ctx, cred, ChatRequest{ + Model: model, + Messages: []Message{{Role: "user", Content: livePrompt}}, + }) + require.NoError(t, err) + answer = strings.TrimSpace(answer) + assert.NotEmpty(t, answer) + t.Logf("live chat with %s returned %d characters", model, len(answer)) + + // 4. The image-input filter is a strict subset of the chat list. + withImage := ChatModels(cat.Models, []string{"image"}) + assert.LessOrEqual(t, len(withImage), len(chat)) + for _, model := range withImage { + assert.True(t, model.DeclaresInput("image"), model.ID) + } + t.Logf("live catalog: %d image-input chat models", len(withImage)) +} diff --git a/internal/orcarouter/manager.go b/internal/orcarouter/manager.go new file mode 100644 index 000000000..0002cd1be --- /dev/null +++ b/internal/orcarouter/manager.go @@ -0,0 +1,193 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package orcarouter + +import ( + "errors" + "fmt" + "sync" +) + +// ErrNoCredential is returned when no OrcaRouter credential is configured. +var ErrNoCredential = errors.New("no OrcaRouter credential is configured") + +// Store is the persistence seam. The CLI backs it with the configuration file +// it already trusts; tests back it with an in-memory implementation. Only the +// key string and its source are persisted: there is no refresh token to keep, +// because a PKCE login yields a durable API key. +type Store interface { + // Load returns the stored key and the adapter that produced it. A missing + // entry returns an empty key and no error. + Load() (key string, source Source, err error) + // Save writes the key and its source. + Save(key string, source Source) error + // Clear removes the entry. + Clear() error +} + +// MemoryStore is a Store that keeps the credential in memory, for tests and for +// callers that do not persist anything. +type MemoryStore struct { + mu sync.Mutex + key string + source Source +} + +// Load implements Store. +func (s *MemoryStore) Load() (string, Source, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.key, s.source, nil +} + +// Save implements Store. +func (s *MemoryStore) Save(key string, source Source) error { + s.mu.Lock() + defer s.mu.Unlock() + s.key, s.source = key, source + return nil +} + +// Clear implements Store. +func (s *MemoryStore) Clear() error { + s.mu.Lock() + defer s.mu.Unlock() + s.key, s.source = "", "" + return nil +} + +// Manager owns the current credential and its generation. The two adapters +// produce the same Credential, so everything downstream is adapter-agnostic. +// +// A revoked key is durable and cannot be refreshed. When a request is rejected, +// the manager marks exactly the generation that made it as needing +// reauthentication, and leaves a newer credential alone: a late failure from an +// old request must not break a fresh login. +type Manager struct { + mu sync.Mutex + store Store + credential Credential + needsReaut bool +} + +// NewManager reads the stored credential. A storage failure is reported but +// does not prevent the caller from logging in again. +func NewManager(store Store) (*Manager, error) { + m := &Manager{store: store} + if store == nil { + return m, nil + } + key, source, err := store.Load() + if err != nil { + return m, fmt.Errorf("read the OrcaRouter credential: %w", err) + } + if key != "" { + m.credential = Credential{Key: key, Source: source, Generation: 1} + } + return m, nil +} + +// Current returns the credential in use, if any. +func (m *Manager) Current() (Credential, bool) { + m.mu.Lock() + defer m.mu.Unlock() + if m.credential.Key == "" { + return Credential{}, false + } + return m.credential, true +} + +// NeedsReauth reports whether the current credential was rejected. +func (m *Manager) NeedsReauth() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.needsReaut +} + +// SetAPIKey validates and stores a pasted key, through the same path a PKCE +// login ends at. +func (m *Manager) SetAPIKey(key string) (Credential, error) { + cred, err := (APIKeyAdapter{}).Acquire(key) + if err != nil { + return Credential{}, err + } + return m.adopt(cred) +} + +// SetPKCE adopts a credential minted by a PKCE login. +func (m *Manager) SetPKCE(cred Credential) (Credential, error) { + if cred.Key == "" { + return Credential{}, ErrExchangeRejected + } + cred.Source = SourcePKCE + return m.adopt(cred) +} + +// adopt installs a new credential, advancing the generation and clearing the +// reauthentication flag. The stored key is replaced only after the save +// succeeds, so a failed write does not lose the old one. +func (m *Manager) adopt(cred Credential) (Credential, error) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.store != nil { + if err := m.store.Save(cred.Key, cred.Source); err != nil { + return Credential{}, fmt.Errorf("store the OrcaRouter credential: %w", err) + } + } + cred.Generation = m.credential.Generation + 1 + m.credential = cred + m.needsReaut = false + return cred, nil +} + +// Clear removes the credential from memory and from storage. +func (m *Manager) Clear() error { + m.mu.Lock() + defer m.mu.Unlock() + if m.store != nil { + if err := m.store.Clear(); err != nil { + return fmt.Errorf("clear the OrcaRouter credential: %w", err) + } + } + m.credential = Credential{} + m.needsReaut = true + return nil +} + +// Rejected marks the credential of the given generation as needing +// reauthentication. A rejection for a generation that is no longer current, or +// for a key that is no longer the stored one, changes nothing, so a late +// failure from an old request cannot poison a fresh login. +func (m *Manager) Rejected(generation int) { + m.mu.Lock() + defer m.mu.Unlock() + if m.credential.Key == "" { + return + } + if generation != 0 && generation != m.credential.Generation { + return + } + m.needsReaut = true +} + +// Generation returns the current credential generation, or zero when there is +// none. A caller records it before a request and passes it back to Rejected. +func (m *Manager) Generation() int { + m.mu.Lock() + defer m.mu.Unlock() + return m.credential.Generation +} diff --git a/internal/orcarouter/manager_test.go b/internal/orcarouter/manager_test.go new file mode 100644 index 000000000..331704e31 --- /dev/null +++ b/internal/orcarouter/manager_test.go @@ -0,0 +1,195 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package orcarouter + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// failingStore is a Store that fails on the operation under test. +type failingStore struct { + loadErr error + saveErr error + clearErr error + key string + source Source +} + +func (s *failingStore) Load() (string, Source, error) { return s.key, s.source, s.loadErr } +func (s *failingStore) Save(key string, source Source) error { + if s.saveErr != nil { + return s.saveErr + } + s.key, s.source = key, source + return nil +} +func (s *failingStore) Clear() error { + if s.clearErr != nil { + return s.clearErr + } + s.key, s.source = "", "" + return nil +} + +// TestBothAdaptersProduceTheSameCredential is the core of the dual-auth +// requirement: the pasted key and the PKCE login end at the same value, and +// nothing downstream can tell them apart except the recorded source. +func TestBothAdaptersProduceTheSameCredential(t *testing.T) { + store := &MemoryStore{} + manager, err := NewManager(store) + require.NoError(t, err) + + viaKey, err := manager.SetAPIKey("sk-orca-pasted-key") + require.NoError(t, err) + + managerPKCE, err := NewManager(&MemoryStore{}) + require.NoError(t, err) + viaPKCE, err := managerPKCE.SetPKCE(Credential{Key: "sk-orca-pkce-key", UserID: "42", Scope: "api"}) + require.NoError(t, err) + + assert.Equal(t, SourceAPIKey, viaKey.Source) + assert.Equal(t, SourcePKCE, viaPKCE.Source) + + // The inference client and the catalog accept either one through the same + // interface: neither reads Source. + for _, cred := range []Credential{viaKey, viaPKCE} { + assert.True(t, len(cred.Key) > len(KeyPrefix)) + assert.Equal(t, cred.Key, (Credential{Key: cred.Key}).Key) + } + + // Both are persisted as an ordinary key. + stored, source, err := store.Load() + require.NoError(t, err) + assert.Equal(t, "sk-orca-pasted-key", stored) + assert.Equal(t, SourceAPIKey, source) +} + +func TestManagerPersistsAndReloads(t *testing.T) { + store := &MemoryStore{} + manager, err := NewManager(store) + require.NoError(t, err) + + _, ok := manager.Current() + assert.False(t, ok) + + cred, err := manager.SetAPIKey("sk-orca-durable") + require.NoError(t, err) + assert.Equal(t, 1, cred.Generation) + + // A second process reads the same stored key and reuses it, rather than + // authorizing again. + reloaded, err := NewManager(store) + require.NoError(t, err) + got, ok := reloaded.Current() + require.True(t, ok) + assert.Equal(t, "sk-orca-durable", got.Key) +} + +func TestManagerClear(t *testing.T) { + store := &MemoryStore{} + manager, err := NewManager(store) + require.NoError(t, err) + _, err = manager.SetAPIKey("sk-orca-clear-me") + require.NoError(t, err) + + require.NoError(t, manager.Clear()) + _, ok := manager.Current() + assert.False(t, ok) + assert.True(t, manager.NeedsReauth()) + + key, _, err := store.Load() + require.NoError(t, err) + assert.Empty(t, key) +} + +func TestManagerRejectedIsGenerationSafe(t *testing.T) { + manager, err := NewManager(&MemoryStore{}) + require.NoError(t, err) + + first, err := manager.SetAPIKey("sk-orca-first") + require.NoError(t, err) + assert.Equal(t, 1, first.Generation) + + // A failure from the first request arrives after a new login. + second, err := manager.SetPKCE(Credential{Key: "sk-orca-second"}) + require.NoError(t, err) + assert.Equal(t, 2, second.Generation) + + manager.Rejected(first.Generation) + assert.False(t, manager.NeedsReauth(), "a late failure from an old generation must not mark the new credential") + + current, ok := manager.Current() + require.True(t, ok) + assert.Equal(t, "sk-orca-second", current.Key) + + // A rejection of the current generation does mark it. + manager.Rejected(second.Generation) + assert.True(t, manager.NeedsReauth()) + + // And a new login clears the flag without deleting the old key first. + third, err := manager.SetPKCE(Credential{Key: "sk-orca-third"}) + require.NoError(t, err) + assert.False(t, manager.NeedsReauth()) + assert.Equal(t, third.Generation, manager.Generation()) + assert.Equal(t, "sk-orca-third", third.Key) +} + +func TestManagerFailedSaveKeepsOldKey(t *testing.T) { + store := &failingStore{key: "sk-orca-original", source: SourcePKCE} + manager, err := NewManager(store) + require.NoError(t, err) + + store.saveErr = errors.New("disk full") + _, err = manager.SetAPIKey("sk-orca-replacement") + require.Error(t, err) + + got, ok := manager.Current() + require.True(t, ok) + assert.Equal(t, "sk-orca-original", got.Key, "a failed store write must not lose the working credential") + assert.Equal(t, "sk-orca-original", store.key) +} + +func TestManagerLoadError(t *testing.T) { + _, err := NewManager(&failingStore{loadErr: errors.New("unreadable")}) + require.Error(t, err) +} + +func TestSetPKCERequiresKey(t *testing.T) { + manager, err := NewManager(&MemoryStore{}) + require.NoError(t, err) + _, err = manager.SetPKCE(Credential{}) + assert.ErrorIs(t, err, ErrExchangeRejected) +} + +func TestManagerNeedsReauthAfterRevocation(t *testing.T) { + // A revoked durable key is a terminal reauthentication, never a refresh. + manager, err := NewManager(&MemoryStore{}) + require.NoError(t, err) + cred, err := manager.SetPKCE(Credential{Key: "sk-orca-revoked"}) + require.NoError(t, err) + + manager.Rejected(cred.Generation) + assert.True(t, manager.NeedsReauth()) + // The key is kept until a new login succeeds, so a misclassified failure + // does not destroy it. + current, ok := manager.Current() + require.True(t, ok) + assert.Equal(t, "sk-orca-revoked", current.Key) +} diff --git a/internal/orcarouter/pkce.go b/internal/orcarouter/pkce.go new file mode 100644 index 000000000..011a9766f --- /dev/null +++ b/internal/orcarouter/pkce.go @@ -0,0 +1,422 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package orcarouter + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "runtime" + "strings" + "time" +) + +// Origins and paths of the login flow. Authentication and inference are +// different origins, and the exchange path is not below /v1. +const ( + // EnvAuthBaseURL overrides the authentication origin. + EnvAuthBaseURL = "ORCA_AUTH_BASE_URL" + // EnvAPIBaseURL overrides the inference origin. + EnvAPIBaseURL = "ORCA_API_BASE_URL" + // EnvSharedBaseURL sets one origin for both, as a self-hosted deployment + // may do. The explicit overrides win. + EnvSharedBaseURL = "ORCA_BASE_URL" + // AuthPath is the consent screen. + AuthPath = "/auth" + // ExchangePath is where an auth code becomes an API key. + ExchangePath = "/api/v1/auth/keys" + // DefaultScope is the scope the login asks for. + DefaultScope = "api" + // MaxResponseBytes bounds what the exchange may return. + MaxResponseBytes = 1 << 20 +) + +// ErrAccessDenied is returned when the user declines on the consent screen. +var ErrAccessDenied = errors.New("the authorization was denied") + +// ErrStateMismatch is returned when the callback state does not match the one +// that this attempt sent. +var ErrStateMismatch = errors.New("the callback state does not match this authorization attempt") + +// ErrExchangeRejected is returned when the exchange refuses the code. A code is +// single use with a ten minute lifetime; any refusal needs a new attempt. +var ErrExchangeRejected = errors.New("the authorization code was rejected") + +// ErrScopeDowngrade is returned when the granted scope is narrower than the +// scope the caller needs. +var ErrScopeDowngrade = errors.New("the granted scope is narrower than the requested scope") + +// ErrNeedsReauth is returned by the request path when the service rejects a +// durable key. The key is not refreshable; a new login is required. +var ErrNeedsReauth = errors.New("the OrcaRouter credential was rejected; authorize again") + +// AuthOrigin returns the authentication origin: the explicit override first, +// then the shared base, then the public default. +func AuthOrigin() string { + return resolveBase(EnvAuthBaseURL) +} + +// APIOrigin returns the inference origin, with the same precedence. +func APIOrigin() string { + return resolveBase(EnvAPIBaseURL) +} + +// resolveBase reads a documented override and normalizes it. +func resolveBase(explicit string) string { + base := os.Getenv(explicit) + if base == "" { + base = os.Getenv(EnvSharedBaseURL) + } + if base == "" { + if explicit == EnvAuthBaseURL { + return AuthBaseURL + } + return BaseURL + } + return NormalizeBase(base) +} + +// NormalizeBase trims a trailing slash from a configured origin. +func NormalizeBase(base string) string { + return strings.TrimRight(strings.TrimSpace(base), "/") +} + +// ValidateOrigin requires HTTPS for a remote origin and permits plain HTTP only +// for loopback, where a development server and the local callback live. +func ValidateOrigin(raw string) error { + u, err := url.Parse(raw) + if err != nil { + return fmt.Errorf("parse origin: %w", err) + } + if u.Scheme == "https" && u.Host != "" { + return nil + } + if u.Scheme == "http" && isLoopbackHost(u.Hostname()) { + return nil + } + return fmt.Errorf("origin %q must be https, or http on loopback", raw) +} + +// isLoopbackHost reports whether a host names this machine. +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// newVerifier returns a fresh PKCE verifier from the cryptographic RNG. +func newVerifier() (string, error) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate code verifier: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// newState returns a fresh opaque state value. +func newState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("generate state: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// challengeFor returns the S256 challenge of a verifier, unpadded base64url. +func challengeFor(verifier string) string { + sum := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +// AuthorizeURL builds the consent URL for one attempt. +func AuthorizeURL(authBase, callbackURL, challenge, state, appName, scope string) string { + u, err := url.Parse(NormalizeBase(authBase) + AuthPath) + if err != nil { + // The callers validate the origin before reaching here; fall back to + // the public default rather than emitting a broken URL. + u, _ = url.Parse(AuthBaseURL + AuthPath) + } + q := u.Query() + q.Set("callback_url", callbackURL) + q.Set("code_challenge", challenge) + q.Set("code_challenge_method", "S256") + q.Set("state", state) + q.Set("app_name", appName) + if scope != "" { + q.Set("scope", scope) + } + u.RawQuery = q.Encode() + return u.String() +} + +// exchangeResponse is the body of a successful exchange. +type exchangeResponse struct { + Key string `json:"key"` + UserID string `json:"user_id"` + Scope string `json:"scope"` +} + +// ConnectOptions configures a login attempt. +type ConnectOptions struct { + // AuthBase is the authentication origin. Empty uses AuthOrigin(). + AuthBase string + // AppName is shown on the consent screen. + AppName string + // Scope is the scope to ask for. Empty uses DefaultScope. + Scope string + // Client is the HTTP client. Empty uses a client with a bounded timeout. + Client *http.Client + // OpenBrowser opens the consent URL. Empty uses the platform opener. + OpenBrowser func(url string) error + // Timeout bounds the wait for the user. Empty uses two minutes. + Timeout time.Duration +} + +// Connect runs the loopback redirect flow (Flow A) and returns the minted API +// key. A CLI on a developer machine has a browser and can listen on loopback, +// so this flow needs no code copied by hand. The verifier and state are fresh +// for every attempt, and only the S256 challenge travels in the URL. +func Connect(ctx context.Context, opts ConnectOptions) (Credential, error) { + authBase := opts.AuthBase + if authBase == "" { + authBase = AuthOrigin() + } + authBase = NormalizeBase(authBase) + if err := ValidateOrigin(authBase); err != nil { + return Credential{}, err + } + + appName := opts.AppName + if appName == "" { + appName = "Chainloop" + } + scope := opts.Scope + if scope == "" { + scope = DefaultScope + } + client := opts.Client + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + timeout := opts.Timeout + if timeout == 0 { + timeout = 2 * time.Minute + } + + verifier, err := newVerifier() + if err != nil { + return Credential{}, err + } + state, err := newState() + if err != nil { + return Credential{}, err + } + + // Listen first, so the callback URL is known before the browser opens and + // no approval can race the listener. + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return Credential{}, fmt.Errorf("listen on loopback: %w", err) + } + defer func() { _ = listener.Close() }() + + port := listener.Addr().(*net.TCPAddr).Port + callbackURL := fmt.Sprintf("http://127.0.0.1:%d/cb", port) + + results := make(chan callbackResult, 1) + server := &http.Server{ + Handler: callbackHandler(state, results), + ReadHeaderTimeout: 10 * time.Second, + } + go func() { _ = server.Serve(listener) }() + defer func() { _ = server.Close() }() + + authorizeURL := AuthorizeURL(authBase, callbackURL, challengeFor(verifier), state, appName, scope) + open := opts.OpenBrowser + if open == nil { + open = openBrowser + } + if err := open(authorizeURL); err != nil { + return Credential{}, fmt.Errorf("open the authorization page: %w", err) + } + + waitCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + var code string + select { + case <-waitCtx.Done(): + return Credential{}, fmt.Errorf("waiting for authorization: %w", waitCtx.Err()) + case res := <-results: + if res.err != nil { + return Credential{}, res.err + } + code = res.code + } + + return Exchange(ctx, client, authBase, code, verifier, scope) +} + +// callbackResult is what the loopback handler delivers: a code, or the reason +// there is none. +type callbackResult struct { + code string + err error +} + +// callbackHandler serves the loopback callback. It compares the state in +// constant time before anything that uses the code, so a callback another page +// dropped here cannot be redeemed, and it always answers the browser so the +// user is never left on a blank page. +func callbackHandler(state string, results chan<- callbackResult) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/cb" { + http.NotFound(w, r) + return + } + + q := r.URL.Query() + send := func(res callbackResult) { + select { + case results <- res: + default: + } + } + + // Constant-time state comparison comes before anything that uses the + // code, so a callback another page dropped here cannot be redeemed. + if subtle.ConstantTimeCompare([]byte(q.Get("state")), []byte(state)) != 1 { + http.Error(w, "this authorization response does not belong to this login attempt", http.StatusBadRequest) + send(callbackResult{err: ErrStateMismatch}) + return + } + + if e := q.Get("error"); e != "" { + http.Error(w, "authorization failed; you can close this tab", http.StatusForbidden) + send(callbackResult{err: fmt.Errorf("%w: %s", ErrAccessDenied, e)}) + return + } + + code := q.Get("code") + if code == "" { + http.Error(w, "the callback carried no code; you can close this tab", http.StatusBadRequest) + send(callbackResult{err: ErrExchangeRejected}) + return + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, "

Connected. You can close this tab and return to the terminal.

") + send(callbackResult{code: code}) + }) +} + +// Exchange trades an auth code for an API key. It posts to the authentication +// origin at /api/v1/auth/keys, never to the inference origin. +func Exchange(ctx context.Context, client *http.Client, authBase, code, verifier, requestedScope string) (Credential, error) { + if code == "" { + return Credential{}, ErrExchangeRejected + } + if client == nil { + client = &http.Client{Timeout: 30 * time.Second} + } + authBase = NormalizeBase(authBase) + if err := ValidateOrigin(authBase); err != nil { + return Credential{}, err + } + + body := struct { + Code string `json:"code"` + CodeVerifier string `json:"code_verifier"` + CodeChallengeMethod string `json:"code_challenge_method"` + }{Code: code, CodeVerifier: verifier, CodeChallengeMethod: "S256"} + payload, err := json.Marshal(body) + if err != nil { + return Credential{}, fmt.Errorf("encode exchange request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, authBase+ExchangePath, strings.NewReader(string(payload))) + if err != nil { + return Credential{}, fmt.Errorf("build exchange request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + // The error text of a transport failure can carry the URL but never + // the body, and the verifier is never in either. + return Credential{}, fmt.Errorf("exchange the authorization code: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, MaxResponseBytes)) + if err != nil { + return Credential{}, fmt.Errorf("read the exchange response: %w", err) + } + + switch resp.StatusCode { + case http.StatusOK: + case http.StatusBadRequest, http.StatusForbidden: + return Credential{}, fmt.Errorf("%w: status %d", ErrExchangeRejected, resp.StatusCode) + case http.StatusTooManyRequests: + return Credential{}, fmt.Errorf("OrcaRouter rate limited the login; wait and try again (status 429)") + default: + return Credential{}, fmt.Errorf("exchange the authorization code: status %d", resp.StatusCode) + } + + var decoded exchangeResponse + if err := json.Unmarshal(raw, &decoded); err != nil { + return Credential{}, fmt.Errorf("decode the exchange response: %w", err) + } + if decoded.Key == "" { + return Credential{}, ErrExchangeRejected + } + + cred := Credential{Key: decoded.Key, Source: SourcePKCE, UserID: decoded.UserID, Scope: decoded.Scope} + if requestedScope != "" && decoded.Scope != "" && decoded.Scope != requestedScope { + return Credential{}, fmt.Errorf("%w: granted %q, requested %q", ErrScopeDowngrade, decoded.Scope, requestedScope) + } + return cred, nil +} + +// openBrowser opens a URL with the desktop opener of the platform. +func openBrowser(rawURL string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", rawURL) //nolint:gosec // the URL is one we built + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL) //nolint:gosec // the URL is one we built + default: + cmd = exec.Command("xdg-open", rawURL) //nolint:gosec // the URL is one we built + } + if err := cmd.Start(); err != nil { + return err + } + return cmd.Process.Release() +} diff --git a/internal/orcarouter/pkce_test.go b/internal/orcarouter/pkce_test.go new file mode 100644 index 000000000..aeb8933ae --- /dev/null +++ b/internal/orcarouter/pkce_test.go @@ -0,0 +1,411 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package orcarouter + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeAuthServer is a stand-in for the OrcaRouter consent exchange. It records +// what it was sent so the tests can assert the path, the verifier, and the +// method, and it never receives a real credential. +type fakeAuthServer struct { + *httptest.Server + mu sync.Mutex + requests []exchangeRecord + // respond lets a test override the reply. + respond func(w http.ResponseWriter, body map[string]string) + // verifier is the verifier the server expects, learned from the challenge + // the client sent at authorize time. + challenge string +} + +type exchangeRecord struct { + path string + method string + code string + verif string + kmeth string +} + +func newFakeAuthServer(t *testing.T) *fakeAuthServer { + t.Helper() + f := &fakeAuthServer{} + f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "bad body", http.StatusBadRequest) + return + } + f.mu.Lock() + f.requests = append(f.requests, exchangeRecord{ + path: r.URL.Path, method: r.Method, + code: body["code"], verif: body["code_verifier"], kmeth: body["code_challenge_method"], + }) + respond := f.respond + challenge := f.challenge + f.mu.Unlock() + + if respond != nil { + respond(w, body) + return + } + // When a test set a challenge, enforce it the way the real service + // does before minting a key. + if challenge != "" { + sum := sha256.Sum256([]byte(body["code_verifier"])) + if base64.RawURLEncoding.EncodeToString(sum[:]) != challenge { + http.Error(w, `{"error":"invalid_grant"}`, http.StatusForbidden) + return + } + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"key":"sk-orca-test-minted-key","user_id":"42","scope":"api"}`)) + })) + t.Cleanup(f.Close) + return f +} + +// runConnect drives Connect with a fake browser: it reads the authorize URL, +// extracts the callback, and delivers the code the way the consent screen +// would. deliver lets a test send a different query. +func runConnect(t *testing.T, authBase string, deliver func(callback url.URL, state string) string) (Credential, error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + t.Cleanup(cancel) + + return Connect(ctx, ConnectOptions{ + AuthBase: authBase, + AppName: "Chainloop Test", + Client: &http.Client{Timeout: 5 * time.Second}, + OpenBrowser: func(authorizeURL string) error { + u, err := url.Parse(authorizeURL) + if err != nil { + return err + } + q := u.Query() + require.Equal(t, "S256", q.Get("code_challenge_method")) + require.NotEmpty(t, q.Get("code_challenge")) + require.NotEmpty(t, q.Get("state")) + require.Equal(t, "Chainloop Test", q.Get("app_name")) + require.Equal(t, "api", q.Get("scope")) + + callback, err := url.Parse(q.Get("callback_url")) + if err != nil { + return err + } + target := deliver(*callback, q.Get("state")) + resp, err := http.Get(target) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + return nil + }, + }) +} + +func TestConnectSuccess(t *testing.T) { + f := newFakeAuthServer(t) + + // The authorize URL carries only the challenge; the real verifier is what + // the exchange presents. Recompute the challenge from the exchange body + // afterwards, so the fake server can enforce the binding. + cred, err := runConnect(t, f.URL, func(callback url.URL, state string) string { + callback.RawQuery = url.Values{"code": {"test-code"}, "state": {state}}.Encode() + return callback.String() + }) + require.NoError(t, err) + + assert.Equal(t, "sk-orca-test-minted-key", cred.Key) + assert.Equal(t, SourcePKCE, cred.Source) + assert.Equal(t, "42", cred.UserID) + assert.Equal(t, "api", cred.Scope) + + f.mu.Lock() + defer f.mu.Unlock() + require.Len(t, f.requests, 1) + assert.Equal(t, ExchangePath, f.requests[0].path) + assert.Equal(t, "/api/v1/auth/keys", f.requests[0].path) + assert.Equal(t, http.MethodPost, f.requests[0].method) + assert.Equal(t, "test-code", f.requests[0].code) + assert.Equal(t, "S256", f.requests[0].kmeth) + assert.NotEmpty(t, f.requests[0].verif) +} + +func TestConnectChallengeEnforcedByServer(t *testing.T) { + f := newFakeAuthServer(t) + f.mu.Lock() + f.challenge = "not-the-challenge-of-the-verifier" + f.mu.Unlock() + + _, err := runConnect(t, f.URL, func(callback url.URL, state string) string { + callback.RawQuery = url.Values{"code": {"test-code"}, "state": {state}}.Encode() + return callback.String() + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrExchangeRejected) +} + +func TestConnectDenied(t *testing.T) { + f := newFakeAuthServer(t) + + _, err := runConnect(t, f.URL, func(callback url.URL, state string) string { + callback.RawQuery = url.Values{"error": {"access_denied"}, "state": {state}}.Encode() + return callback.String() + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrAccessDenied) + // A denial stops before the exchange. + f.mu.Lock() + defer f.mu.Unlock() + assert.Empty(t, f.requests) +} + +func TestConnectStateMismatch(t *testing.T) { + f := newFakeAuthServer(t) + + _, err := runConnect(t, f.URL, func(callback url.URL, _ string) string { + callback.RawQuery = url.Values{"code": {"test-code"}, "state": {"someone-elses-state"}}.Encode() + return callback.String() + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrStateMismatch) + f.mu.Lock() + defer f.mu.Unlock() + assert.Empty(t, f.requests, "a callback from another attempt must never be exchanged") +} + +func TestConnectTimeout(t *testing.T) { + f := newFakeAuthServer(t) + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + + _, err := Connect(ctx, ConnectOptions{ + AuthBase: f.URL, + Client: &http.Client{Timeout: time.Second}, + OpenBrowser: func(string) error { return nil }, + }) + require.Error(t, err) + assert.ErrorIs(t, err, context.DeadlineExceeded) +} + +func TestConnectExchangeCodes(t *testing.T) { + cases := []struct { + name string + status int + want error + }{ + {name: "expired or reused code", status: http.StatusForbidden, want: ErrExchangeRejected}, + {name: "bad method", status: http.StatusBadRequest, want: ErrExchangeRejected}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFakeAuthServer(t) + f.mu.Lock() + f.respond = func(w http.ResponseWriter, _ map[string]string) { + http.Error(w, `{"error":"invalid_grant"}`, tc.status) + } + f.mu.Unlock() + + _, err := runConnect(t, f.URL, func(callback url.URL, state string) string { + callback.RawQuery = url.Values{"code": {"used-code"}, "state": {state}}.Encode() + return callback.String() + }) + require.Error(t, err) + assert.ErrorIs(t, err, tc.want) + }) + } +} + +func TestConnectRateLimited(t *testing.T) { + f := newFakeAuthServer(t) + f.mu.Lock() + f.respond = func(w http.ResponseWriter, _ map[string]string) { + http.Error(w, `{"error":"rate_limited"}`, http.StatusTooManyRequests) + } + f.mu.Unlock() + + _, err := runConnect(t, f.URL, func(callback url.URL, state string) string { + callback.RawQuery = url.Values{"code": {"test-code"}, "state": {state}}.Encode() + return callback.String() + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "429") +} + +func TestConnectScopeDowngrade(t *testing.T) { + f := newFakeAuthServer(t) + f.mu.Lock() + f.respond = func(w http.ResponseWriter, _ map[string]string) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"key":"sk-orca-narrow","user_id":"42","scope":"connector"}`)) + } + f.mu.Unlock() + + _, err := runConnect(t, f.URL, func(callback url.URL, state string) string { + callback.RawQuery = url.Values{"code": {"test-code"}, "state": {state}}.Encode() + return callback.String() + }) + require.Error(t, err) + assert.ErrorIs(t, err, ErrScopeDowngrade) +} + +func TestExchangeUsesAuthOriginOnly(t *testing.T) { + var gotPath, gotHost string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath, gotHost = r.URL.Path, r.Host + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"key":"sk-orca-x","scope":"api"}`)) + })) + defer srv.Close() + + _, err := Exchange(context.Background(), srv.Client(), srv.URL, "code", "verifier", "api") + require.NoError(t, err) + assert.Equal(t, "/api/v1/auth/keys", gotPath) + assert.False(t, strings.HasPrefix(gotPath, "/v1/"), "the exchange path is not below /v1") + assert.NotContains(t, gotHost, "api.orcarouter.ai", "the exchange must not go to the inference origin") +} + +func TestAuthorizeURL(t *testing.T) { + raw := AuthorizeURL("https://www.orcarouter.ai", "http://127.0.0.1:5000/cb", "CHALLENGE", "STATE", "Chainloop", "api") + u, err := url.Parse(raw) + require.NoError(t, err) + assert.Equal(t, "/auth", u.Path) + q := u.Query() + assert.Equal(t, "http://127.0.0.1:5000/cb", q.Get("callback_url")) + assert.Equal(t, "CHALLENGE", q.Get("code_challenge")) + assert.Equal(t, "S256", q.Get("code_challenge_method")) + assert.Equal(t, "STATE", q.Get("state")) + assert.Equal(t, "Chainloop", q.Get("app_name")) + assert.Equal(t, "api", q.Get("scope")) + assert.NotContains(t, raw, "verifier") +} + +func TestVerifierAndStateAreFresh(t *testing.T) { + a, err := newVerifier() + require.NoError(t, err) + b, err := newVerifier() + require.NoError(t, err) + assert.NotEqual(t, a, b) + assert.Len(t, a, 43) + + s1, err := newState() + require.NoError(t, err) + s2, err := newState() + require.NoError(t, err) + assert.NotEqual(t, s1, s2) + + // The challenge is the unpadded base64url SHA-256 of the verifier. + sum := sha256.Sum256([]byte(a)) + assert.Equal(t, base64.RawURLEncoding.EncodeToString(sum[:]), challengeFor(a)) + assert.False(t, strings.Contains(challengeFor(a), "=")) + assert.Len(t, challengeFor(a), 43) +} + +func TestResolveOrigins(t *testing.T) { + t.Setenv(EnvSharedBaseURL, "") + t.Setenv(EnvAuthBaseURL, "") + t.Setenv(EnvAPIBaseURL, "") + assert.Equal(t, "https://www.orcarouter.ai", AuthOrigin()) + assert.Equal(t, "https://api.orcarouter.ai/v1", APIOrigin()) + + // A shared self-hosted base covers both, and the explicit overrides win. + t.Setenv(EnvSharedBaseURL, "https://orca.internal/") + assert.Equal(t, "https://orca.internal", AuthOrigin()) + assert.Equal(t, "https://orca.internal", APIOrigin()) + + t.Setenv(EnvAuthBaseURL, "https://login.internal") + t.Setenv(EnvAPIBaseURL, "https://relay.internal/v1") + assert.Equal(t, "https://login.internal", AuthOrigin()) + assert.Equal(t, "https://relay.internal/v1", APIOrigin()) +} + +func TestValidateOrigin(t *testing.T) { + cases := []struct { + name string + origin string + wantErr bool + }{ + {name: "https remote", origin: "https://www.orcarouter.ai"}, + {name: "http loopback", origin: "http://127.0.0.1:8080"}, + {name: "http localhost", origin: "http://localhost:1234"}, + {name: "http remote", origin: "http://evil.example.com", wantErr: true}, + {name: "no scheme", origin: "www.orcarouter.ai", wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateOrigin(tc.origin) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + }) + } +} + +// TestConnectDoesNotLeakVerifier proves the verifier never rides in the URL, +// which is what the tests above read. +func TestConnectDoesNotLeakVerifier(t *testing.T) { + f := newFakeAuthServer(t) + var seenAuthorizeURL string + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := Connect(ctx, ConnectOptions{ + AuthBase: f.URL, + Client: &http.Client{Timeout: 5 * time.Second}, + OpenBrowser: func(authorizeURL string) error { + seenAuthorizeURL = authorizeURL + u, _ := url.Parse(authorizeURL) + callback, _ := url.Parse(u.Query().Get("callback_url")) + state := u.Query().Get("state") + callback.RawQuery = url.Values{"code": {"c"}, "state": {state}}.Encode() + resp, err := http.Get(callback.String()) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + return nil + }, + }) + require.NoError(t, err) + + f.mu.Lock() + defer f.mu.Unlock() + require.Len(t, f.requests, 1) + verifier := f.requests[0].verif + require.NotEmpty(t, verifier) + assert.NotContains(t, seenAuthorizeURL, verifier, "the verifier must never leave the process") + assert.NotContains(t, fmt.Sprintf("%v", f.requests), "sk-orca-", "the server record holds no key") +} diff --git a/internal/orcarouter/provider.go b/internal/orcarouter/provider.go new file mode 100644 index 000000000..e3081303c --- /dev/null +++ b/internal/orcarouter/provider.go @@ -0,0 +1,106 @@ +// +// Copyright 2026 The Chainloop Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package orcarouter is the Chainloop client of OrcaRouter Cyber Inference, an +// OpenAI-compatible inference gateway with cyber models and gateway controls +// for security teams (https://www.orcarouter.ai/cyber). +// +// It holds the provider definition, the two credential adapters (a pasted API +// key and an OAuth 2.0 + PKCE login), the durable credential lifecycle, and +// the model catalog that both share. It is the single place where Chainloop +// talks to OrcaRouter, so no caller repeats the base URL, the environment +// variable, or the credential handling. +package orcarouter + +// Identity of the provider. The name is what users see in listings and docs; +// the ID is the stable key used in configuration. +const ( + // Name is the display name of the provider. + Name = "OrcaRouter Cyber Inference" + // ID is the stable identifier of the provider. + ID = "orcarouter" + // BaseURL is the inference API. It is OpenAI-compatible and takes a + // Bearer API key. + BaseURL = "https://api.orcarouter.ai/v1" + // AuthBaseURL is the origin of the consent screen and of the code + // exchange. Authentication and inference use different public origins, + // and the exchange path is /api/v1/auth/keys, not /v1/auth/keys. + AuthBaseURL = "https://www.orcarouter.ai" + // EnvKey is the environment variable that holds a pasted API key, the + // convention of the other model providers Chainloop configures. + EnvKey = "ORCAROUTER_API_KEY" + // KeyPrefix is the visible prefix of an OrcaRouter API key. It is a + // format check only, never a validity check. + KeyPrefix = "sk-orca-" + // DocsURL is the Cyber Inference provider page. + DocsURL = "https://www.orcarouter.ai/cyber" + // ModelsURL is the cyber tier model catalog. + ModelsURL = "https://www.orcarouter.ai/models?tier=cyber" + // ConsoleURL is where a user manages and revokes keys. + ConsoleURL = "https://www.orcarouter.ai/console/authorized-apps" +) + +// Models recommended by the provider documentation, in the order they are +// presented. They are model IDs of the OrcaRouter namespace; the values are +// recorded only for documentation and cold-start seeding, never as a substitute +// for the live catalog. +const ( + // DefaultModel routes automatically and works with any key. + DefaultModel = "orcarouter/auto" + // FallbackModel is the general-purpose fallback. + FallbackModel = "orcarouter/fusion" + + // CyberUltraModel (OrcaCyber Ultra) is the dedicated cyber model for + // authorized security teams, for security code scanning and multi-step + // reasoning over large code bases. It needs business verification. The ID + // keeps the namespace the live catalog publishes. + CyberUltraModel = "orca/orcacyber-ultra-1.0" + // CyberZeroModel (OrcaCyber Zero) is the OrcaCyber harness-driven cyber + // model. It needs business verification. + CyberZeroModel = "orca/orcacyber-zero-1.5" +) + +// Provider is the definition that the harness and documentation layers consume. +// It exists so those layers take the base URL and the key variable from here +// instead of hardcoding a second copy. +type Provider struct { + // ID is the stable identifier. + ID string + // Name is the display name. + Name string + // BaseURL is the OpenAI-compatible inference base URL. + BaseURL string + // EnvKey is the environment variable holding an API key, if any. + EnvKey string + // DefaultModel is the model used when the caller names none. + DefaultModel string +} + +// Default returns the OrcaRouter Cyber Inference provider definition. +func Default() Provider { + return Provider{ + ID: ID, + Name: Name, + BaseURL: BaseURL, + EnvKey: EnvKey, + DefaultModel: DefaultModel, + } +} + +// RecommendedModels returns the model IDs that the provider documentation +// presents to users, in presentation order. See ModelsURL for the live list. +func RecommendedModels() []string { + return []string{DefaultModel, CyberUltraModel, CyberZeroModel, FallbackModel} +}