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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ each one:
rejections, not silent success.
7. **No secret leakage.** Adapters receive `ServerCredential` but must never log/serialize `apiKey`.

`CONTRACT_VERSION = 2`. Any breaking change bumps it; registry asserts adapters match.
`CONTRACT_VERSION = 4`. Any breaking change bumps it; registry asserts adapters match.

## 3. Provider-registration shim (`shim/provider-shim.ts`)

Expand Down
37 changes: 22 additions & 15 deletions CAPABILITY-MATRIX.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,22 @@
Backend endpoints are **[WEB]** — confirm live per adapter. `pi api` = which built-in Pi API type the
adapter registers under (`oai` = `openai-completions`, `ant` = `anthropic-messages`).

| Backend | port | pi api | listModels | introspectLoaded | switchModel | loadUnload | auth | health | perModelCaps | streaming | discovery fingerprint |
|---|---|---|---|---|---|---|---|---|---|---|---|
| **Ollama** | 11434 | oai | ✅ `/api/tags`,`/v1/models` | ✅ `/api/ps` | ✅ implicit (request id) | ✅ `keep_alive:0` | ◐ none local | ✅ `GET /` text | ✅ `/api/show` caps + ctx | ✅ | `GET /` → `Ollama is running` |
| **LM Studio** | 1234 | oai | ✅ `/api/v1/models` (v0 fallback) | ✅ `state` field | ✅ JIT + `/api/v1/models/load` | ✅ load/unload + `lms` | ◐ Bearer, none default | ◐ infer 200 | ✅ type+`max_context_length` | ✅ | `/api/v1/models` (v0 fallback) w/ `state`,`compatibility_type` |
| **llama-server** | 8080 | oai | ✅ `/v1/models` | ◐ `/props`,`/slots` (single) | ❌ (1/instance) | ❌ classic | ◐ none / `--api-key` | ✅ `/health` | ◐ ctx via `meta.n_ctx`, router args, `/props` | ✅ | `/props` w/ `default_generation_settings`+`build_info` |
| **llama-swap** | 8080 | oai/ant | ✅ `/v1/models` (all config) | ✅ `/running` | ✅ via `model` → restart upstream | ✅ `/api/models/unload`, ttl | ◐ optional multi-scheme | ✅ `/health`→OK | ◐ ctx via `context_length`; output unknown | ✅ | `/` → `/ui/`; `/running`,`/upstream/{model}` |
| **vLLM** | 8000 | oai | ✅ `/v1/models` | ◐ `/is_sleeping` (dev) | ❌ base · ◐ LoRA | ◐ sleep/wake + LoRA | ◐ none / `--api-key` | ✅ `/health` | ◐ `max_model_len` only | ✅ | `/version` + `/metrics` `vllm:` + `owned_by:"vllm"` |
| **OpenAI** | cloud | oai | ✅ `/v1/models` | ❌ | ✅ (pick id) | ❌ managed | ✅ Bearer | ❌ (status page) | ❌ (static table needed) | ✅ | n/a (configured, not probed) |
| **Anthropic** | cloud | ant | ✅ `/v1/models` | ❌ | ✅ (pick id) | ❌ managed | ✅ x-api-key+version | ❌ | ✅ caps + `max_input_tokens` | ✅ | n/a |
| **TabbyAPI** | 5000 | oai | ✅ `/v1/model/list` | ✅ `/v1/model` | ✅ load | ✅ `/v1/model/{load,unload}` | ✅ x-api-key/x-admin-key | ◐ | ◐ | ✅ | `/v1/model/*` + `x-admin-key` |
| **KoboldCpp** | 5001 | oai | ✅ `/v1/models` | ✅ `/api/v1/model` | ❌ (1 GGUF) | ❌ | ◐ `--password` | ✅ `/api/extra/version` | ◐ | ✅ | `/api/extra/version`→`{"result":"KoboldCpp"}` |
| **oobabooga** | 5000 | oai | ✅ `/v1/models` | ✅ `/v1/internal/model/info` | ✅ load | ✅ `/v1/internal/model/{load,unload}` | ◐ `--api-key` | ◐ | ◐ | ✅ | `/v1/internal/*` namespace |
| **Jan** | 1337 | oai | ✅ `/v1/models` | ◐ | ◐ engine | ◐ engine | ◐ Bearer | ❌ | ◐ | ✅ | weak (log line) |
| **llamafile** | 8080 | oai | ✅ `/v1/models` | ◐ `/props` | ❌ | ❌ | ◐ `--api-key` | ✅ `/health` | ◐ via `/props` | ✅ | `/props` w/ non-`bNNNN` build_info |
| **generic OpenAI-compat** | varies | oai | ✅ `/v1/models` | ❌ | ❌ | ❌ | ◐ optional Bearer | ◐ | ◐ | ✅ | anything serving `/v1/models` (fallback) |
| Backend | port | pi api | listModels | introspectLoaded | switchModel | loadUnload | autoLoadStatus | auth | health | perModelCaps | streaming | discovery fingerprint |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| **Ollama** | 11434 | oai | ✅ `/api/tags`,`/v1/models` | ✅ `/api/ps` | ✅ implicit (request id) | ✅ `keep_alive:0` | ❌ (always on) | ◐ none local | ✅ `GET /` text | ✅ `/api/show` caps + ctx | ✅ | `GET /` → `Ollama is running` |
| **LM Studio** | 1234 | oai | ✅ `/api/v1/models` (v0 fallback) | ✅ `state` field | ✅ JIT + `/api/v1/models/load` | ✅ load/unload + `lms` | ❌ (always on) | ◐ Bearer, none default | ◐ infer 200 | ✅ type+`max_context_length` | ✅ | `/api/v1/models` (v0 fallback) w/ `state`,`compatibility_type` |
| **llama-server** | 8080 | oai | ✅ `/v1/models` | ◐ `/props`,`/slots` (single) | ❌ (1/instance) | ❌ classic | ❌ (always on) | ◐ none / `--api-key` | ✅ `/health` | ◐ ctx via `meta.n_ctx`, router args, `/props` | ✅ | `/props` w/ `default_generation_settings`+`build_info` |
| **llama-swap** | 8080 | oai/ant | ✅ `/v1/models` (all config) | ✅ `/running` | ✅ via `model` → restart upstream | ✅ `/api/models/unload`, ttl | ❌ (always on) | ◐ optional multi-scheme | ✅ `/health`→OK | ◐ ctx via `context_length`; output unknown | ✅ | `/` → `/ui/`; `/running`,`/upstream/{model}` |
| **vLLM** | 8000 | oai | ✅ `/v1/models` | ◐ `/is_sleeping` (dev) | ❌ base · ◐ LoRA | ◐ sleep/wake + LoRA | ❌ | ◐ none / `--api-key` | ✅ `/health` | ◐ `max_model_len` only | ✅ | `/version` + `/metrics` `vllm:` + `owned_by:"vllm"` |
| **OpenAI** | cloud | oai | ✅ `/v1/models` | ❌ | ✅ (pick id) | ❌ managed | ❌ | ✅ Bearer | ❌ (status page) | ❌ (static table needed) | ✅ | n/a (configured, not probed) |
| **Anthropic** | cloud | ant | ✅ `/v1/models` | ❌ | ✅ (pick id) | ❌ managed | ❌ | ✅ x-api-key+version | ❌ | ✅ caps + `max_input_tokens` | ✅ | n/a |
| **TabbyAPI** | 5000 | oai | ✅ `/v1/model/list` | ✅ `/v1/model` | ✅ load | ✅ `/v1/model/{load,unload}` | ❌ | ✅ x-api-key/x-admin-key | ◐ | ◐ | ✅ | `/v1/model/*` + `x-admin-key` |
| **KoboldCpp** | 5001 | oai | ✅ `/v1/models` | ✅ `/api/v1/model` | ❌ (1 GGUF) | ❌ | ❌ | ◐ `--password` | ✅ `/api/extra/version` | ◐ | ✅ | `/api/extra/version`→`{"result":"KoboldCpp"}` |
| **oobabooga** | 5000 | oai | ✅ `/v1/models` | ✅ `/v1/internal/model/info` | ✅ load | ✅ `/v1/internal/model/{load,unload}` | ❌ | ◐ `--api-key` | ◐ | ◐ | ✅ | `/v1/internal/*` namespace |
| **Jan** | 1337 | oai | ✅ `/v1/models` | ◐ | ◐ engine | ◐ engine | ❌ | ◐ Bearer | ❌ | ◐ | ✅ | weak (log line) |
| **llamafile** | 8080 | oai | ✅ `/v1/models` | ◐ `/props` | ❌ | ❌ | ❌ (always on) | ◐ `--api-key` | ✅ `/health` | ◐ via `/props` | ✅ | `/props` w/ non-`bNNNN` build_info |
| **Unsloth Studio** | 8888 | oai | ✅ `/v1/models` (keyed) | ✅ per-model `loaded` field | ❌ (UI-only) | ❌ (UI-only) | ✅ `/api/settings/openai-auto-switch` | ✅ Bearer `sk-unsloth-…` (required) | ❌ (poll via listModels) | ◐ ctx fields only while loaded | ✅ | `Server: unsloth-studio` header (every response) |
| **generic OpenAI-compat** | varies | oai | ✅ `/v1/models` | ❌ | ❌ | ❌ | ❌ | ◐ optional Bearer | ◐ | ◐ | ✅ | anything serving `/v1/models` (fallback) |

