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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .gitkeep

This file was deleted.

2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Link.Assistant.Router is a transparent proxy that sits between API clients (such
- **Supports Claude MAX (OAuth)** by reading Claude Code session credentials
- **OpenAI-compatible endpoints** — `/v1/chat/completions`, `/v1/responses`, `/v1/models` translate to Anthropic or forward to a configured OpenAI-compatible provider
- **Optional Gonka upstream** — `UPSTREAM_PROVIDER=gonka` forwards OpenAI-compatible routes to Gonka instead of translating them to Anthropic
- **Optional Crater ForgeFed upstream** — `UPSTREAM_PROVIDER=crater` turns OpenAI chat requests into ForgeFed `Offer{Ticket}` tasks and waits for resolved task results
- **Optional LiteLLM/OpenAI-compatible upstream** — `UPSTREAM_PROVIDER=openai-compatible` routes OpenAI SDK traffic to a stored provider such as LiteLLM
- **Multi-account routing** — pool any number of Claude MAX accounts; round-robin / priority / least-used; automatic cooldowns on 429
- **Issues custom `la_sk_...` JWT tokens** with expiration and revocation for multi-tenant access
Expand Down Expand Up @@ -54,6 +55,11 @@ router forwards OpenAI-compatible requests to the configured provider, such as
a LiteLLM proxy, and substitutes only the upstream provider key inside the
router.

When `UPSTREAM_PROVIDER=crater`, `/v1/chat/completions` accepts normal OpenAI
chat requests, delivers a ForgeFed `Offer` containing a `Ticket` to
`CRATER_FORGEFED_INBOX`, reads `Accept.result`, polls that task URI until
`isResolved:true`, and maps the resolved content back to OpenAI JSON or SSE.

## Quick Start

### Prerequisites
Expand Down Expand Up @@ -227,7 +233,7 @@ Claude Code will work exactly as normal, with all requests transparently proxied

| Endpoint | Method | Description |
|---|---|---|
| `/v1/chat/completions` | POST | Chat Completions, translated to Anthropic Messages or forwarded to the selected OpenAI-compatible provider |
| `/v1/chat/completions` | POST | Chat Completions, translated to Anthropic Messages, forwarded to the selected OpenAI-compatible provider, or delivered as a Crater ForgeFed task |
| `/v1/responses` | POST | Responses API, translated to Anthropic Messages or forwarded to the selected OpenAI-compatible provider |
| `/v1/models` | GET | OpenAI-shaped model list |

Expand All @@ -243,6 +249,11 @@ provider base URL to the LiteLLM `/v1` API base. Streaming OpenAI requests are
passed through for OpenAI-compatible providers, and Anthropic-backed streaming
requests are translated to OpenAI SSE chunks.

With `UPSTREAM_PROVIDER=crater`, `/v1/chat/completions` supports normal JSON
responses and SSE with either request-body `"stream": true` or `?stream=true`.
The SSE stream emits OpenAI chat-completion chunks once the ForgeFed task
resolves.

### MPP charges for OpenAI endpoints

The OpenAI-compatible endpoints can advertise Machine Payments Protocol (MPP)
Expand Down Expand Up @@ -354,7 +365,7 @@ Every flag listed in `--help` has an env-var alias and can be configured from
| `--port` / `ROUTER_PORT` | `8080` | No | Port to listen on |
| `--host` / `ROUTER_HOST` | `0.0.0.0` | No | Host/IP to bind to |
| `--claude-code-home` / `CLAUDE_CODE_HOME` | `~/.claude` | No | Primary Claude Code credentials directory |
| `--upstream-provider` / `UPSTREAM_PROVIDER` | `anthropic` | No | Upstream provider: `anthropic`, `gonka`, or `openai-compatible` |
| `--upstream-provider` / `UPSTREAM_PROVIDER` | `anthropic` | No | Upstream provider: `anthropic`, `gonka`, `crater`, or `openai-compatible` |
| `--upstream-base-url` / `UPSTREAM_BASE_URL` | `https://api.anthropic.com` | No | Upstream Anthropic API URL |
| `--api-format` / `UPSTREAM_API_FORMAT` | (auto) | No | Restrict the proxy to `anthropic` / `bedrock` / `vertex` |
| `--verbose` / `VERBOSE` | `false` | No | Verbose tracing |
Expand Down Expand Up @@ -383,6 +394,29 @@ Your Gonka account must be activated for inference, funded, and have a
published on-chain public key. Participant registration is only needed for
hosting.

### Crater ForgeFed provider

Crater support is optional. It keeps router-issued `la_sk_...` tokens at the
edge, then uses ForgeFed to submit work to a remote ticket tracker or exchange.

```env
TOKEN_SECRET=your-router-token-secret

UPSTREAM_PROVIDER=crater
CRATER_FORGEFED_INBOX=https://tracker.example/inbox
CRATER_FORGEFED_TARGET=https://tracker.example/projects/demo
# Optional; defaults to ACTIVITYPUB_ACTOR_BASE_URL/actor/code
CRATER_FORGEFED_ACTOR=https://router.example/actor/code
```

| Flag / env | Default | Required | Description |
|---|---|---|---|
| `--crater-forgefed-inbox` / `CRATER_FORGEFED_INBOX` | — | Yes, for Crater | Remote ForgeFed inbox that receives `Offer{Ticket}` activities |
| `--crater-forgefed-actor` / `CRATER_FORGEFED_ACTOR` | `${ACTIVITYPUB_ACTOR_BASE_URL}/actor/code` | No | Local actor URI used in outbound activities |
| `--crater-forgefed-target` / `CRATER_FORGEFED_TARGET` | inbox URI | No | Ticket tracker or project URI used as `Offer.target` |
| `--crater-poll-interval-ms` / `CRATER_POLL_INTERVAL_MS` | `1000` | No | Delay between task URI polls |
| `--crater-poll-timeout-secs` / `CRATER_POLL_TIMEOUT_SECS` | `120` | No | Maximum wait for `isResolved:true` |

### OpenAI-compatible / LiteLLM provider

Generic OpenAI-compatible providers are used when
Expand Down Expand Up @@ -765,6 +799,7 @@ This demonstrates token issuance, validation, and revocation programmatically.
│ ├── main.rs # Binary entry point — Cli dispatch + server setup
│ ├── cli.rs # `lino-arguments`-based CLI parser + subcommands
│ ├── config.rs # CLI/env/.lenv configuration
│ ├── crater.rs # Crater ForgeFed task provider
│ ├── oauth.rs # Claude Code OAuth credential reader
│ ├── accounts.rs # Multi-account router (round-robin/priority/least-used + cooldowns)
│ ├── storage.rs # Persistent token store (text Lino + binary backends)
Expand Down
2 changes: 2 additions & 0 deletions changelog.d/20260512_093900_crater_provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
### Added
- Add an optional Crater ForgeFed upstream provider for OpenAI chat completions, including `Offer{Ticket}` delivery, `Accept.result` polling, SSE responses, and `TaskProvider` backend abstraction.
28 changes: 28 additions & 0 deletions docs/forgefed.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,31 @@ curl -H 'Accept: application/activity+json' \

Submit that activity to a compatible ForgeFed inbox when a remote problem source
requires an explicit follow request.

## Crater Provider

Set `UPSTREAM_PROVIDER=crater` to make `/v1/chat/completions` submit outbound
ForgeFed tasks instead of translating to Anthropic. The router builds an
`Offer` activity whose `object` is a ForgeFed `Ticket`, posts it to
`CRATER_FORGEFED_INBOX`, reads the remote `Accept.result` task URI, polls that
URI until the task has `isResolved: true`, then returns the resolved content in
OpenAI Chat Completions JSON or SSE format.

Required:

```bash
export UPSTREAM_PROVIDER=crater
export CRATER_FORGEFED_INBOX=https://tracker.example/inbox
```

Optional:

```bash
export CRATER_FORGEFED_TARGET=https://tracker.example/projects/demo
export CRATER_FORGEFED_ACTOR=https://router.example.com/actor/code
export CRATER_POLL_INTERVAL_MS=1000
export CRATER_POLL_TIMEOUT_SECS=120
```

When `CRATER_FORGEFED_ACTOR` is omitted, it defaults to
`${ACTIVITYPUB_ACTOR_BASE_URL}/actor/code`.
65 changes: 64 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#![allow(clippy::struct_excessive_bools)]

use std::path::PathBuf;
use std::time::Duration;

use clap::Subcommand;
use lino_arguments::Parser as LinoParser;
Expand Down Expand Up @@ -90,7 +91,7 @@ pub struct Cli {
#[arg(long, env = "CLAUDE_CLI_BIN", global = true)]
pub claude_cli_bin: Option<PathBuf>,

/// Upstream provider: anthropic, gonka, or openai-compatible.
/// Upstream provider: anthropic, gonka, crater, or openai-compatible.
#[arg(
long,
env = "UPSTREAM_PROVIDER",
Expand Down Expand Up @@ -121,6 +122,36 @@ pub struct Cli {
)]
pub gonka_model: String,

/// Remote `ForgeFed` inbox for the crater provider.
#[arg(long, env = "CRATER_FORGEFED_INBOX", global = true)]
pub crater_forgefed_inbox: Option<String>,

/// Local actor URI used by the crater provider.
#[arg(long, env = "CRATER_FORGEFED_ACTOR", global = true)]
pub crater_forgefed_actor: Option<String>,

/// Remote ticket tracker or project URI used as the `ForgeFed` `Offer` target.
#[arg(long, env = "CRATER_FORGEFED_TARGET", global = true)]
pub crater_forgefed_target: Option<String>,

/// Delay between crater task-resolution polls.
#[arg(
long,
env = "CRATER_POLL_INTERVAL_MS",
default_value_t = 1000,
global = true
)]
pub crater_poll_interval_ms: u64,

