Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions packages/blocks/src/deepnote-file/deepnote-file-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,11 @@ const agentBlockSchema = z.object({
content: z.string().optional(),
metadata: executableBlockMetadataSchema
.extend({
/**
* Model the agent block runs on, optionally prefixed with a provider:
* `anthropic:claude-opus-5`, `openai-compatible:llama3`. A bare name
* (`gpt-5`) or `'auto'` means the `openai` provider.
*/
deepnote_agent_model: z.string().default('auto'),
deepnote_mcp_servers: z.array(mcpServerSchema).optional(),
})
Expand Down
43 changes: 36 additions & 7 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,15 +272,44 @@ Without `--python`, the CLI also picks up a `.venv` or `venv` next to (or above)

The `--prompt` flag appends an agent block to the notebook (or creates one from scratch) and runs it. The agent can read prior block outputs, execute Python code, and add new blocks to the notebook autonomously.

**Choosing a provider:**

`deepnote_agent_model` takes an optional `provider:model` prefix:

| Provider id | Package | Use for |
| ------------------- | --------------------------- | ------------------------------------------------- |
| `openai` | `@ai-sdk/openai` | OpenAI (the default when no provider is named) |
| `anthropic` | `@ai-sdk/anthropic` | Claude |
| `openai-compatible` | `@ai-sdk/openai-compatible` | OpenRouter, Ollama, LiteLLM, vLLM, Together, Groq |

A bare model name (`gpt-5`) or `auto` means `openai`, so notebooks written before prefixes existed are unaffected.

```bash
ANTHROPIC_API_KEY=sk-ant-... deepnote run my-project.deepnote --prompt "Analyze the sales data"
```

**Requirements:**

- `OPENAI_API_KEY` environment variable must be set (works with any OpenAI-compatible API)
- Optionally set `OPENAI_BASE_URL` for non-OpenAI providers (Ollama, LiteLLM, etc.)
- Model selection precedence:
- If the agent block sets `deepnote_agent_model` to a specific model, that model is used.
- If `deepnote_agent_model` is `"auto"` (or omitted), `OPENAI_MODEL` is used when set.
- If neither a block-specific model nor `OPENAI_MODEL` is set, the runtime falls back to `gpt-5`.
- `OPENAI_BASE_URL` only changes the provider endpoint; it does not change the precedence above or the final `gpt-5` fallback.
- The API key variable for the chosen provider must be set (see the table below).
- Model selection precedence, per provider:
- If the agent block names a model after the prefix, that model is used.
- If the model is `"auto"` (or omitted), the provider's `*_MODEL` variable is used when set.
- Otherwise the provider default applies: `gpt-5` for `openai`, `claude-opus-5` for `anthropic`. `openai-compatible` has no default and errors instead of guessing.
- A base URL only changes the endpoint; it does not change the precedence above.

**Environment variables:**

| Variable | Provider | Required | Description |
| ------------------------- | ------------------- | -------- | -------------------------------------------------------------- |
| `OPENAI_API_KEY` | `openai` | yes | API key |
| `OPENAI_BASE_URL` | `openai` | no | Custom endpoint; switches the SDK to Chat Completions |
| `OPENAI_MODEL` | `openai` | no | Model when the block says `auto`; otherwise the block wins |
| `ANTHROPIC_API_KEY` | `anthropic` | yes | API key |
| `ANTHROPIC_BASE_URL` | `anthropic` | no | Custom endpoint |
| `ANTHROPIC_MODEL` | `anthropic` | no | Model when the block says `auto`; otherwise the block wins |
| `DEEPNOTE_AGENT_API_KEY` | `openai-compatible` | yes | API key; falls back to `OPENAI_API_KEY` |
| `DEEPNOTE_AGENT_BASE_URL` | `openai-compatible` | yes | Endpoint; falls back to `OPENAI_BASE_URL` |
| `DEEPNOTE_AGENT_MODEL` | `openai-compatible` | no | Model when the block says `auto`; falls back to `OPENAI_MODEL` |

When database integrations are configured, the agent is automatically made aware of them and can query them using `deepnote-toolkit`.

Expand Down
2 changes: 2 additions & 0 deletions packages/runtime-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,10 @@
"watch": "tsdown --watch"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.127",
"@ai-sdk/mcp": "^1.0.70",
"@ai-sdk/openai": "^3.0.96",
"@ai-sdk/openai-compatible": "^2.0.81",
"@deepnote/blocks": "workspace:*",
"@jupyterlab/nbformat": "^4.6.3",
"@jupyterlab/services": "^7.6.3",
Expand Down
32 changes: 15 additions & 17 deletions packages/runtime-core/src/agent-handler.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { createMCPClient, type MCPClient } from '@ai-sdk/mcp'
import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio'
import { createOpenAI } from '@ai-sdk/openai'
import type { AgentBlock, DeepnoteBlock, DeepnoteFile, McpServerConfig } from '@deepnote/blocks'
import { extractOutputsText } from '@deepnote/blocks'
import { stepCountIs, ToolLoopAgent, tool } from 'ai'
import { z } from 'zod'
import { apiKeyEnvVarFor, parseAgentModel, resolveAgentModel } from './agent-provider'

export type AgentStreamEvent =
| { type: 'tool_called'; toolName: string }
Expand All @@ -13,7 +13,10 @@ export type AgentStreamEvent =
| { type: 'reasoning_delta'; text: string }

export interface AgentBlockContext {
openAiToken: string
/** API key for the provider named by the block's `deepnote_agent_model`. */
apiKey?: string
/** @deprecated Use {@link AgentBlockContext.apiKey}. Kept so existing callers keep working. */
openAiToken?: string
mcpServers: McpServerConfig[]
notebookContext: string
addAndExecuteCodeBlock: (args: { code: string }) => Promise<string>
Expand Down Expand Up @@ -150,23 +153,18 @@ export async function executeAgentBlock(block: AgentBlock, context: AgentBlockCo
// Before any resource acquisition — a pre-aborted call must not spawn MCP subprocesses
context.signal?.throwIfAborted()

const openai = createOpenAI({
apiKey: context.openAiToken,
baseURL: process.env.OPENAI_BASE_URL,
})
const apiKey = context.apiKey ?? context.openAiToken
if (!apiKey) {
const { providerId } = parseAgentModel(block.metadata.deepnote_agent_model)
throw new Error(`No API key supplied for the "${providerId}" agent provider (${apiKeyEnvVarFor(providerId)}).`)
}

const modelName =
block.metadata.deepnote_agent_model !== 'auto'
? block.metadata.deepnote_agent_model
: (process.env.OPENAI_MODEL ?? 'gpt-5')
const { model, providerOptions } = resolveAgentModel({
spec: block.metadata.deepnote_agent_model,
apiKey,
})
const maxTurns = 10

// Use the Responses API for direct OpenAI access (supports reasoning
// summaries), but fall back to Chat Completions for custom base URLs
// since most OpenAI-compatible providers don't implement the Responses API.
const baseURL = process.env.OPENAI_BASE_URL
const model = baseURL ? openai.chat(modelName) : openai(modelName)

const blockMcpServers = block.metadata.deepnote_mcp_servers ?? []
const mergedMcpConfig = mergeMcpConfigs(context.mcpServers, blockMcpServers)

Expand Down Expand Up @@ -233,7 +231,7 @@ export async function executeAgentBlock(block: AgentBlock, context: AgentBlockCo
...mcpTools,
},
stopWhen: stepCountIs(maxTurns),
...(baseURL ? {} : { providerOptions: { openai: { reasoningSummary: 'auto' } } }),
...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
})

const streamResult = await agent.stream({ prompt: block.content ?? '', abortSignal: context.signal })
Expand Down
166 changes: 166 additions & 0 deletions packages/runtime-core/src/agent-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest'
import { apiKeyEnvVarFor, parseAgentModel, resolveAgentModel } from './agent-provider'

describe('parseAgentModel', () => {
it('defaults to openai/auto when no model is set', () => {
expect(parseAgentModel(undefined)).toEqual({ providerId: 'openai', modelName: 'auto' })
expect(parseAgentModel('')).toEqual({ providerId: 'openai', modelName: 'auto' })
expect(parseAgentModel(' ')).toEqual({ providerId: 'openai', modelName: 'auto' })
})

it('treats a bare model name as openai, so existing files keep working', () => {
expect(parseAgentModel('auto')).toEqual({ providerId: 'openai', modelName: 'auto' })
expect(parseAgentModel('gpt-5')).toEqual({ providerId: 'openai', modelName: 'gpt-5' })
expect(parseAgentModel('gpt-5.6-sol')).toEqual({ providerId: 'openai', modelName: 'gpt-5.6-sol' })
})

it('splits a known provider prefix', () => {
expect(parseAgentModel('anthropic:claude-opus-5')).toEqual({
providerId: 'anthropic',
modelName: 'claude-opus-5',
})
expect(parseAgentModel('openai:gpt-5')).toEqual({ providerId: 'openai', modelName: 'gpt-5' })
expect(parseAgentModel('openai-compatible:llama3')).toEqual({
providerId: 'openai-compatible',
modelName: 'llama3',
})
})

it('leaves slash-separated aggregator ids intact', () => {
expect(parseAgentModel('openai-compatible:anthropic/claude-opus-5')).toEqual({
providerId: 'openai-compatible',
modelName: 'anthropic/claude-opus-5',
})
})

it('keeps an unknown prefix as part of the model name', () => {
// A colon is legal in a model name; only a known provider id is a prefix.
expect(parseAgentModel('mistral:large')).toEqual({ providerId: 'openai', modelName: 'mistral:large' })
})

it('falls back to auto when a provider prefix has no model', () => {
expect(parseAgentModel('anthropic:')).toEqual({ providerId: 'anthropic', modelName: 'auto' })
})

it('ignores a leading colon rather than reading an empty provider', () => {
expect(parseAgentModel(':gpt-5')).toEqual({ providerId: 'openai', modelName: ':gpt-5' })
})

it('trims surrounding whitespace', () => {
expect(parseAgentModel(' anthropic:claude-opus-5 ')).toEqual({
providerId: 'anthropic',
modelName: 'claude-opus-5',
})
})
})

describe('apiKeyEnvVarFor', () => {
it('names the variable each provider reads', () => {
expect(apiKeyEnvVarFor('openai')).toBe('OPENAI_API_KEY')
expect(apiKeyEnvVarFor('anthropic')).toBe('ANTHROPIC_API_KEY')
expect(apiKeyEnvVarFor('openai-compatible')).toBe('DEEPNOTE_AGENT_API_KEY')
})
})

describe('resolveAgentModel', () => {
it('keeps the documented openai default and reasoning summaries', () => {
const resolved = resolveAgentModel({ spec: 'auto', apiKey: 'k', env: {} })

expect(resolved.providerId).toBe('openai')
expect(resolved.modelName).toBe('gpt-5')
expect(resolved.providerOptions).toEqual({ openai: { reasoningSummary: 'auto' } })
})

it('lets OPENAI_MODEL override the openai default', () => {
const resolved = resolveAgentModel({ spec: 'auto', apiKey: 'k', env: { OPENAI_MODEL: 'gpt-4o' } })

expect(resolved.modelName).toBe('gpt-4o')
})

it('prefers the block model over OPENAI_MODEL', () => {
const resolved = resolveAgentModel({ spec: 'gpt-5', apiKey: 'k', env: { OPENAI_MODEL: 'gpt-4o' } })

expect(resolved.modelName).toBe('gpt-5')
})

it('drops reasoning summaries when OPENAI_BASE_URL points elsewhere', () => {
// Most OpenAI-compatible endpoints have no Responses API, so the handler
// falls back to Chat Completions and the option would be rejected.
const resolved = resolveAgentModel({
spec: 'auto',
apiKey: 'k',
env: { OPENAI_BASE_URL: 'https://example.test/v1' },
})

expect(resolved.providerOptions).toEqual({})
})

it('resolves anthropic with a Claude default and summarized thinking', () => {
const resolved = resolveAgentModel({ spec: 'anthropic:auto', apiKey: 'k', env: {} })

expect(resolved.providerId).toBe('anthropic')
expect(resolved.modelName).toBe('claude-opus-5')
expect(resolved.providerOptions).toEqual({
anthropic: { thinking: { type: 'adaptive', display: 'summarized' } },
})
})

it('lets ANTHROPIC_MODEL override the anthropic default', () => {
const resolved = resolveAgentModel({
spec: 'anthropic:auto',
apiKey: 'k',
env: { ANTHROPIC_MODEL: 'claude-sonnet-5' },
})

expect(resolved.modelName).toBe('claude-sonnet-5')
})

it('does not let OPENAI_MODEL leak into another provider', () => {
const resolved = resolveAgentModel({ spec: 'anthropic:auto', apiKey: 'k', env: { OPENAI_MODEL: 'gpt-4o' } })

expect(resolved.modelName).toBe('claude-opus-5')
})

it('resolves openai-compatible from its own variables', () => {
const resolved = resolveAgentModel({
spec: 'openai-compatible:auto',
apiKey: 'k',
env: { DEEPNOTE_AGENT_BASE_URL: 'https://openrouter.ai/api/v1', DEEPNOTE_AGENT_MODEL: 'anthropic/claude-opus-5' },
})

expect(resolved.modelName).toBe('anthropic/claude-opus-5')
expect(resolved.providerOptions).toEqual({})
})

it('falls back to the OPENAI_* variables for openai-compatible', () => {
const resolved = resolveAgentModel({
spec: 'openai-compatible:auto',
apiKey: 'k',
env: { OPENAI_BASE_URL: 'http://localhost:11434/v1', OPENAI_MODEL: 'llama3' },
})

expect(resolved.modelName).toBe('llama3')
})

it('names the variable to set when openai-compatible has no endpoint', () => {
expect(() => resolveAgentModel({ spec: 'openai-compatible:llama3', apiKey: 'k', env: {} })).toThrow(
/DEEPNOTE_AGENT_BASE_URL/
)
})

it('refuses to guess a model for openai-compatible', () => {
expect(() =>
resolveAgentModel({
spec: 'openai-compatible:auto',
apiKey: 'k',
env: { DEEPNOTE_AGENT_BASE_URL: 'https://example.test/v1' },
})
).toThrow(/DEEPNOTE_AGENT_MODEL/)
})

it('ignores empty env values instead of treating them as configured', () => {
const resolved = resolveAgentModel({ spec: 'auto', apiKey: 'k', env: { OPENAI_MODEL: '' } })

expect(resolved.modelName).toBe('gpt-5')
})
})
Loading
Loading