## Capability-driven UX rules (derived)

Expand All @@ -33,6 +34,12 @@ adapter registers under (`oai` = `openai-completions`, `ant` = `anthropic-messag
key for inference (a `401` on `/v1/chat/completions` but `200` on `/v1/models` ⇒ "running but keyed").
- **loadUnload ✅** (Ollama, LM Studio, TabbyAPI, oobabooga, llama-swap) → expose explicit load/unload;
elsewhere degrade to implicit-on-use (Ollama) or nothing.
- **autoLoadStatus ✅** (Unsloth Studio) → when the backend answers `false`, mark not-loaded models
with ○ + "no auto-load" in the picker, add a footnote pointing at the backend's own load UI,
and make them non-confirmable (Enter is swallowed with an explanation); if *no* model is loaded
the picker doesn't open at all and the notification says so. `undefined` (unknown/older versions)
never marks or blocks. Backends that always auto-load (Ollama, LM Studio, llama-server,
llama-swap, llamafile) need no query — they are never marked.

## Discovery probe order (cheapest/most-specific first)

Expand Down
79 changes: 79 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
## Fix: contextWindow and maxTokens not read from OpenAI-compatible servers

### maxTokens Issue

**Problem**: The generic OpenAI-compatible adapter always used a hardcoded default of 4096 for `maxTokens`, even when servers reported `max_completion_tokens` in the `/v1/models` response. This was way too small for modern models with large context windows.

**Fix**:
1. First determine the `contextWindow` (checking `context_window`, `max_model_len`, `context_length`, `max_context_length`)
2. Try to read `max_completion_tokens` from the server response
3. If the server provides it, use that value
4. Otherwise, default to **half the context window** (reasonable balance between input and output)

### contextWindow Issue

### Problem

The generic OpenAI-compatible adapter in `src/adapters/generic.ts` only checks for these context-window fields in the `/v1/models` response:

- `context_window`
- `context_length`
- `max_context_length`

Many servers (e.g. **omlx**) report the value under **`max_model_len`** instead. When this field is encountered, `contextWindow` stays `undefined` and a default fallback is used, giving wrong values to the user.

### Fix

In `src/adapters/generic.ts`:

1. **Add `max_model_len` to the response type** so TypeScript recognizes the field.
2. **Insert `max_model_len` into the context-window detection chain** as the second check (after `context_window`, before `context_length` and `max_context_length`).

### Files changed

- `src/adapters/generic.ts`

### Diff

```diff
--- a/src/adapters/generic.ts
+++ b/src/adapters/generic.ts
@@ -122,7 +122,7 @@
headers["Authorization"] = `Bearer ${cred.apiKey}`;
}

