Skip to content

Commit fd40169

Browse files
Aitomatesclaude
andcommitted
feat(v1.2): add Ollama tier-0 provider + OpenAPI 3.1 spec export
Phase 8 — Local Ollama Provider in maf_starter: - Settings: ollama_base_url / ollama_model fields; api_key guard optional when OLLAMA_BASE_URL is set (fully offline, no GEMINI_API_KEY required) - Fallback chain: ollama prepended at tier-0 when configured - agent_factory: create_client() and build_agent() dispatch to Ollama - provider_fallback: ollama branch in _execute_chain_step() - routing_policy: _lane_default_chain() returns ollama primary when set; _gemini_fallbacks_for_tier() helper provides Gemini safety net - maf_starter/cli.py: smoke sub-command (python main.py smoke) - main.py: hybrid dispatch — smoke -> maf_starter.cli; else legacy path - .env.example: Ollama/Gemma local-only config block added - 5 new tests; all 20 tests green Phase 9 — OpenAPI Spec + Promptimprover Local Config: - autogen_dashboard/app.py: SSE endpoint annotated (summary, description) - scripts/export_openapi.py: standalone spec export; no env vars required - openapi.json: OpenAPI 3.1.0, 14 paths, 28 schemas - requirements.txt: openapi-spec-validator>=0.7,<1.0 added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent ecb478f commit fd40169

12 files changed

Lines changed: 3832 additions & 8 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,8 @@ CLAUDE_CLI_MODEL=
2828
CLAUDE_CODE_GIT_BASH_PATH=
2929
CODEX_CLI_COMMAND=codex.cmd
3030
CODEX_CLI_MODEL=
31+
32+
# Local Ollama / Gemma (fully offline, no GEMINI_API_KEY required)
33+
# Prerequisites: ollama pull gemma3 && ollama serve
34+
OLLAMA_BASE_URL=http://localhost:11434/v1
35+
OLLAMA_MODEL=gemma3

