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
12 changes: 12 additions & 0 deletions .changeset/neon-ai-gateway-models.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@upstash/box": patch
"@upstash/box-cli": patch
---

Add Neon AI Gateway models: `NeonModel` (`neon/<neon-short-id>`) for the Codex and
OpenCode harnesses. `inferDefaultProvider` routes `neon/gpt-*` to Codex, except
`neon/gpt-oss-*`, and every other Neon model to OpenCode (GPT OSS, Claude, Gemini,
Llama, Qwen, Kimi, GLM, Grok). Neon's Claude models run on OpenCode only, because Neon
serves them through chat completions and not the Responses API. Box does not run
Claude Code with Neon. The CLI model picker lists them for Codex and OpenCode.
Runs use the Neon credential (token and branch base URL) saved in the console.
20 changes: 20 additions & 0 deletions packages/cli/src/__tests__/commands/create-wizard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,26 @@ describe("createWizard", () => {
});
});

it("Neon model → stored credential without asking for a key", async () => {
vi.mocked(interactiveSelect)
.mockResolvedValueOnce("node") // runtime
.mockResolvedValueOnce("agent") // action: configure agent
.mockResolvedValueOnce("opencode") // provider
.mockResolvedValueOnce("neon/claude-haiku-4-5") // model
.mockResolvedValueOnce("create"); // action: create (no key prompt in between)

const result = await createWizard();

expect(result).toEqual({
runtime: "node",
agentModel: "neon/claude-haiku-4-5",
agentProvider: "opencode",
agentApiKey: "stored",
});
// runtime, action, provider, model, action: the key choice was never shown.
expect(vi.mocked(interactiveSelect)).toHaveBeenCalledTimes(5);
});