- const body = r.json as { data?: Array<{ id?: unknown; max_completion_tokens?: number; context_length?: number; max_context_length?: number; context_window?: number }> } | undefined;
+ const body = r.json as { data?: Array<{ id?: unknown; max_completion_tokens?: number; max_model_len?: number; context_length?: number; max_context_length?: number; context_window?: number }> } | undefined;
if (!Array.isArray(body?.data)) return [];

return body.data
@@ -135,13 +135,18 @@
const contextWindow =
typeof item.context_window === "number" && item.context_window > 0
? item.context_window
+ : typeof item.max_model_len === "number" && item.max_model_len > 0
+ ? item.max_model_len
: typeof item.context_length === "number" && item.context_length > 0
? item.context_length
: typeof item.max_context_length === "number" && item.max_context_length > 0
? item.max_context_length
: undefined;

+ const finalContextWindow = contextWindow ?? DEFAULT_CONTEXT_WINDOW;
+
const maxTokens =
typeof item.max_completion_tokens === "number" && item.max_completion_tokens > 0
? item.max_completion_tokens
+ : Math.floor(finalContextWindow / 2);

return {
id: item.id,
name: item.id,
- contextWindow: contextWindow ?? DEFAULT_CONTEXT_WINDOW,
- maxTokens: DEFAULT_MAX_TOKENS,
+ contextWindow: finalContextWindow,
+ maxTokens,
input: ["text"],
reasoning: false,
embeddings: isEmbedding,
```
45 changes: 0 additions & 45 deletions package-lock.json

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

41 changes: 36 additions & 5 deletions src/adapters/generic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ class GenericAdapter implements BackendAdapter {

/**
* GET /v1/models → map data[].id to ModelDescriptor.
* Conservative defaults are applied (contextWindow 8192, maxTokens 4096, input ["text"]).
* Conservative defaults are applied (contextWindow 8192, maxTokens = half of contextWindow, input ["text"]).
* Common embedding/reranking model families are excluded from chat registration.
* Throws on non-ok / 401 / status:0.
*/
Expand All @@ -112,21 +112,52 @@ class GenericAdapter implements BackendAdapter {
if (r.status === 0) throw new Error("listModels failed: server unreachable (status 0)");
if (!r.ok) throw new Error(`listModels failed: HTTP ${r.status}`);

const body = r.json as { data?: Array<{ id?: unknown }> } | undefined;
interface GenericModelEntry {
id?: unknown;
max_completion_tokens?: number;
max_model_len?: number;
context_length?: number;
max_context_length?: number;
context_window?: number;
}
interface GenericModelEntryWithId extends GenericModelEntry {
id: string;
}

const body = r.json as { data?: GenericModelEntry[] } | undefined;
if (!Array.isArray(body?.data)) return [];

return body.data
.filter((item): item is { id: string } => typeof item?.id === "string")
.filter((item): item is GenericModelEntryWithId => typeof item?.id === "string")
.map((item): ModelDescriptor => {
const normalizedId = item.id.toLowerCase();
const isEmbedding =
/(^|[/:._-])(embed|embedding|bge|gte|e5|reranker)([/:._-]|$)/.test(normalizedId) ||
normalizedId.includes("nomic-embed");

const contextWindow =
typeof item.context_window === "number" && item.context_window > 0
? item.context_window
: typeof item.max_model_len === "number" && item.max_model_len > 0
? item.max_model_len
: typeof item.context_length === "number" && item.context_length > 0
? item.context_length
: typeof item.max_context_length === "number" && item.max_context_length > 0
? item.max_context_length
: undefined;

const finalContextWindow = contextWindow ?? DEFAULT_CONTEXT_WINDOW;

const maxTokens =
typeof item.max_completion_tokens === "number" && item.max_completion_tokens > 0
? item.max_completion_tokens
: Math.floor(finalContextWindow / 2);

return {
id: item.id,
name: item.id,
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
contextWindow: finalContextWindow,
maxTokens,
input: ["text"],
reasoning: false,
embeddings: isEmbedding,
Expand Down
3 changes: 3 additions & 0 deletions src/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { vllmAdapter } from "./vllm.ts";
import { omlxAdapter } from "./omlx.ts";
import { openaiAdapter } from "./openai.ts";
import { anthropicAdapter } from "./anthropic.ts";
import { unslothAdapter } from "./unsloth.ts";
import { genericAdapter } from "./generic.ts";

/** Every adapter Crossbar ships. */
Expand All @@ -31,6 +32,7 @@ export const ADAPTERS: readonly BackendAdapter[] = [
omlxAdapter,
openaiAdapter,
anthropicAdapter,
unslothAdapter,
genericAdapter,
];

Expand Down Expand Up @@ -63,5 +65,6 @@ export {
omlxAdapter,
openaiAdapter,
anthropicAdapter,
unslothAdapter,
genericAdapter,
};
Loading