/// Maximum seconds to wait for crater task resolution.
#[arg(
long,
env = "CRATER_POLL_TIMEOUT_SECS",
default_value_t = 120,
global = true
)]
pub crater_poll_timeout_secs: u64,

/// Stored provider name for generic OpenAI-compatible upstream routing.
#[arg(
long,
Expand Down Expand Up @@ -321,6 +352,27 @@ impl Cli {
.activitypub_actor_base_url
.clone()
.unwrap_or_else(|| format!("http://{}:{}", self.host, self.port));
let crater_actor = self
.crater_forgefed_actor
.clone()
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
format!(
"{}/actor/code",
activitypub_actor_base_url.trim_end_matches('/')
)
});
let crater = crate::crater::CraterConfig::new(
self.crater_forgefed_inbox
.clone()
.filter(|value| !value.is_empty()),
&crater_actor,
self.crater_forgefed_target
.clone()
.filter(|value| !value.is_empty()),
Duration::from_millis(self.crater_poll_interval_ms),
Duration::from_secs(self.crater_poll_timeout_secs),
);
let activitypub_public_key_pem = self
.activitypub_public_key_pem
.clone()
Expand Down Expand Up @@ -358,6 +410,7 @@ impl Cli {
gonka_private_key: self.gonka_private_key.clone().filter(|s| !s.is_empty()),
gonka_source_url: self.gonka_source_url.clone(),
gonka_model: self.gonka_model.clone(),
crater,
openai_compatible,
activitypub_actor_base_url,
activitypub_public_key_pem,
Expand Down Expand Up @@ -404,6 +457,11 @@ mod tests {
gonka_private_key: None,
gonka_source_url: default_gonka_source_url(),
gonka_model: default_gonka_model(),
crater_forgefed_inbox: None,
crater_forgefed_actor: None,
crater_forgefed_target: None,
crater_poll_interval_ms: 1000,
crater_poll_timeout_secs: 120,
openai_compatible_provider_name: "litellm".into(),
openai_compatible_base_url: default_openai_compatible_base_url(),
openai_compatible_api_key: None,
Expand Down Expand Up @@ -452,6 +510,11 @@ mod tests {
gonka_private_key: None,
gonka_source_url: default_gonka_source_url(),
gonka_model: default_gonka_model(),
crater_forgefed_inbox: None,
crater_forgefed_actor: None,
crater_forgefed_target: None,
crater_poll_interval_ms: 1000,
crater_poll_timeout_secs: 120,
openai_compatible_provider_name: "litellm".into(),
openai_compatible_base_url: default_openai_compatible_base_url(),
openai_compatible_api_key: None,
Expand Down
Loading
Loading