it("stored key option → agentApiKey is 'stored'", async () => {
vi.mocked(interactiveSelect)
.mockResolvedValueOnce("node") // runtime
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/src/__tests__/models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Agent,
ClaudeCode,
CursorModel,
NeonModel,
OpenAICodex,
OpenCodeModel,
OpenRouterModel,
Expand Down Expand Up @@ -88,6 +89,44 @@ describe("MODEL_OPTIONS_BY_AGENT", () => {
expect(group?.options).toContainEqual({ value, label });
});

it("offers Neon AI Gateway models only to Codex and OpenCode", () => {
const groupFor = (agent: Agent) =>
MODEL_OPTIONS_BY_AGENT[agent].find(({ label }) => label === "Neon AI Gateway");

expect(groupFor(Agent.ClaudeCode)).toBeUndefined();
expect(groupFor(Agent.Cursor)).toBeUndefined();
expect(groupFor(Agent.Codex)?.options).toContainEqual({
value: NeonModel.GPT_5_5,
label: "GPT-5.5 (Neon)",
});
expect(groupFor(Agent.OpenCode)?.options).toContainEqual({
value: NeonModel.Gemini_3_6_Flash,
label: "Gemini 3.6 Flash (Neon)",
});
});

it("splits Neon models by endpoint support", () => {
const values = (agent: Agent) =>
MODEL_OPTIONS_BY_AGENT[agent]
.find(({ label }) => label === "Neon AI Gateway")
?.options.map((o) => o.value) ?? [];

// Responses-only on Neon: Codex yes, OpenCode no.
expect(values(Agent.Codex)).toContain(NeonModel.GPT_5_5_Pro);
expect(values(Agent.Codex)).toContain(NeonModel.GPT_5_3_Codex);
expect(values(Agent.OpenCode)).not.toContain(NeonModel.GPT_5_5_Pro);
expect(values(Agent.OpenCode)).not.toContain(NeonModel.GPT_5_3_Codex);
// Chat-only on Neon: OpenCode yes, Codex no.
expect(values(Agent.OpenCode)).toContain(NeonModel.GPT_OSS_120B);
expect(values(Agent.OpenCode)).toContain(NeonModel.Kimi_K3);
expect(values(Agent.Codex)).not.toContain(NeonModel.GPT_OSS_120B);
expect(values(Agent.Codex)).not.toContain(NeonModel.Kimi_K3);
// Claude on Neon: chat completions only, so OpenCode yes, Codex no.
expect(values(Agent.OpenCode)).toContain(NeonModel.Claude_Haiku_4_5);
expect(values(Agent.OpenCode)).toContain(NeonModel.Claude_Opus_5_5);
expect(values(Agent.Codex).some((v) => v.startsWith("neon/claude-"))).toBe(false);
});

it("includes Cursor models", () => {
const cursorModels = MODEL_OPTIONS_BY_AGENT[Agent.Cursor].flatMap((group) => group.options);

Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/commands/create-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,14 @@ async function configureAgent(result: Partial<CreateFlags>): Promise<boolean> {
result.agentModel = model;
result.agentProvider = provider;

// Neon runs only with the stored credential (token plus branch gateway URL):
// the backend rejects both the Upstash-managed key and a raw key for neon/.
if (model.startsWith("neon/")) {
result.agentApiKey = "stored";
console.log(dim(" Neon AI Gateway uses your stored Neon credential (token and gateway URL)."));
return true;
}

const keyOption = await interactiveSelect({
prompt: cyan("Agent API key:"),
items: API_KEY_OPTIONS,
Expand Down
60 changes: 60 additions & 0 deletions packages/cli/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
CursorModel,
OpenAICodex,
OpenCodeModel,
NeonModel,
OpenRouterModel,
VercelModel,
} from "@upstash/box";
Expand Down Expand Up @@ -144,6 +145,25 @@ export const MODEL_OPTIONS_BY_AGENT: Record<
{ value: VercelModel.Claude_Sonnet_4_6, label: "Claude Sonnet 4.6 (Vercel)" },
],
},
{
// Responses API models only: Codex cannot use Neon's chat-only models.
label: "Neon AI Gateway",
options: [
Comment thread
alitariksahin marked this conversation as resolved.
{ value: NeonModel.GPT_6_Astra, label: "GPT-6 Astra (Neon)" },
{ value: NeonModel.GPT_5_6_Sol, label: "GPT-5.6 Sol (Neon)" },
{ value: NeonModel.GPT_5_6_Terra, label: "GPT-5.6 Terra (Neon)" },
{ value: NeonModel.GPT_5_6_Luna, label: "GPT-5.6 Luna (Neon)" },
{ value: NeonModel.GPT_5_5, label: "GPT-5.5 (Neon)" },
{ value: NeonModel.GPT_5_5_Pro, label: "GPT-5.5 Pro (Neon)" },
{ value: NeonModel.GPT_5_4, label: "GPT-5.4 (Neon)" },
{ value: NeonModel.GPT_5_4_Mini, label: "GPT-5.4 Mini (Neon)" },
{ value: NeonModel.GPT_5_4_Nano, label: "GPT-5.4 Nano (Neon)" },
{ value: NeonModel.GPT_5_3_Codex, label: "GPT-5.3 Codex (Neon)" },
{ value: NeonModel.GPT_5_Mini, label: "GPT-5 Mini (Neon)" },
{ value: NeonModel.GPT_5_Nano, label: "GPT-5 Nano (Neon)" },
{ value: NeonModel.Grok_4_6, label: "Grok 4.6 (Neon)" },
],
},
],
[Agent.OpenCode]: [
{
Expand Down Expand Up @@ -259,6 +279,46 @@ export const MODEL_OPTIONS_BY_AGENT: Record<
{ value: VercelModel.Grok_4_3, label: "Grok 4.3 (Vercel)" },
],
},
{
// Chat-completions models only: GPT-5.5 Pro and GPT-5.3 Codex are Responses-only on Neon.
label: "Neon AI Gateway",
options: [
{ value: NeonModel.GPT_6_Astra, label: "GPT-6 Astra (Neon)" },
{ value: NeonModel.GPT_5_6_Sol, label: "GPT-5.6 Sol (Neon)" },
{ value: NeonModel.GPT_5_6_Terra, label: "GPT-5.6 Terra (Neon)" },
{ value: NeonModel.GPT_5_6_Luna, label: "GPT-5.6 Luna (Neon)" },
{ value: NeonModel.GPT_5_5, label: "GPT-5.5 (Neon)" },
{ value: NeonModel.GPT_5_4, label: "GPT-5.4 (Neon)" },
{ value: NeonModel.GPT_5_4_Mini, label: "GPT-5.4 Mini (Neon)" },
{ value: NeonModel.GPT_5_4_Nano, label: "GPT-5.4 Nano (Neon)" },
{ value: NeonModel.GPT_5_Mini, label: "GPT-5 Mini (Neon)" },
{ value: NeonModel.GPT_5_Nano, label: "GPT-5 Nano (Neon)" },
{ value: NeonModel.GPT_OSS_120B, label: "GPT OSS 120B (Neon)" },
{ value: NeonModel.GPT_OSS_20B, label: "GPT OSS 20B (Neon)" },
{ value: NeonModel.Gemini_3_6_Flash, label: "Gemini 3.6 Flash (Neon)" },
{ value: NeonModel.Gemini_3_5_Flash, label: "Gemini 3.5 Flash (Neon)" },
{ value: NeonModel.Gemini_3_5_Flash_Lite, label: "Gemini 3.5 Flash Lite (Neon)" },
{ value: NeonModel.Gemini_3_1_Pro, label: "Gemini 3.1 Pro (Neon)" },
{ value: NeonModel.Grok_4_6, label: "Grok 4.6 (Neon)" },
{ value: NeonModel.Kimi_K3, label: "Kimi K3 (Neon)" },
{ value: NeonModel.GLM_5_2, label: "GLM-5.2 (Neon)" },
{ value: NeonModel.Qwen3_5_122B, label: "Qwen3.5 122B (Neon)" },
{ value: NeonModel.Llama_4_Maverick, label: "Llama 4 Maverick (Neon)" },
{ value: NeonModel.Claude_Opus_5_5, label: "Claude Opus 5.5 (Neon)" },
{ value: NeonModel.Claude_Fable_5_1, label: "Claude Fable 5.1 (Neon)" },
{ value: NeonModel.Claude_Opus_5, label: "Claude Opus 5 (Neon)" },
{ value: NeonModel.Claude_Sonnet_5, label: "Claude Sonnet 5 (Neon)" },
{ value: NeonModel.Claude_Fable_5, label: "Claude Fable 5 (Neon)" },
{ value: NeonModel.Claude_Opus_4_8, label: "Claude Opus 4.8 (Neon)" },
{ value: NeonModel.Claude_Opus_4_7, label: "Claude Opus 4.7 (Neon)" },
{ value: NeonModel.Claude_Sonnet_4_6, label: "Claude Sonnet 4.6 (Neon)" },
{ value: NeonModel.Claude_Opus_4_6, label: "Claude Opus 4.6 (Neon)" },
{ value: NeonModel.Claude_Opus_4_5, label: "Claude Opus 4.5 (Neon)" },
{ value: NeonModel.Claude_Haiku_4_5, label: "Claude Haiku 4.5 (Neon)" },
{ value: NeonModel.Claude_Sonnet_4_5, label: "Claude Sonnet 4.5 (Neon)" },
{ value: NeonModel.Claude_Opus_4_1, label: "Claude Opus 4.1 (Neon)" },
],
},
],
[Agent.Cursor]: [
{
Expand Down
6 changes: 6 additions & 0 deletions packages/python-sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to `upstash-box` (Python) are documented here.

## Unreleased

- Add `NeonModel` (Neon AI Gateway, `neon/<neon-short-id>`). `infer_default_provider`
routes `neon/gpt-*` to Codex, except `neon/gpt-oss-*`, and every other Neon model
(GPT OSS, Claude, Gemini, Llama, Qwen, Kimi, GLM, Grok) to OpenCode.
Neon's ``CLAUDE_*`` models run on OpenCode only (chat completions); Box does
not run Claude Code with Neon. Neon credentials (token + branch base URL)
must be stored via the console or agent-credentials API.
- Add GPT-6.1 Sol to `OpenAICodex`, `OpenRouterModel`, `VercelModel`, and
`OpenCodeModel`.
- Init commands now work on every box, not only keep-alive ones. `create(init_command=...)`
Expand Down
2 changes: 1 addition & 1 deletion packages/python-sdk/PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ JS `Run`/`StreamRun` → Python `Run`/`StreamRun` (+ `AsyncRun`/`AsyncStreamRun`
| `BoxError` | `BoxError` |
| `inferDefaultProvider` | `infer_default_provider` |
| `runCustomHarness` | `run_custom_harness` |
| `Agent`, `ClaudeCode`, `OpenAICodex`, `OpenCodeModel`, `OpenRouterModel`, `VercelModel`, `CursorModel`, `BoxApiKey` | same names (str-Enums) |
| `Agent`, `ClaudeCode`, `OpenAICodex`, `OpenCodeModel`, `OpenRouterModel`, `VercelModel`, `NeonModel`, `CursorModel`, `BoxApiKey` | same names (str-Enums) |

## `Box` instance methods/properties

Expand Down
12 changes: 12 additions & 0 deletions packages/python-sdk/tests/_async/test_infer_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,15 @@ def test_custom_prefix():

def test_unknown_defaults_to_claude_code():
assert infer_default_provider("mystery-model") == Agent.CLAUDE_CODE


def test_neon_gpt_prefix_infers_codex():
assert infer_default_provider("neon/gpt-5-5") == Agent.CODEX
assert infer_default_provider("neon/gpt-5-3-codex") == Agent.CODEX


def test_neon_chat_only_models_infer_opencode():
assert infer_default_provider("neon/gpt-oss-120b") == Agent.OPEN_CODE
assert infer_default_provider("neon/gemini-3-6-flash") == Agent.OPEN_CODE
assert infer_default_provider("neon/kimi-k3") == Agent.OPEN_CODE
assert infer_default_provider("neon/claude-opus-4-8") == Agent.OPEN_CODE
10 changes: 10 additions & 0 deletions packages/python-sdk/tests/_async/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
CursorModel,
FinishChunk,
FinishUsage,
NeonModel,
OpenAICodex,
OpenCodeModel,
OpenRouterModel,
Expand Down Expand Up @@ -83,6 +84,15 @@ def test_claude_opus_5_model_identifier():
}


def test_neon_model_identifiers_use_short_ids():
assert NeonModel.GPT_5_5.value == "neon/gpt-5-5"
assert NeonModel.GEMINI_3_6_FLASH.value == "neon/gemini-3-6-flash"
assert NeonModel.CLAUDE_HAIKU_4_5.value == "neon/claude-haiku-4-5"
for member in NeonModel:
assert member.value.startswith("neon/")
assert "/" not in member.value[len("neon/") :]


def test_box_run_data_fields():
run = BoxRunData.model_validate(
{
Expand Down
2 changes: 2 additions & 0 deletions packages/python-sdk/upstash_box/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
LogEntry,
McpServerConfig,
ModelConfig,
NeonModel,
NetworkPolicy,
OpenAICodex,
OpenCodeAgentOptions,
Expand Down Expand Up @@ -176,6 +177,7 @@
"OpenCodeModel",
"OpenRouterModel",
"VercelModel",
"NeonModel",
# Config / option types
"AgentConfig",
"AgentOptions",
Expand Down
9 changes: 9 additions & 0 deletions packages/python-sdk/upstash_box/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,15 @@ def infer_default_provider(model: str) -> Agent:
return Agent.CODEX
if model.startswith("vercel/"):
return Agent.CLAUDE_CODE
# Default routing for Neon AI Gateway (Box does not run Claude Code with Neon).
# GPT models default to Codex (Responses API). Every other Neon model defaults
# to OpenCode (chat completions), which Neon serves for every text model.
# Grok 4.6 is also served on Responses, so Codex can run it when chosen
# explicitly; it only defaults to OpenCode here.
if model.startswith("neon/gpt-") and not model.startswith("neon/gpt-oss-"):
return Agent.CODEX
if model.startswith("neon/"):
return Agent.OPEN_CODE
if model.startswith("openrouter/"):
return Agent.CLAUDE_CODE
if model.startswith("opencode/"):
Expand Down
51 changes: 51 additions & 0 deletions packages/python-sdk/upstash_box/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,57 @@ class VercelModel(str, Enum):
GROK_4_20_REASONING = "vercel/spacexai/grok-4.20-reasoning"


class NeonModel(str, Enum):
"""Neon AI Gateway model identifiers (``neon/<neon-short-id>``).

These run on the Codex harness (Responses API) or the OpenCode harness
(chat completions); Box does not run Claude Code with Neon.
``GPT_5_5_PRO`` and ``GPT_5_3_CODEX`` are Responses-only (Codex);
the other GPT models and ``GROK_4_6`` run on both; ``GPT_OSS_*``, the
``CLAUDE_*`` models and every other non-OpenAI model except Grok are served
through chat completions only (OpenCode).
Neon credentials are a token plus a per-branch base URL and must be stored
via the console or agent-credentials API; use ``BoxApiKey.STORED_KEY``.
"""

GPT_6_ASTRA = "neon/gpt-6-astra"
GPT_5_6_SOL = "neon/gpt-5-6-sol"
GPT_5_6_TERRA = "neon/gpt-5-6-terra"
GPT_5_6_LUNA = "neon/gpt-5-6-luna"
GPT_5_5 = "neon/gpt-5-5"
GPT_5_5_PRO = "neon/gpt-5-5-pro"
GPT_5_4 = "neon/gpt-5-4"
GPT_5_4_MINI = "neon/gpt-5-4-mini"
GPT_5_4_NANO = "neon/gpt-5-4-nano"
GPT_5_3_CODEX = "neon/gpt-5-3-codex"
GPT_5_MINI = "neon/gpt-5-mini"
GPT_5_NANO = "neon/gpt-5-nano"
GPT_OSS_120B = "neon/gpt-oss-120b"
GPT_OSS_20B = "neon/gpt-oss-20b"
GEMINI_3_6_FLASH = "neon/gemini-3-6-flash"
GEMINI_3_5_FLASH = "neon/gemini-3-5-flash"
GEMINI_3_5_FLASH_LITE = "neon/gemini-3-5-flash-lite"
GEMINI_3_1_PRO = "neon/gemini-3-1-pro"
GROK_4_6 = "neon/grok-4-6"
KIMI_K3 = "neon/kimi-k3"
GLM_5_2 = "neon/glm-5-2"
QWEN3_5_122B = "neon/qwen35-122b-a10b"
LLAMA_4_MAVERICK = "neon/llama-4-maverick"
CLAUDE_OPUS_5_5 = "neon/claude-opus-5-5"
CLAUDE_FABLE_5_1 = "neon/claude-fable-5-1"
CLAUDE_OPUS_5 = "neon/claude-opus-5"
CLAUDE_SONNET_5 = "neon/claude-sonnet-5"
CLAUDE_FABLE_5 = "neon/claude-fable-5"
CLAUDE_OPUS_4_8 = "neon/claude-opus-4-8"
CLAUDE_OPUS_4_7 = "neon/claude-opus-4-7"
CLAUDE_SONNET_4_6 = "neon/claude-sonnet-4-6"
CLAUDE_OPUS_4_6 = "neon/claude-opus-4-6"
CLAUDE_OPUS_4_5 = "neon/claude-opus-4-5"
CLAUDE_HAIKU_4_5 = "neon/claude-haiku-4-5"
CLAUDE_SONNET_4_5 = "neon/claude-sonnet-4-5"
CLAUDE_OPUS_4_1 = "neon/claude-opus-4-1"


class OpenCodeModel(str, Enum):
"""OpenCode model identifiers — supports models from multiple providers."""

Expand Down
16 changes: 16 additions & 0 deletions packages/sdk/src/__tests__/infer-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@ describe("inferDefaultProvider", () => {
expect(inferDefaultProvider("vercel/openai/gpt-5.5")).toBe(Agent.Codex);
});

it("returns Codex for neon/gpt-* models", () => {
expect(inferDefaultProvider("neon/gpt-5-5")).toBe(Agent.Codex);
expect(inferDefaultProvider("neon/gpt-5-3-codex")).toBe(Agent.Codex);
expect(inferDefaultProvider("neon/gpt-6-astra")).toBe(Agent.Codex);
});

it("defaults every non-GPT neon model to OpenCode", () => {
// Chat-completions-only on Neon.
expect(inferDefaultProvider("neon/gpt-oss-120b")).toBe(Agent.OpenCode);
expect(inferDefaultProvider("neon/gemini-3-6-flash")).toBe(Agent.OpenCode);
expect(inferDefaultProvider("neon/kimi-k3")).toBe(Agent.OpenCode);
expect(inferDefaultProvider("neon/claude-opus-4-8")).toBe(Agent.OpenCode);
// Served on both endpoints; Codex can run it when chosen, but the default is OpenCode.
expect(inferDefaultProvider("neon/grok-4-6")).toBe(Agent.OpenCode);
});

it("returns OpenCode for opencode/ prefix", () => {
expect(inferDefaultProvider("opencode/zen-claude-sonnet-4.5")).toBe(Agent.OpenCode);
});
Expand Down
22 changes: 22 additions & 0 deletions packages/sdk/src/__tests__/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
ClaudeCode,
CursorModel,
NeonModel,
OpenAICodex,
OpenCodeModel,
OpenRouterModel,
Expand Down Expand Up @@ -77,3 +78,24 @@ describe("Claude Opus 5 model identifiers", () => {
expect(model).toBe(expected);
});
});

describe("Neon AI Gateway model identifiers", () => {
it.each([
[NeonModel.GPT_5_5, "neon/gpt-5-5"],
[NeonModel.GPT_5_3_Codex, "neon/gpt-5-3-codex"],
[NeonModel.GPT_OSS_120B, "neon/gpt-oss-120b"],
[NeonModel.Gemini_3_6_Flash, "neon/gemini-3-6-flash"],
[NeonModel.Kimi_K3, "neon/kimi-k3"],
[NeonModel.Claude_Opus_5_5, "neon/claude-opus-5-5"],
[NeonModel.Claude_Haiku_4_5, "neon/claude-haiku-4-5"],
])("uses Neon's short id under the neon/ namespace: %s", (model, expected) => {
expect(model).toBe(expected);
});

it("never carries a provider segment", () => {
for (const value of Object.values(NeonModel)) {
expect(value.startsWith("neon/")).toBe(true);
expect(value.slice("neon/".length)).not.toContain("/");
}
});
});
Loading
Loading