autogen_dashboard/app.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,24 @@ async def api_retry(
192192
except ValueError as exc:
193193
raise HTTPException(status_code=400, detail=str(exc)) from exc
194194

195-
@app.get("/api/sessions/{session_id}/events")
195+
@app.get(
196+
"/api/sessions/{session_id}/events",
197+
summary="Stream session events (SSE)",
198+
description=(
199+
"Server-Sent Events stream for the given session. "
200+
"Each event frame carries a typed payload. "
201+
"The first event is always a 'snapshot' containing the full current session state. "
202+
"Subsequent events are incremental updates. "
203+
"Media type: text/event-stream."
204+
),
205+
responses={
206+
200: {
207+
"description": "SSE stream of session events",
208+
"content": {"text/event-stream": {"schema": {"type": "string"}}},
209+
},
210+
404: {"description": "Session not found"},
211+
},
212+
)
196213
async def api_session_events(
197214
session_id: str,
198215
since_seq: int = 0,

maf_starter/agent_factory.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212

1313

1414
def create_client(settings: Settings, *, routing_mode: str = "auto"):
15+
if settings.ollama_base_url:
16+
return OpenAIChatClient(
17+
model_id=settings.ollama_model,
18+
api_key="ollama",
19+
base_url=settings.ollama_base_url,
20+
)
1521
return OpenAIChatClient(
1622
model_id=settings.model,
1723
api_key=settings.api_key,
@@ -49,8 +55,8 @@ def build_agent(
4955
middleware=[
5056
build_fallback_middleware(
5157
current,
52-
primary_provider="gemini",
53-
primary_model=current.model,
58+
primary_provider="ollama" if current.ollama_base_url else "gemini",
59+
primary_model=current.ollama_model if current.ollama_base_url else current.model,
5460
routing_mode=routing_mode,
5561
)
5662
],

maf_starter/cli.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import asyncio
5+
import sys
6+
7+
from agent_framework import Message
8+
9+
from maf_starter.agent_factory import create_client
10+
from maf_starter.config import load_settings
11+
12+
13+
DEFAULT_SMOKE_MESSAGE = "Reply with exactly: READY"
14+
15+
16+
def build_parser() -> argparse.ArgumentParser:
17+
parser = argparse.ArgumentParser(prog="maf", description="MAF starter CLI")
18+
sub = parser.add_subparsers(dest="command")
19+
sub.add_parser("smoke", help="Send a quick probe message to verify the configured provider responds")
20+
return parser
21+
22+
23+
async def run_smoke() -> None:
24+
settings = load_settings()
25+
client = create_client(settings)
26+
response = await client.get_response([Message(role="user", text=DEFAULT_SMOKE_MESSAGE)])
27+
provider = "ollama" if settings.ollama_base_url else "gemini"
28+
model = settings.ollama_model if settings.ollama_base_url else settings.model
29+
print(f"[smoke] provider={provider} model={model}")
30+
print(f"[smoke] response: {response.text.strip()}")
31+
32+
33+
def main() -> int:
34+
parser = build_parser()
35+
args = parser.parse_args()
36+
if args.command == "smoke":
37+
try:
38+
asyncio.run(run_smoke())
39+
return 0
40+
except ValueError as exc:
41+
print(f"[smoke] configuration error: {exc}", file=sys.stderr)
42+
return 2
43+
except Exception as exc:
44+
print(f"[smoke] failed: {exc}", file=sys.stderr)
45+
return 1
46+
parser.print_help()
47+
return 0

maf_starter/config.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ class Settings:
5050
codex_cli_command: str = "codex.cmd"
5151
codex_cli_model: str | None = None
5252
model_candidates: tuple[str, ...] = DEFAULT_MODEL_CANDIDATES
53+
ollama_base_url: str | None = None
54+
ollama_model: str = "gemma3"
5355

5456
def with_run_scope(
5557
self,
@@ -110,14 +112,22 @@ def _split_candidates(raw_value: str | None) -> tuple[str, ...]:
110112
return values or DEFAULT_MODEL_CANDIDATES
111113

112114

113-
def _default_fallback_chain(*, anthropic_model: str | None, anthropic_api_key: str | None) -> tuple[str, ...]:
115+
def _default_fallback_chain(
116+
*,
117+
anthropic_model: str | None,
118+
anthropic_api_key: str | None,
119+
ollama_base_url: str | None = None,
120+
ollama_model: str = "gemma3",
121+
) -> tuple[str, ...]:
114122
chain: list[str] = [
115123
"gemini:gemini-2.5-pro",
116124
"gemini:gemini-2.5-flash",
117125
"gemini:gemini-2.5-flash-lite",
118126
]
119127
if anthropic_api_key:
120128
chain.insert(1, f"anthropic:{anthropic_model or DEFAULT_ANTHROPIC_MODEL}")
129+
if ollama_base_url:
130+
chain.insert(0, f"ollama:{ollama_model}")
121131
chain.extend(
122132
[
123133
"claude-cli",
@@ -133,11 +143,15 @@ def _split_fallback_chain(
133143
*,
134144
anthropic_model: str | None,
135145
anthropic_api_key: str | None,
146+
ollama_base_url: str | None = None,
147+
ollama_model: str = "gemma3",
136148
) -> tuple[str, ...]:
137149
if not raw_value:
138150
return _default_fallback_chain(
139151
anthropic_model=anthropic_model,
140152
anthropic_api_key=anthropic_api_key,
153+
ollama_base_url=ollama_base_url,
154+
ollama_model=ollama_model,
141155
)
142156
values = tuple(part.strip() for part in raw_value.split(",") if part.strip())
143157
return values
@@ -185,8 +199,13 @@ def load_settings(*, env_path: Path | None = None, project_root: Path | None = N
185199
load_dotenv(dotenv_path=dotenv_path, override=False)
186200

187201
api_key = (os.getenv("MAF_API_KEY") or os.getenv("GEMINI_API_KEY") or "").strip()
188-
if not api_key:
189-
raise ValueError("Set GEMINI_API_KEY or MAF_API_KEY in the repo .env file.")
202+
ollama_base_url = (os.getenv("OLLAMA_BASE_URL") or "").strip() or None
203+
ollama_model = (os.getenv("OLLAMA_MODEL") or "gemma3").strip()
204+
if not api_key and not ollama_base_url:
205+
raise ValueError(
206+
"Set GEMINI_API_KEY or MAF_API_KEY in the repo .env file, "
207+
"or set OLLAMA_BASE_URL for local-only operation."
208+
)
190209

191210
model = (os.getenv("MAF_MODEL") or os.getenv("GEMINI_MODEL") or DEFAULT_MODEL).strip()
192211
base_url = (os.getenv("MAF_BASE_URL") or os.getenv("GEMINI_BASE_URL") or DEFAULT_GEMINI_BASE_URL).strip()
@@ -215,7 +234,11 @@ def load_settings(*, env_path: Path | None = None, project_root: Path | None = N
215234
os.getenv("MAF_FALLBACK_CHAIN"),
216235
anthropic_model=anthropic_model,
217236
anthropic_api_key=anthropic_api_key,
237+
ollama_base_url=ollama_base_url,
238+
ollama_model=ollama_model,
218239
),
240+
ollama_base_url=ollama_base_url,
241+
ollama_model=ollama_model,
219242
anthropic_api_key=anthropic_api_key,
220243
anthropic_model=anthropic_model or DEFAULT_ANTHROPIC_MODEL,
221244
gemini_cli_command=(os.getenv("GEMINI_CLI_COMMAND") or "gemini.cmd").strip(),

maf_starter/provider_fallback.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,44 @@ async def _execute_chain_step(
446446
return _stream_from_response(response)
447447
return response
448448

449+
if step.provider == "ollama":
450+
if not settings.ollama_base_url:
451+
raise RuntimeError("Ollama base URL is not configured. Set OLLAMA_BASE_URL.")
452+
client = OpenAIChatClient(
453+
model_id=step.model or settings.ollama_model,
454+
api_key="ollama",
455+
base_url=settings.ollama_base_url,
456+
)
457+
if _context_is_streaming(context):
458+
response = client.get_streaming_response(
459+
context.messages,
460+
**_context_response_kwargs(context, _override_model(context.options, step.model or settings.ollama_model)),
461+
)
462+
else:
463+
response = await client.get_response(
464+
context.messages,
465+
**_context_response_kwargs(context, _override_model(context.options, step.model or settings.ollama_model)),
466+
)
467+
if attempt_log is not None:
468+
attempt_log.append(
469+
_build_route_attempt(
470+
step.provider,
471+
step.model or settings.ollama_model,
472+
status="succeeded",
473+
fallback_index=fallback_index,
474+
tools_available=True,
475+
)
476+
)
477+
return _decorate_result(
478+
response,
479+
settings=settings,
480+
step=step,
481+
route=route,
482+
prior_error=prior_error,
483+
add_notice=False,
484+
attempt_log=attempt_log,
485+
)
486+
449487
raise RuntimeError(f"Unsupported fallback provider: {step.provider}")
450488

451489

maf_starter/routing_policy.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ def _build_lane_chain(
157157

158158
def _lane_default_chain(settings: Settings, *, lane: RouteLane, tier: str) -> tuple[ChainStep, tuple[ChainStep, ...]]:
159159
normalized_tier = tier if lane == "auto" else _tier_for_lane(lane)[0]
160+
if settings.ollama_base_url:
161+
ollama_primary = ChainStep("ollama", settings.ollama_model)
162+
gemini_fallbacks = _gemini_fallbacks_for_tier(settings, normalized_tier)
163+
return ollama_primary, gemini_fallbacks
160164
if normalized_tier == "simple":
161165
primary = ChainStep("gemini", "gemini-2.5-flash-lite")
162166
fallbacks = [
@@ -187,6 +191,23 @@ def _lane_default_chain(settings: Settings, *, lane: RouteLane, tier: str) -> tu
187191
return primary, tuple(fallbacks)
188192

189193

194+
def _gemini_fallbacks_for_tier(settings: Settings, tier: str) -> tuple[ChainStep, ...]:
195+
if tier == "simple":
196+
return (
197+
ChainStep("gemini", "gemini-2.5-flash-lite"),
198+
ChainStep("gemini", "gemini-2.5-flash"),
199+
)
200+
if tier == "deep":
201+
return (
202+
ChainStep("gemini", "gemini-2.5-pro"),
203+
ChainStep("gemini", "gemini-2.5-flash"),
204+
)
205+
return (
206+
ChainStep("gemini", "gemini-2.5-flash"),
207+
ChainStep("gemini", "gemini-2.5-pro"),
208+
)
209+
210+
190211
def _tier_for_lane(lane: RouteLane) -> tuple[str, str]:
191212
if lane == "deep":
192213
return "deep", "The deep lane was selected explicitly."

main.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1-
from autogen_starter.cli import main
1+
from __future__ import annotations
2+
3+
import sys
4+
5+
6+
def _dispatch() -> int:
7+
if len(sys.argv) >= 2 and sys.argv[1] == "smoke":
8+
from maf_starter.cli import main as maf_main
9+
return maf_main()
10+
from autogen_starter.cli import main as legacy_main
11+
return legacy_main()
212

313

414
if __name__ == "__main__":
5-
raise SystemExit(main())
15+
raise SystemExit(_dispatch())

0 commit comments

Comments
 (0)