Skip to content
Open
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ make validate-examples # validate all examples
- **Route evaluation**: First matching `when` condition wins; no `when` = always matches
- **Tool resolution**: `null` = all workflow tools, `[]` = none, `[list]` = subset
- **Reasoning effort**: `runtime.default_reasoning_effort` sets a workflow-wide default; per-agent `reasoning.effort` overrides it. Allowed values: `low`, `medium`, `high`, `xhigh`. Each provider translates the unified value to its native API (Copilot: `reasoning_effort` on the session, validated against the model's `supported_reasoning_efforts`; Claude: extended thinking with budget mapping low=2048, medium=8192, high=16384, xhigh=32768 tokens, with `temperature` coerced to 1.0 and `max_tokens` bumped to fit the budget). See `examples/reasoning-effort.yaml`.
- **Structured `runtime.provider` (Copilot custom routing)**: `runtime.provider` accepts either the bare string shorthand (`provider: copilot`) or a structured `ProviderSettings` object that routes the Copilot SDK at OpenAI-compatible / Azure / Anthropic endpoints (Ollama, vLLM, LM Studio, Azure OpenAI, etc.). Object fields: `name` (defaults to `copilot`), `type` (`openai`|`azure`|`anthropic`), `wire_api` (`completions`|`responses`), `base_url`, `api_key`, `bearer_token`, `headers`, `azure.api_version`. `api_key` and `bearer_token` are `SecretStr` (redacted in `model_dump` / dashboard / event logs). The model is frozen after construction. Custom routing activates only when at least one non-`name` field is set in YAML — ambient `OPENAI_*` env vars never divert default routing on their own. Once activated, missing fields fall back from env vars in this order: `base_url` ← `COPILOT_PROVIDER_BASE_URL` → `OPENAI_BASE_URL`; `api_key` ← `COPILOT_PROVIDER_API_KEY` (only — ambient `OPENAI_API_KEY` is intentionally NOT a fallback to avoid credential leaks); `bearer_token` ← `COPILOT_PROVIDER_BEARER_TOKEN`. The schema rejects every non-`name` field when `name != "copilot"` (structured config for other providers is a follow-up). It also rejects anchorless / broken combinations that would silently no-op at the SDK boundary: `wire_api` / `type` / `headers` / `azure` cannot stand alone without `base_url` / `api_key` / `bearer_token`; empty `headers`, empty `SecretStr`, and `azure: {api_version: null}` are rejected. The resolver raises `ProviderError` when custom routing is activated but every resolved field is falsy (e.g. expected env vars all unset). Custom routing applies to both agent execution and dialog turns so all sessions hit the same endpoint. `--provider <name>` CLI override replaces the whole `ProviderSettings` (logs a notice when YAML had structured fields). See `examples/copilot-local-llm.yaml`.

### Debugging `--web-bg` failures

Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased](https://github.com/microsoft/conductor/compare/v0.1.17...HEAD)

### Added
- `runtime.provider` now accepts either the bare string shorthand
(`provider: copilot`) or a structured `ProviderSettings` object that
forwards a `ProviderConfig` to the Copilot SDK's
`create_session(provider=…)` parameter. This lets workflows route the
Copilot SDK at OpenAI-compatible / Azure / Anthropic endpoints —
Ollama, vLLM, LM Studio, Azure OpenAI, llamafile, or any other
OpenAI-compatible REST endpoint — instead of being locked to the
GitHub Copilot service. The structured form supports `name`, `type`
(`openai`|`azure`|`anthropic`), `wire_api`
(`completions`|`responses`), `base_url`, `api_key`, `bearer_token`,
`headers`, and `azure.api_version`. `api_key` and `bearer_token` are
Pydantic `SecretStr` (redacted in `model_dump`, dashboard payloads,
event logs, and checkpoints). Custom routing activates only when YAML
sets at least one non-`name` field — ambient `OPENAI_*` env vars
never divert default routing on their own. Once activated, missing
fields fall back from `COPILOT_PROVIDER_BASE_URL` → `OPENAI_BASE_URL`
for `base_url`, `COPILOT_PROVIDER_API_KEY` for `api_key`, and
`COPILOT_PROVIDER_BEARER_TOKEN` for `bearer_token`. Ambient
`OPENAI_API_KEY` is intentionally NOT consulted as an implicit
fallback (credential-leak risk); use `api_key: ${OPENAI_API_KEY}`
YAML interpolation for explicit opt-in. The schema rejects every
non-`name` field when `name != "copilot"` (structured config for
Claude / openai-agents is a follow-up), and rejects anchorless or
empty combinations (`wire_api` / `type` / `headers` / `azure` alone,
empty `headers`, empty `SecretStr`, empty `azure` block) so silent
no-ops cannot reach the SDK. Custom routing applies to both agent
execution and dialog turns so all sessions hit the same endpoint.
See `examples/copilot-local-llm.yaml` and
[Configuration → Custom Provider Routing](docs/configuration.md#custom-provider-routing-ollama--vllm--azure-openai)
([#225](https://github.com/microsoft/conductor/pull/225),
[#136](https://github.com/microsoft/conductor/issues/136)).

## [0.1.17](https://github.com/microsoft/conductor/compare/v0.1.16...v0.1.17) - 2026-05-21

### Added
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,32 @@ Set your API key: `export ANTHROPIC_API_KEY=sk-ant-...`

**See also:** [Claude Documentation](docs/providers/claude.md) | [Provider Comparison](docs/providers/comparison.md) | [Migration Guide](docs/providers/migration.md)

### Using a Local / Custom LLM Endpoint (Ollama, vLLM, Azure OpenAI, ...)

`runtime.provider` also accepts a structured object that routes the
Copilot SDK at any OpenAI-compatible / Azure / Anthropic-shaped endpoint.
Useful for local inference (Ollama, vLLM, LM Studio) and managed
deployments (Azure OpenAI):

```yaml
workflow:
runtime:
provider:
name: copilot
type: openai # openai | azure | anthropic
wire_api: completions # completions | responses
base_url: http://localhost:11434/v1
api_key: ${OPENAI_API_KEY:-ollama}
default_model: llama3.1 # match your endpoint's model name
```

The structured form is opt-in: a bare `provider: copilot` keeps the
default GitHub Copilot routing. See
[`examples/copilot-local-llm.yaml`](examples/copilot-local-llm.yaml) for
the full example (including an Azure OpenAI variant) and
[Configuration Guide → Custom Provider Routing](docs/configuration.md#custom-provider-routing-ollama--vllm--azure-openai)
for environment-variable fallbacks, security notes, and validator rules.

## CLI Reference

### `conductor run`
Expand Down
131 changes: 131 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,137 @@ workflow:

**See**: [Claude Provider Documentation](providers/claude.md)

### Custom Provider Routing (Ollama / vLLM / Azure OpenAI)

`runtime.provider` accepts either the bare string shorthand
(`provider: copilot`) **or** a structured object that forwards a
`ProviderConfig` to the Copilot SDK's `create_session(provider=…)`
parameter. This lets workflows route the Copilot SDK at:

- Local OpenAI-compatible servers — Ollama, vLLM, LM Studio, llamafile
- Azure OpenAI deployments
- Anthropic-compatible proxies
- Any other OpenAI-compatible REST endpoint

```yaml
workflow:
runtime:
provider:
name: copilot
type: openai # openai | azure | anthropic
wire_api: completions # completions | responses
base_url: http://localhost:11434/v1
api_key: ${OPENAI_API_KEY:-ollama}
default_model: llama3.1 # required for non-Copilot endpoints
```

Azure OpenAI variant:

```yaml
workflow:
runtime:
provider:
name: copilot
type: azure
base_url: https://<your-resource>.openai.azure.com
api_key: ${AZURE_OPENAI_API_KEY}
azure:
api_version: "2024-10-21"
default_model: gpt-4o
```

#### Activation rule (opt-in)

Custom routing activates **only** when at least one non-`name` field is
set in YAML. Ambient `OPENAI_*` environment variables alone will NOT
divert default Copilot traffic — that would be too easy a way to break
a workflow based on unrelated shell state. A bare `provider: copilot`
always means default GitHub Copilot routing.

#### Environment-variable fallbacks

Once a structured object opts in, missing fields fall back to env vars
in this precedence:

| Field | Env-var chain |
|---|---|
| `base_url` | `COPILOT_PROVIDER_BASE_URL` → `OPENAI_BASE_URL` |
| `api_key` | `COPILOT_PROVIDER_API_KEY` *(only)* |
| `bearer_token` | `COPILOT_PROVIDER_BEARER_TOKEN` *(only)* |
| `type` | defaults to `"openai"` when `base_url` is set |

Ambient `OPENAI_API_KEY` is intentionally **not** consulted as an
implicit fallback — that would silently send an OpenAI dev credential
to whatever `base_url` points at, which is a real credential-leak risk.
Users who want OpenAI-environment-style behavior must opt in
explicitly via `api_key: ${OPENAI_API_KEY}` interpolation in YAML.

#### Secrets

`api_key` and `bearer_token` are stored as Pydantic `SecretStr` — they
redact in `model_dump`, dashboard payloads, event logs, and
checkpoints. Prefer `${VAR}` env interpolation for the values in YAML
so the literal secret never lands in `workflow_started` events:

```yaml
api_key: ${OPENAI_API_KEY} # good — interpolated at load time
api_key: sk-aaaaaaaaaaaaaaaa # avoid — literal in yaml_source
```

If both `api_key` and `bearer_token` resolve (from any combination of
YAML and env), both are forwarded; the Copilot SDK silently prefers
`bearer_token`, and conductor logs a warning so the precedence is
visible.

#### Validator rules

`ProviderSettings` is frozen after construction. The schema rejects
the following misconfigurations at config load time so they cannot
silently produce a no-op SDK call:

- `name != "copilot"` combined with **any** non-`name` field
(structured config for `claude` / `openai-agents` is not yet
implemented).
- `type: azure` without an `azure: { api_version: ... }` block
(and the reverse: `azure` block without `type: azure`).
- Anchorless routing fields: `wire_api`, `type`, `headers`, or
`azure` cannot stand alone — at least one of `base_url`, `api_key`,
`bearer_token` must also be set (in YAML or via the
`COPILOT_PROVIDER_*` env vars).
- Empty `headers: {}`, empty `api_key: ""`, empty `bearer_token: ""`,
empty `azure: { api_version: null }`.

When custom routing activates but every resolved field ends up empty
(for example, the workflow expects `COPILOT_PROVIDER_*` env vars and
none are set), the resolver raises `ProviderError` with a clear
message rather than silently routing back to default Copilot.

#### CLI override

`--provider <name>` (and `-p`) replaces the entire `ProviderSettings`
with the bare-string default for that name. When YAML had structured
fields, conductor logs a notice telling the user the custom routing
was dropped:

```
Provider override: claude
Provider override discards structured runtime.provider settings (base_url/type/etc.) from YAML; using SDK defaults.
```

#### Custom routing and dialog mode

The resolved provider config is attached to **every** Copilot
`create_session` call this provider makes — including the dialog-mode
turns used by `agent.dialog` evaluators. All sessions hit the same
endpoint, so you can mix custom-routed agents with dialog mode without
worrying about per-call drift.

#### Example workflow

[`examples/copilot-local-llm.yaml`](../examples/copilot-local-llm.yaml)
demonstrates the full pattern with both Ollama (active) and Azure
OpenAI (commented variant).

## Common Configuration Options

These options work with both providers:
Expand Down
20 changes: 20 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,26 @@ Research workflow demonstrating multi-provider patterns. Demonstrates:
conductor run examples/multi-provider-research.yaml --input topic="Cloud computing"
```

### copilot-local-llm.yaml

Routes the Copilot SDK at a local / custom OpenAI-compatible endpoint
(Ollama, vLLM, LM Studio, Azure OpenAI, etc.) via structured
`runtime.provider` configuration. Demonstrates:
- Object form of `runtime.provider` (the bare-string `provider: copilot`
shorthand still works)
- `type` / `wire_api` / `base_url` / `api_key` forwarded to the Copilot
SDK's `create_session(provider=…)` parameter
- Secret hygiene via `${OPENAI_API_KEY:-ollama}` interpolation
- Azure OpenAI variant in a commented block

```bash
# Requires Ollama running on http://localhost:11434
conductor run examples/copilot-local-llm.yaml --input question="What is Python?"
```

See [Configuration → Custom Provider Routing](../docs/configuration.md#custom-provider-routing-ollama--vllm--azure-openai)
for env-var fallbacks, validator rules, and the security rationale.

## Planning and Implementation

### plan.yaml
Expand Down
95 changes: 95 additions & 0 deletions examples/copilot-local-llm.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Copilot SDK pointed at a local / custom LLM endpoint
#
# Demonstrates structured ``runtime.provider`` configuration (issue #136).
# The Copilot SDK's ``create_session(provider=...)`` parameter is exposed
# through conductor's YAML so workflows can target:
#
# - Local OpenAI-compatible servers (Ollama, vLLM, LM Studio, llamafile, ...)
# - Azure OpenAI deployments
# - Any other OpenAI-compatible REST endpoint
#
# Usage (Ollama running on http://localhost:11434):
#
# conductor run examples/copilot-local-llm.yaml --input question="What is Python?"
#
# ``api_key`` uses ``${OPENAI_API_KEY:-ollama}`` YAML interpolation: the
# literal value comes from ``$OPENAI_API_KEY`` when set, otherwise falls
# back to the placeholder ``"ollama"`` (Ollama accepts any non-empty key).
# Either way the literal secret never lands in checkpoints or dashboard
# events because env interpolation happens at load time.
#
# When custom routing is active and a field is omitted from YAML, the
# Copilot provider also consults environment variables:
#
# base_url <- COPILOT_PROVIDER_BASE_URL, then OPENAI_BASE_URL
# api_key <- COPILOT_PROVIDER_API_KEY (NOT ambient OPENAI_API_KEY;
# that would leak a dev creds
# to whatever base_url points
# at — use the ${OPENAI_API_KEY}
# YAML interpolation above for
# explicit opt-in.)
# bearer_token <- COPILOT_PROVIDER_BEARER_TOKEN
#
# The ``runtime.provider`` field still accepts the bare string form
# (``provider: copilot``); the object form below opts into custom routing.

workflow:
name: copilot-local-llm
description: Route the Copilot SDK at a local OpenAI-compatible endpoint
version: "1.0.0"
entry_point: answerer

runtime:
# Custom routing: ``name`` plus at least one extra field activates
# the alternate endpoint. Setting only ``name: copilot`` is the
# default GitHub Copilot routing.
provider:
name: copilot
type: openai # openai | azure | anthropic
wire_api: completions # completions | responses
base_url: http://localhost:11434/v1
api_key: ${OPENAI_API_KEY:-ollama}

# Custom endpoints rarely expose the SDK's built-in default ``gpt-4o``,
# so always set ``default_model`` to a model your endpoint actually
# serves. The Copilot provider warns when custom routing is active
# without a default model configured.
default_model: llama3.1

input:
question:
type: string
required: true
description: The question to answer

agents:
- name: answerer
description: Answers the user's question via the configured endpoint
prompt: |
You are a helpful assistant. Please answer the following question
clearly and concisely:

Question: {{ workflow.input.question }}

Provide a direct answer without unnecessary preamble.
output:
answer:
type: string
description: The answer to the question
routes:
- to: $end

output:
answer: "{{ answerer.output.answer }}"

# Azure OpenAI variant (commented out):
#
# runtime:
# provider:
# name: copilot
# type: azure
# base_url: https://<your-resource>.openai.azure.com
# api_key: ${AZURE_OPENAI_API_KEY}
# azure:
# api_version: "2024-10-21"
# default_model: gpt-4o
Loading
Loading