Merge branch 'main' into adil/use_standard_tracing

This commit is contained in:
Adil Hafeez 2026-02-02 12:13:10 -08:00
commit 891f3a7413
No known key found for this signature in database
GPG key ID: 9B18EF7691369645
102 changed files with 6426 additions and 1026 deletions

View file

@ -30,7 +30,7 @@ jobs:
- name: build arch docker image
run: |
cd ../../ && docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.3 -t katanemo/plano:latest
cd ../../ && docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.4 -t katanemo/plano:latest
- name: start plano
env:

View file

@ -24,7 +24,7 @@ jobs:
- name: build plano docker image
run: |
docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.3
docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.4
- name: install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh

View file

@ -24,7 +24,7 @@ jobs:
- name: build arch docker image
run: |
docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.3
docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.4
- name: install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh

View file

@ -19,9 +19,8 @@ jobs:
# Build and run the Docker container to generate the documentation
- name: Build documentation using Docker
run: |
cd ./docs
chmod +x build_docs.sh
./build_docs.sh
chmod +x docs/build_docs.sh
sh docs/build_docs.sh
- name: Copy CNAME to HTML Build Directory
run: cp docs/CNAME docs/build/html/CNAME

View file

@ -24,7 +24,7 @@ jobs:
- name: build arch docker image
run: |
docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.3
docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.4
- name: validate arch config
run: |

1
.gitignore vendored
View file

@ -149,3 +149,4 @@ apps/*/dist/
*.logs
.cursor/
.agents

View file

@ -18,8 +18,8 @@ Fork the repository to create your own version of **Plano**:
Once you've forked the repository, clone it to your local machine (replace `<your-username>` with your GitHub username):
```bash
$ git clone git@github.com:<your-username>/plano.git
$ cd plano
git clone git@github.com:<your-username>/plano.git
cd plano
```
### 3. Add Upstream Remote
@ -27,15 +27,15 @@ $ cd plano
Add the original repository as an upstream remote so you can keep your fork in sync:
```bash
$ git remote add upstream git@github.com:katanemo/plano.git
git remote add upstream git@github.com:katanemo/plano.git
```
To sync your fork with the latest changes from the main repository:
```bash
$ git fetch upstream
$ git checkout main
$ git merge upstream/main
git fetch upstream
git checkout main
git merge upstream/main
```
### 4. Install Prerequisites
@ -43,7 +43,7 @@ $ git merge upstream/main
**Install uv** (Python package manager for the planoai CLI):
```bash
$ curl -LsSf https://astral.sh/uv/install.sh | sh
curl -LsSf https://astral.sh/uv/install.sh | sh
```
**Install pre-commit hooks:**
@ -51,8 +51,8 @@ $ curl -LsSf https://astral.sh/uv/install.sh | sh
Pre-commit hooks help maintain code quality by running automated checks before each commit. Install them with:
```bash
$ pip install pre-commit
$ pre-commit install
pip install pre-commit
pre-commit install
```
The pre-commit hooks will automatically run:
@ -66,18 +66,12 @@ The pre-commit hooks will automatically run:
The planoai CLI is used to build, run, and manage Plano locally:
```bash
$ cd cli
$ uv sync
cd cli
uv sync
```
This creates a virtual environment in `.venv` and installs all dependencies.
Optionally, install planoai globally in editable mode:
```bash
$ uv tool install --editable .
```
Now you can use `planoai` commands from anywhere, or use `uv run planoai` from the `cli` directory.
### 6. Create a Branch
@ -85,7 +79,7 @@ Now you can use `planoai` commands from anywhere, or use `uv run planoai` from t
Use a descriptive name for your branch (e.g., fix-bug-123, add-feature-x).
```bash
$ git checkout -b <your-branch-name>
git checkout -b <your-branch-name>
```
### 7. Make Your Changes
@ -97,25 +91,25 @@ Make your changes in the relevant files. If you're adding new features or fixing
**Run Rust tests:**
```bash
$ cd crates
$ cargo test
cd crates
cargo test
```
For library tests only:
```bash
$ cargo test --lib
cargo test --lib
```
**Run Python CLI tests:**
```bash
$ cd cli
$ uv run pytest
cd cli
uv run pytest
```
Or with verbose output:
```bash
$ uv run pytest -v
uv run pytest -v
```
**Run pre-commit checks manually:**
@ -123,18 +117,10 @@ $ uv run pytest -v
Before committing, you can run all pre-commit checks manually:
```bash
$ pre-commit run --all-files
pre-commit run --all-files
```
This ensures your code passes all checks before you commit.
### 9. Push Changes and Create a Pull Request
Once your changes are tested and committed:
```bash
$ git push origin <your-branch-name>
```
### 9. Push changes, and create a Pull request
Go back to the original Plano repository, and you should see a "Compare & pull request" button. Click that to submit a Pull Request (PR). In your PR description, clearly explain the changes you made and why they are necessary.

View file

@ -1,5 +1,5 @@
# build docker image for arch gateway
FROM rust:1.92.0 AS builder
FROM rust:1.93.0 AS builder
RUN rustup -v target add wasm32-wasip1
WORKDIR /arch
COPY crates .

41
apps/katanemo-www/.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View file

@ -0,0 +1,37 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": true,
"includes": ["**", "!node_modules", "!.next", "!dist", "!build"]
},
"formatter": {
"enabled": true,
"indentStyle": "space",
"indentWidth": 2
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noUnknownAtRules": "off"
}
},
"domains": {
"next": "recommended",
"react": "recommended"
}
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
}
}

View file

@ -0,0 +1,33 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
transpilePackages: [
"@katanemo/ui",
"@katanemo/shared-styles",
"@katanemo/tailwind-config",
"@katanemo/tsconfig",
],
experimental: {
externalDir: true,
},
webpack: (config, { isServer }) => {
config.resolve.modules = [
...(config.resolve.modules || []),
"node_modules",
"../../node_modules",
];
if (!isServer) {
config.resolve.fallback = {
...config.resolve.fallback,
fs: false,
};
}
return config;
},
turbopack: {
resolveAlias: {},
},
};
export default nextConfig;

View file

@ -0,0 +1,33 @@
{
"name": "@katanemo/katanemo-www",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "biome check",
"format": "biome format --write",
"typecheck": "tsc --noEmit",
"clean": "rm -rf .next"
},
"dependencies": {
"@katanemo/shared-styles": "*",
"@katanemo/ui": "*",
"next": "^16.1.6",
"react": "19.2.0",
"react-dom": "19.2.0"
},
"devDependencies": {
"@biomejs/biome": "2.2.0",
"@katanemo/tailwind-config": "*",
"@katanemo/tsconfig": "*",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 343 KiB

View file

@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="STANDARD_UPDATE" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" viewBox="0 0 1000 141.83" style="enable-background:new 0 0 1000 141.83;" xml:space="preserve">
<path d="M267.52,104.26c2.51,1.01,6.23,1.66,11.16,1.96v2.11h-55.64v-2.11c4.92-0.3,8.65-0.95,11.16-1.96c2.51-1,4.25-2.61,5.2-4.83
c0.95-2.21,1.43-5.48,1.43-9.8V22.37h-0.45l-38.15,87.92h-0.45l-40.87-87.61h-0.45v66.95c0,4.32,0.5,7.59,1.51,9.8
c1,2.21,2.74,3.82,5.2,4.83c2.46,1.01,6.16,1.66,11.08,1.96v2.11H135.8v-2.11c4.92-0.3,8.62-0.95,11.08-1.96
c2.46-1,4.2-2.61,5.2-4.83c1-2.21,1.51-5.48,1.51-9.8V22.22c0-4.32-0.5-7.59-1.51-9.8c-1.01-2.21-2.74-3.82-5.2-4.83
c-2.47-1-6.16-1.66-11.08-1.96V3.52h38.08l34.78,75.93l33.73-75.93h36.29v2.11c-4.93,0.3-8.65,0.96-11.16,1.96
c-2.51,1.01-4.27,2.64-5.28,4.9c-1.01,2.26-1.51,5.5-1.51,9.73v67.41c0,4.32,0.5,7.59,1.51,9.8
C263.25,101.65,265.01,103.25,267.52,104.26z M343.12,53.44c3.11,5.73,4.67,12.21,4.67,19.45c0,7.24-1.56,13.72-4.67,19.45
c-3.12,5.73-7.46,10.16-13.04,13.27c-5.58,3.12-11.94,4.68-19.08,4.68c-7.14,0-13.47-1.56-19-4.68c-5.53-3.11-9.88-7.54-13.04-13.27
c-3.17-5.73-4.75-12.21-4.75-19.45c0-7.24,1.58-13.72,4.75-19.45s7.51-10.15,13.04-13.27c5.53-3.12,11.86-4.68,19-4.68
c7.14,0,13.5,1.56,19.08,4.68C335.66,43.29,340,47.71,343.12,53.44z M328.04,72.89c0-7.14-0.68-13.27-2.04-18.4
c-1.36-5.13-3.32-9.02-5.88-11.69c-2.56-2.66-5.61-4-9.12-4c-3.52,0-6.56,1.33-9.12,4c-2.56,2.67-4.52,6.56-5.88,11.69
c-1.36,5.13-2.04,11.26-2.04,18.4c0,7.14,0.68,13.27,2.04,18.4c1.36,5.13,3.32,9.02,5.88,11.69c2.56,2.67,5.6,4,9.12,4
c3.52,0,6.56-1.33,9.12-4c2.56-2.66,4.52-6.56,5.88-11.69C327.36,86.16,328.04,80.03,328.04,72.89z M398.92,51.63
c2.61,0,4.98,0.45,7.09,1.36V36.4c-1.31-0.6-2.86-0.9-4.67-0.9c-4.63,0-8.72,2.04-12.29,6.11c-3.57,4.07-6.35,8.75-8.93,18.07h-0.34
V36.1h-0.6l-29.86,8.6v1.96c3.42,0,6.06,0.33,7.92,0.98c1.86,0.65,3.19,1.71,4,3.17c0.8,1.46,1.21,3.44,1.21,5.96v37.4
c0,3.12-0.3,5.43-0.9,6.94c-0.6,1.51-1.71,2.64-3.32,3.39c-1.61,0.75-4.17,1.33-7.69,1.73v2.11h41.92v-2.11
c-3.52-0.3-6.16-0.83-7.92-1.58c-1.76-0.75-2.99-1.91-3.69-3.47c-0.7-1.56-1.06-3.9-1.06-7.01V64.53
C382.95,57.1,389.91,51.63,398.92,51.63z M453.56,46.52 M618.97,101.02c-0.6-1.56-0.9-3.89-0.9-7.01V59.62
c0-16.15-8.94-24.13-20.66-24.13c-11.72,0-19.25,8.3-22.47,12.72h-0.3V36.1h-0.6l-29.86,8.6v1.96c3.32,0,5.93,0.33,7.84,0.98
c1.91,0.65,3.27,1.68,4.07,3.09c0.8,1.41,1.21,3.37,1.21,5.88V94c0,3.12-0.3,5.46-0.9,7.01c-0.6,1.56-1.71,2.71-3.32,3.47
c-1.61,0.75-4.17,1.33-7.69,1.73v2.11h39.21v-2.11c-2.71-0.4-4.8-0.98-6.26-1.73c-1.46-0.75-2.44-1.91-2.94-3.47
c-0.5-1.56-0.75-3.89-0.75-7.01V53.69c3.47-4.21,7.55-8.25,14.18-8.25c9.11,0,11.91,6.87,11.91,14.63V94c0,3.12-0.28,5.46-0.83,7.01
c-0.55,1.56-1.51,2.71-2.87,3.47c-1.36,0.75-3.44,1.33-6.26,1.73v2.11h38.76v-2.11c-3.32-0.4-5.76-0.98-7.31-1.73
C620.65,103.73,619.58,102.58,618.97,101.02z M1000,87.52c-3.33,7.27-12.62,22.77-31.52,22.77c-17.95,0-32.87-14.68-32.87-36.79
c0-6.94,1.51-13.32,4.52-19.15c3.02-5.83,7.11-10.43,12.29-13.8c5.18-3.37,10.88-5.05,17.12-5.05c18.11,0,27.14,12.76,27.14,26.06v3
h-44.93c0,0.27-0.01,0.53-0.01,0.8c0,16,7.12,31.37,25.03,31.37c13.62,0,19.32-7.54,21.56-10.25L1000,87.52z M952.06,59.47h28.18
c-0.19-10.93-3.45-20.47-12.36-20.51C959.52,38.93,953.46,46.73,952.06,59.47z M799.99,101.02c-0.6-1.56-0.9-3.89-0.9-7.01V59.62
c0-5.23-0.88-9.65-2.64-13.27c-1.76-3.62-4.2-6.33-7.31-8.14c-3.12-1.81-6.69-2.71-10.71-2.71c-4.63,0-8.72,1.18-12.29,3.54
c-3.57,2.36-6.96,5.76-10.18,10.18h-0.3V0h-0.6L725.2,8.6v1.96c3.32,0,5.93,0.33,7.84,0.98c1.91,0.65,3.27,1.68,4.07,3.09
c0.8,1.41,1.21,3.37,1.21,5.88V94c0,3.12-0.3,5.46-0.9,7.01c-0.6,1.56-1.71,2.71-3.32,3.47c-1.61,0.75-4.17,1.33-7.69,1.73v2.11
h39.21v-2.11c-2.71-0.4-4.8-0.98-6.26-1.73c-1.46-0.75-2.44-1.91-2.94-3.47c-0.5-1.56-0.75-3.89-0.75-7.01V53.69
c3.47-4.21,7.55-8.25,14.18-8.25c3.72,0,6.63,1.23,8.75,3.69c2.11,2.46,3.17,6.11,3.17,10.93V94c0,3.12-0.28,5.46-0.83,7.01
c-0.55,1.56-1.51,2.71-2.87,3.47c-1.36,0.75-3.44,1.33-6.26,1.73v2.11h38.76v-2.11c-3.32-0.4-5.76-0.98-7.31-1.73
C801.67,103.73,800.6,102.58,799.99,101.02z M690.46,101.82c-22.24,0.15-39.88-17.73-39.88-46.74c0-30.66,16.43-48.54,35.22-48.54
c18.79,0,26.85,16.93,30.96,33.56l3.01-0.05V9.45c-7.06-3.91-18.64-8.32-35.47-8.32c-32.46,0-56.96,23.75-56.96,55.46
c0,30.21,20.44,54.1,55.61,53.95c19.39-0.15,32.91-11.72,39.37-20.59l-2.1-2.25C715.86,92.66,707.14,101.67,690.46,101.82z
M914.35,66.41l-10.4-4.98c-6.91-3.09-10.86-7.18-10.86-12.21c0-5.7,5.22-9.6,12.52-9.6c10.63,0,16.14,5.88,18.42,18.95h3.14v-19
c-3.12-1.21-11.36-4.07-19.75-4.07c-16.59,0-26.69,10.43-26.69,22.47c0,4.63,1.23,8.67,3.69,12.14c2.46,3.47,6.16,6.46,11.08,8.97
l10.86,5.43c7.43,3.39,10.41,6.73,10.41,11.76c0,5.56-4.48,9.91-12.97,9.91c-12.41,0-18.31-11.77-19.93-21.22h-3.14v19.45
c4.73,3.06,13.63,5.88,21.87,5.88c15.9,0,26.99-9.74,26.99-23.52C929.58,76.84,924.28,70.63,914.35,66.41z M90.03,62.64v26.99
c0,4.22,0.6,7.46,1.81,9.73c1.21,2.26,3.22,3.9,6.03,4.9c2.81,1.01,7.09,1.66,12.82,1.96v2.11H52.33v-2.11
c4.92-0.3,8.62-0.95,11.08-1.96c2.46-1,4.2-2.61,5.2-4.83c1-2.21,1.51-5.48,1.51-9.8V22.68c0-13.79-6.23-16.59-15.69-16.59
c-10.38,0-16.73,2.79-16.73,16.14v74.91c0,16.93-11,33.03-32.72,33.03c-1.68,0-3.34-0.1-4.98-0.29v-1.81
c5.16-0.31,9.34-2.06,12.52-5.29c3.52-3.57,5.28-9.42,5.28-17.57V22.22c0-4.22-0.5-7.47-1.51-9.73c-1.01-2.26-2.77-3.89-5.28-4.9
c-2.51-1-6.18-1.66-11.01-1.96V3.52h103.05c23.44,0,37.95,11.72,37.95,29.41c0,20.25-19.89,29.71-37.67,29.71H90.03z M90.03,58.59
h7.19c12.23,0,22.52-7.16,22.52-25.51c0-22.43-14.81-25.51-22.15-25.51h-7.56V58.59z M538.1,102.31c2.49,0,4.68-1.09,6.07-2.03v3.58
c-2.78,2.6-7.94,6.38-15.7,6.38c-6.54,0-12.03-4.15-13.49-10.48h-0.39c-2.55,4.82-10.34,10.91-18.84,10.91
c-10.2,0-17.71-6.8-17.71-17.14c0-8.08,5.53-13.46,14.73-16.72l21.82-7.86V58.82c0-9.21-6.66-13.46-13.88-13.46
c-7.37,0-14.17,3.12-21.25,10.34l-2.12-1.98c6.23-10.06,16.15-18.28,30.46-18.28c13.6,0,24.51,8.93,24.37,23.38l-0.28,36.13
C531.87,100.04,534,102.31,538.1,102.31z M514.59,73.67l-8.78,3.56c-6.8,2.69-11.19,5.81-11.19,13.03c0,6.09,4.25,10.34,10.2,10.34
c3.12,0,7.65-2.41,9.78-5.1V73.67z M871.01,102.31c2.5,0,4.68-1.1,6.08-2.03v3.58c-2.78,2.6-7.94,6.39-15.71,6.39
c-6.54,0-12.03-4.15-13.49-10.48h-0.39c-2.55,4.82-10.34,10.91-18.84,10.91c-10.2,0-17.71-6.8-17.71-17.14
c0-8.08,5.53-13.46,14.73-16.72l21.82-7.86V58.82c0-9.21-6.66-13.46-13.88-13.46c-7.37,0-14.17,3.12-21.25,10.34l-2.13-1.98
c6.23-10.06,16.15-18.28,30.46-18.28c13.6,0,24.51,8.93,24.37,23.38l-0.28,36.13C864.78,100.04,866.9,102.31,871.01,102.31z
M847.49,73.67l-8.78,3.56c-6.8,2.69-11.19,5.81-11.19,13.03c0,6.09,4.25,10.34,10.2,10.34c3.12,0,7.65-2.41,9.78-5.1V73.67z
M460.13,40.45c6.45,5.03,9.3,12.27,9.3,18.45c0,16.23-13.87,24.64-29.66,24.64c-4.16,0-8.18-0.59-11.85-1.75
c-2.04,1.57-3.79,3.62-3.79,6.02c0,5.16,8.41,6.2,13.43,6.49l17.12,1.18c13.72,0.89,22.42,5.75,22.42,18
c0,15.49-18.32,28.33-45,28.33c-15.49,0-26.56-5.31-26.56-14.76c0-8.21,4.52-12.91,16.14-18.49c-7.6-2.21-9.36-6.74-9.36-11.17
c0-6.06,5.41-11.5,12.74-16.66c-8.78-3.7-14.95-11.03-14.95-21.85c0-16.23,13.87-24.64,29.66-24.64c7.26,0,13.04,1.65,17.49,4.24
c3.32-6.67,9.87-14.43,21.61-14.43v13.85C472.6,35.96,464.95,36.97,460.13,40.45z M417.93,120.58c0,8.56,9.74,12.39,22.43,12.39
c12.1,0,28.47-5.46,28.47-14.17c0-5.02-2.8-6.2-10.18-6.79L429,109.81c-1.74-0.13-3.31-0.33-4.72-0.6
C420.01,112.54,417.93,115.96,417.93,120.58z M451.13,58.91c0-13.87-4.57-20.51-11.36-20.51s-11.36,6.64-11.36,20.51
s4.57,20.51,11.36,20.51S451.13,72.78,451.13,58.91z"/>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1014" height="1012" version="1.1" viewBox="-10 0 1014 1012" xmlns="http://www.w3.org/2000/svg">
<path d="m498 1012h-15l103-284h143q19 0 37-12.5t24-30.5l111-309q15-39-4.5-65.5t-59.5-26.5h-198l-164 457-94 258q-84-20-156-66-71-46-123.5-111t-81.5-146q-30-80-30-170 0-87 28-166 28-78 78-142t118-110 148-68l-96 266-163 442h107l137-376h81l-137 376h107l128-350q14-38-5-65t-59-27h-90l103-286h23q105 0 197 40t160.5 108.5 108.5 160.5 40 197-40 197-108.5 160.5-160.5 108.5-197 40zm305-662h-81l-113 310h81z" fill="#024ad8"/>
</svg>

After

Width:  |  Height:  |  Size: 571 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 46 KiB

View file

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="108.08" fill="none" viewBox="0 0 1000 108.08">
<path fill="#e10600" d="M255.96 0c-11.96 0-23.37 5.8-30.8 15.26l-74.1 92.82h37.55l61.12-75.26c3.03-3.7 5.24-5.53 10-5.53 6.07 0 10.26 5.26 10.26 11.48v32.68c0 5.27-2.7 9.6-7.7 9.6h-7.56v27H297V40.4c0-5.54.15-40.39-41.05-40.39zM32.69.03C14.72.03 0 14.48 0 32.45c0 11.61 2.57 24.71 20.67 31.74l114.4 43.9V86.47c0-9.26-8.86-11.79-8.86-11.79L37.14 43.25c-3.37-1.21-5.53-3.38-5.53-7.97a8.3 8.3 0 0 1 8.37-8.24h59.66c4.03 0 8.28 3.29 8.28 7.7v7.57h27.02V.03H32.69zm291.6 0v108.06h42.42V81.07h-7.57c-5 0-7.83-4.32-7.83-9.59V38.93c0-7.97 7.83-13.91 15.13-10.8 2.7 1.2 5 3.64 6.08 6.48l19.72 51.32c5.13 13.51 17.96 22.16 32.41 22.16h.81c18.64 0 33.9-14.86 33.9-33.64V.03h-27v73.75a7.4 7.4 0 0 1-6.36 7.29 7.1 7.1 0 0 1-7.42-4.6l-24.72-67.8c-1.9-5.4-6.35-8.64-11.75-8.64H324.3zm162.27 0v27.15h81.04c14.32 0 26.88 12.56 26.88 26.88 0 14.31-12.56 26.88-26.88 26.88h-81.04v27.15h81.04a54.22 54.22 0 0 0 47.41-27.97 54.52 54.52 0 0 0 6.62-26.06c0-9.46-2.43-18.24-6.62-26.07A53.79 53.79 0 0 0 567.61.03h-81.05zm146.28 0v27.01h5.67c.67 0 1.35 0 2.16.14 4.98.67 7.97 4.73 7.97 10.13v70.78h27.01V.03h-42.81zm102.65 0c-17.97 0-32.7 14.45-32.7 32.42 0 11.61 2.58 24.71 20.67 31.74l114.4 43.9V86.47c0-9.26-8.85-11.79-8.85-11.79l-89.07-31.43c-3.37-1.21-5.53-3.38-5.53-7.97a8.3 8.3 0 0 1 8.37-8.24h59.66c4.03 0 8.28 3.29 8.28 7.7v7.57h27.02V.03H735.49zm129.44 0v108.06h26.75V69.32a3.25 3.25 0 0 1 3.24-3.24h29.45a6.3 6.3 0 0 1 4.45 1.75l42.28 38.3a7.56 7.56 0 0 0 5.09 1.96H1000V81.07h-12.97c-4.05 0-7.97-1.48-11.07-4.05l-20.12-17.7a6.96 6.96 0 0 1 0-10.4l20.12-17.69a18.23 18.23 0 0 1 11.07-4.05H1000V.03h-23.81c-1.88 0-3.7.7-5.1 1.96l-42.27 38.29a6.6 6.6 0 0 1-4.45 1.76h-29.45a3.25 3.25 0 0 1-3.24-3.25V.03h-26.75zM0 81.07v27.01h26.74V81.07H0zm702.55 0v27.01h26.74V81.07h-26.74z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,165 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 12.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 51448) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="svg2008"
sodipodi:version="0.32"
inkscape:version="0.48.5 r10040"
sodipodi:docname="T-Mobile_logo.svg"
width="523"
height="123"
viewBox="44 334 523 123"
overflow="visible"
enable-background="new 44 334 523 123"
xml:space="preserve"><metadata
id="metadata27"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
id="defs25">
</defs>
<sodipodi:namedview
units="mm"
inkscape:cy="-177.77119"
borderopacity="1.0"
pagecolor="#ffffff"
inkscape:zoom="0.78723292"
inkscape:cx="503.25509"
id="base"
inkscape:window-x="-8"
inkscape:window-y="-8"
inkscape:current-layer="svg2008"
inkscape:window-width="1920"
inkscape:window-height="1018"
inkscape:pageshadow="2"
inkscape:pageopacity="0.0"
bordercolor="#666666"
width="186.67mm"
height="27.6mm"
inkscape:document-units="px"
showgrid="false"
inkscape:window-maximized="1"
inkscape:snap-bbox="true"
inkscape:bbox-paths="true"
inkscape:bbox-nodes="true"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:snap-bbox-midpoints="true"
inkscape:object-paths="true"
inkscape:snap-intersection-paths="true"
inkscape:object-nodes="true"
inkscape:snap-smooth-nodes="true"
inkscape:snap-object-midpoints="true"
inkscape:snap-center="true"
showguides="false"
inkscape:guide-bbox="true">
<sodipodi:guide
orientation="0,1"
position="62.635787,-253.72888"
id="guide3247" /><sodipodi:guide
orientation="0,1"
position="243.25454,-217.78617"
id="guide3265" /><sodipodi:guide
orientation="0,1"
position="342.87554,-242.11217"
id="guide4070" /></sodipodi:namedview>
<path
sodipodi:type="arc"
style="color:#000000;fill:#999b9e;fill-opacity:1;fill-rule:nonzero;stroke:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
id="path3209"
sodipodi:cx="366.91431"
sodipodi:cy="41.449261"
sodipodi:rx="2.5044003"
sodipodi:ry="2.5044003"
d="m 369.41871,41.449261 a 2.5044003,2.5044003 0 1 1 -5.0088,0 2.5044003,2.5044003 0 1 1 5.0088,0 z"
transform="matrix(2.5267527,0,0,2.592437,-506.01172,255.1969)" /><rect
style="color:#000000;fill:#999b9e;fill-opacity:1;fill-rule:nonzero;stroke:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
id="rect3211"
width="16.107"
height="16.437"
x="523.84003"
y="391.16901" /><rect
y="391.16901"
x="204.39101"
height="16.437"
width="16.107"
id="rect3213"
style="color:#000000;fill:#999b9e;fill-opacity:1;fill-rule:nonzero;stroke:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /><rect
style="color:#000000;fill:#999b9e;fill-opacity:1;fill-rule:nonzero;stroke:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
id="rect3215"
width="16.107"
height="16.437"
x="156.07899"
y="391.16901" /><rect
y="391.16901"
x="108.5"
height="16.437"
width="16.107"
id="rect3217"
style="color:#000000;fill:#999b9e;fill-opacity:1;fill-rule:nonzero;stroke:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate" /><rect
style="color:#000000;fill:#999b9e;fill-opacity:1;fill-rule:nonzero;stroke:none;visibility:visible;display:inline;overflow:visible;enable-background:accumulate"
id="rect3219"
width="16.107"
height="16.437"
x="59.225998"
y="391.16901" /><path
style="fill:#ed008c"
inkscape:connector-curvature="0"
id="path3221"
d="m 97.909,358.36 h 1.911 c 12.343,0 18.096,6.603 20.5,24.212 l 3.822,-0.169 -0.51,-27.938 H 60.824 l -0.619,27.938 3.678,0.169 c 0.637,-6.603 1.401,-10.159 3.059,-13.715 2.931,-6.604 9.048,-10.497 16.566,-10.497 h 2.676 v 60.784 c 0,6.434 -0.382,8.466 -1.91,9.99 -1.275,1.185 -3.824,1.693 -6.756,1.693 h -2.931 v 4.063 h 34.919 v -4.063 h -2.949 c -2.913,0 -5.48,-0.509 -6.736,-1.693 -1.529,-1.524 -1.911,-3.557 -1.911,-9.99 V 358.36" /><path
inkscape:connector-curvature="0"
id="path3223"
d="m 264.053,434.891 20.35,-71.006 v 55.721 c 0,6.081 -0.346,8.218 -1.726,9.697 -1.036,1.15 -3.437,1.644 -6.099,1.644 h -1.035 v 3.944 h 28.765 v -3.944 h -1.497 c -2.646,0 -5.062,-0.493 -6.097,-1.644 -1.382,-1.479 -1.727,-3.616 -1.727,-9.697 v -47.502 c 0,-6.082 0.345,-8.383 1.727,-9.862 1.15,-0.986 3.451,-1.644 6.097,-1.644 h 1.497 v -3.781 h -22.321 l -16.799,57.364 -16.437,-57.364 h -22.206 v 3.781 h 2.07 c 6.099,0 7.249,1.315 7.249,9.205 v 45.2 c 0,7.232 -0.345,10.191 -1.381,12.328 -1.15,2.137 -3.583,3.616 -6.098,3.616 h -1.841 v 3.944 h 23.701 v -3.944 h -1.495 c -2.991,0 -5.408,-1.151 -6.805,-3.452 -1.364,-2.301 -1.709,-4.438 -1.709,-12.492 v -51.117 l 20.233,71.006 h 3.584"
style="fill:#999b9e;fill-opacity:1" /><path
inkscape:connector-curvature="0"
id="path3225"
d="m 329.044,379.335 c -14.267,0 -23.586,11.177 -23.586,28.765 0,16.93 9.319,28.436 23.226,28.436 14.035,0 23.355,-11.506 23.355,-28.6 0,-16.931 -9.32,-28.601 -22.995,-28.601 m -0.46,3.78 c 4.126,0 7.692,2.63 9.648,7.068 1.841,4.109 2.646,9.698 2.646,17.752 0,16.6 -4.027,24.818 -12.195,24.818 -8.153,0 -12.082,-8.219 -12.082,-24.983 0,-7.89 0.821,-13.478 2.662,-17.587 1.826,-4.273 5.524,-7.068 9.321,-7.068"
style="fill:#999b9e;fill-opacity:1" /><path
inkscape:connector-curvature="0"
id="path3227"
d="m 371.155,355.995 -16.339,0.822 v 3.616 h 0.789 c 4.833,0 5.885,1.644 5.885,9.369 v 53.419 c 0,6.903 -0.23,8.712 -1.15,11.67 h 3.797 c 2.859,-4.767 3.566,-5.588 4.717,-5.588 0.559,0 1.117,0.164 1.94,0.986 5.653,4.602 8.069,5.588 13.247,5.588 12.409,0 21.384,-11.999 21.384,-29.093 0,-16.108 -8.301,-26.956 -20.71,-26.956 -6.443,0 -11.144,3.123 -13.56,8.711 v -32.544 m 11.933,27.942 c 7.381,0 11.177,7.89 11.177,23.34 0,16.272 -3.912,24.655 -11.391,24.655 -8.284,0 -12.641,-8.548 -12.641,-24.162 0,-7.89 1.037,-13.313 3.453,-17.423 2.169,-3.944 5.868,-6.41 9.402,-6.41"
style="fill:#999b9e;fill-opacity:1" /><path
inkscape:connector-curvature="0"
style="fill:#999b9e;fill-opacity:1"
d="m 426.711,380.157 -16.782,0.986 v 3.616 h 1.266 c 4.815,0 5.852,1.644 5.852,9.205 v 27.778 c 0,7.562 -1.036,9.37 -5.852,9.37 h -1.825 v 3.779 h 25.051 v -3.779 h -1.809 c -4.849,0 -5.9,-1.645 -5.9,-9.37 v -41.585"
id="path3229" /><path
inkscape:connector-curvature="0"
id="path3231"
d="m 455.819,355.995 -16.782,0.822 v 3.616 h 1.251 c 4.832,0 5.867,1.644 5.867,9.369 v 51.939 c 0,7.726 -1.035,9.37 -5.867,9.37 h -1.825 v 3.779 h 25.065 v -3.779 h -1.841 c -4.849,0 -5.868,-1.645 -5.868,-9.37 v -65.746"
style="fill:#999b9e;fill-opacity:1" /><path
inkscape:connector-curvature="0"
id="path3233"
d="m 509.306,407.606 c -0.56,-17.423 -8.844,-28.271 -21.368,-28.271 -12.098,0 -20.826,11.834 -20.826,28.271 0,17.587 8.613,28.929 21.96,28.929 8.629,0 14.612,-4.603 19.56,-14.794 l -3.451,-1.808 c -4.027,8.547 -8.054,11.999 -14.268,11.999 -9.089,0 -12.984,-7.231 -13.101,-24.326 h 31.494 m -31.378,-4.11 c 0.099,-12.327 4.108,-20.052 10.568,-20.052 6.443,0 10.239,7.561 10.125,20.052 h -20.693"
style="fill:#999b9e;fill-opacity:1" /><g
id="g3235"
style="fill:#999b9e;fill-opacity:1">
<g
id="g3237"
style="fill:#999b9e;fill-opacity:1">
<path
inkscape:connector-curvature="0"
d="m 545.988,377.995 c 0.972,0 1.921,0.249 2.847,0.748 0.926,0.499 1.647,1.213 2.163,2.141 0.518,0.928 0.776,1.896 0.776,2.904 0,0.998 -0.255,1.957 -0.765,2.877 -0.508,0.921 -1.223,1.636 -2.14,2.145 -0.919,0.509 -1.879,0.764 -2.882,0.764 -1.003,0 -1.963,-0.254 -2.882,-0.764 -0.918,-0.509 -1.634,-1.223 -2.144,-2.145 -0.512,-0.92 -0.768,-1.879 -0.768,-2.877 0,-1.008 0.259,-1.976 0.779,-2.904 0.519,-0.928 1.242,-1.643 2.166,-2.141 0.93,-0.499 1.879,-0.748 2.85,-0.748 z m 0,0.964 c -0.812,0 -1.604,0.208 -2.372,0.625 -0.769,0.417 -1.371,1.011 -1.806,1.785 -0.434,0.774 -0.65,1.581 -0.65,2.418 0,0.833 0.213,1.631 0.64,2.395 0.426,0.764 1.023,1.359 1.789,1.786 0.767,0.427 1.567,0.64 2.399,0.64 0.834,0 1.632,-0.213 2.399,-0.64 0.766,-0.426 1.361,-1.021 1.785,-1.786 0.424,-0.764 0.637,-1.562 0.637,-2.395 0,-0.837 -0.216,-1.644 -0.648,-2.418 -0.432,-0.773 -1.033,-1.368 -1.805,-1.785 -0.771,-0.417 -1.56,-0.625 -2.368,-0.625 z m -2.54,8.023 v -6.23 h 2.145 c 0.731,0 1.263,0.058 1.59,0.173 0.329,0.115 0.59,0.316 0.784,0.603 0.195,0.286 0.293,0.591 0.293,0.913 0,0.455 -0.164,0.852 -0.49,1.189 -0.324,0.337 -0.757,0.527 -1.296,0.568 0.221,0.092 0.397,0.202 0.53,0.33 0.252,0.245 0.559,0.657 0.922,1.235 l 0.761,1.22 h -1.233 L 546.899,386 c -0.435,-0.769 -0.784,-1.251 -1.047,-1.447 -0.184,-0.145 -0.453,-0.217 -0.806,-0.217 h -0.592 v 2.647 h -1.006 z m 1.007,-3.502 h 1.226 c 0.584,0 0.983,-0.087 1.197,-0.262 0.214,-0.175 0.319,-0.406 0.319,-0.694 0,-0.185 -0.051,-0.351 -0.154,-0.497 -0.103,-0.146 -0.244,-0.256 -0.428,-0.328 -0.182,-0.072 -0.52,-0.107 -1.013,-0.107 h -1.147 v 1.888 z"
id="path3239"
style="fill:#999b9e;fill-opacity:1" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.4 KiB

View file

@ -0,0 +1,19 @@
:root {
--katanemo-bg-start: #04171a;
--katanemo-bg-end: #0a292e;
--font-sans: var(--font-ibm-plex-sans);
}
html,
body {
background: linear-gradient(
to bottom,
var(--katanemo-bg-start),
var(--katanemo-bg-end)
);
min-height: 100%;
}
body {
font-family: var(--font-ibm-plex-sans, var(--font-sans));
}

View file

@ -0,0 +1,87 @@
import type { Metadata } from "next";
import Script from "next/script";
import localFont from "next/font/local";
import { siteConfig } from "../lib/metadata";
import "@katanemo/shared-styles/globals.css";
import "./globals.css";
const ibmPlexSans = localFont({
src: [
{
path: "../../../www/public/fonts/IBMPlexSans-VariableFont_wdth,wght.ttf",
weight: "100 700",
style: "normal",
},
{
path: "../../../www/public/fonts/IBMPlexSans-Italic-VariableFont_wdth,wght.ttf",
weight: "100 700",
style: "italic",
},
],
display: "swap",
variable: "--font-ibm-plex-sans",
});
const baseUrl = new URL(siteConfig.url);
export const metadata: Metadata = {
title: `${siteConfig.name} - ${siteConfig.tagline}`,
description: siteConfig.description,
keywords: siteConfig.keywords,
metadataBase: baseUrl,
authors: siteConfig.authors,
creator: siteConfig.creator,
icons: {
icon: "/KatanemoLogo.svg",
},
openGraph: {
type: "website",
locale: "en_US",
url: siteConfig.url,
title: `${siteConfig.name} - ${siteConfig.tagline}`,
description: siteConfig.description,
siteName: siteConfig.name,
images: [
{
url: siteConfig.ogImage,
width: 1200,
height: 630,
alt: `${siteConfig.name} - ${siteConfig.tagline}`,
},
],
},
twitter: {
card: "summary_large_image",
title: `${siteConfig.name} - ${siteConfig.tagline}`,
description: siteConfig.description,
images: [siteConfig.ogImage],
creator: "@katanemo",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${ibmPlexSans.variable} antialiased text-white`}>
{/* Google tag (gtag.js) */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-RLD5BDNW5N"
strategy="afterInteractive"
/>
<Script strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-RLD5BDNW5N');
`}
</Script>
<div className="min-h-screen">{children}</div>
</body>
</html>
);
}

View file

@ -0,0 +1,81 @@
import Image from "next/image";
import Link from "next/link";
import LogoSlider from "../components/LogoSlider";
export default function HomePage() {
return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden px-6 pt-12 pb-16 font-sans sm:pt-20 lg:items-start lg:justify-start lg:pt-24">
<div className="relative mx-auto w-full max-w-6xl flex flex-col items-center justify-center text-left lg:items-start lg:justify-start">
<div className="pointer-events-none mb-6 w-full self-start lg:hidden">
<Image
src="/KatanemoLogo.svg"
alt="Katanemo Logo"
width={64}
height={64}
priority
className="h-auto w-10 sm:w-20"
/>
</div>
<div className="relative z-10 max-w-xl sm:max-w-2xl lg:max-w-2xl xl:max-w-8xl lg:pr-[26vw] xl:pr-[2vw] sm:right-0 md:right-0 lg:right-0 xl:right-20 2xl:right-50 sm:mt-36 mt-0">
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-sans font-medium leading-tight tracking-tight text-white">
Forward-deployed AI infrastructure engineers.
</h1>
<p className="mt-4 font-light tracking-[-0.4px] max-w-2xl text-base sm:text-lg md:text-xl lg:text-2xl text-white/70">
Bringing industry-leading research and open-source technologies to
accelerate the development of AI agents.
</p>
<p className="mt-6 sm:mt-12 font-light tracking-[-0.4px] max-w-2xl text-xs sm:text-sm md:text-sm lg:text-sm text-white/50">
Trusted by leading companies to deliver agents to production.
</p>
<LogoSlider />
<div className="mt-18 flex flex-col gap-3 text-lg sm:text-xl lg:text-3xl font-light tracking-wide sm:tracking-[-0.03em] leading-snug">
<Link
href="https://huggingface.co/katanemo"
className="flex items-center gap-2 text-[#31C887] hover:text-[#45e394] transition-colors"
>
<span>Models Research</span>
<span aria-hidden className="text-emerald-300">
</span>
</Link>
<Link
href="https://planoai.dev"
className="flex items-center gap-2 text-[#31C887] hover:text-[#45e394] transition-colors"
>
<span>Plano - Open Source Agent Infrastructure</span>
<span aria-hidden className="text-emerald-300">
</span>
</Link>
</div>
<div className="mt-24">
<div className="sm:max-w-7xl max-w-72 mb-4 text-sm sm:text-base lg:text-lg text-white/70 tracking-[-0.3px] sm:tracking-[0.8px]! font-light">
Move faster and more reliably by letting Katanemo do the
heavy-lifting.
</div>
<a
href="mailto:interest@katanemo.com"
className="text-sm sm:text-sm text-white/50 hover:text-white transition-colors cursor-pointer"
>
Contact Us
</a>
<div className="mt-4 h-px w-52 bg-white/10" />
<div className="mt-3 text-sm text-white/50">
© 2026 Katanemo Labs, Inc.
</div>
</div>
</div>
<div className="pointer-events-none absolute top-50 right-[-20vw] sm:right-[-10vw] md:right-[-5vw] lg:right-[-20vw] xl:right-[-7vw] 2xl:right-[-17vw] hidden lg:block">
<Image
src="/KatanemoLogo.svg"
alt="Katanemo Logo"
width={900}
height={1000}
priority
className="h-[95vh] w-auto max-w-none opacity-90"
/>
</div>
</div>
</main>
);
}

View file

@ -0,0 +1,608 @@
"use client";
import Image from "next/image";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
export type LogoItem =
| {
node: React.ReactNode;
href?: string;
title?: string;
ariaLabel?: string;
}
| {
src: string;
alt?: string;
href?: string;
title?: string;
srcSet?: string;
sizes?: string;
width?: number;
height?: number;
};
export interface LogoLoopProps {
logos: LogoItem[];
speed?: number;
direction?: "left" | "right" | "up" | "down";
width?: number | string;
logoHeight?: number;
gap?: number;
pauseOnHover?: boolean;
hoverSpeed?: number;
fadeOut?: boolean;
fadeOutColor?: string;
scaleOnHover?: boolean;
renderItem?: (item: LogoItem, key: React.Key) => React.ReactNode;
ariaLabel?: string;
className?: string;
style?: React.CSSProperties;
}
const ANIMATION_CONFIG = {
SMOOTH_TAU: 0.25,
MIN_COPIES: 2,
COPY_HEADROOM: 2,
} as const;
const toCssLength = (value?: number | string): string | undefined =>
typeof value === "number" ? `${value}px` : (value ?? undefined);
const cx = (...parts: Array<string | false | null | undefined>) =>
parts.filter(Boolean).join(" ");
const isNodeItem = (
item: LogoItem,
): item is Extract<LogoItem, { node: React.ReactNode }> => "node" in item;
const isImageItem = (
item: LogoItem,
): item is Extract<LogoItem, { src: string }> => "src" in item;
const useResizeObserver = (
callback: () => void,
elements: Array<React.RefObject<Element | null>>,
dependencies: React.DependencyList,
) => {
useEffect(() => {
if (!window.ResizeObserver) {
const handleResize = () => callback();
window.addEventListener("resize", handleResize);
callback();
return () => window.removeEventListener("resize", handleResize);
}
const observers: Array<ResizeObserver | null> = [];
for (const ref of elements) {
if (!ref.current) {
observers.push(null);
continue;
}
const observer = new ResizeObserver(callback);
observer.observe(ref.current);
observers.push(observer);
}
callback();
return () => {
for (const observer of observers) {
observer?.disconnect();
}
};
}, [callback, elements, ...elements, ...dependencies]);
};
const useImageLoader = (
seqRef: React.RefObject<HTMLUListElement | null>,
onLoad: () => void,
dependencies: React.DependencyList,
) => {
useEffect(() => {
const images = seqRef.current?.querySelectorAll("img") ?? [];
if (images.length === 0) {
onLoad();
return;
}
let remainingImages = images.length;
const handleImageLoad = () => {
remainingImages -= 1;
if (remainingImages === 0) {
onLoad();
}
};
images.forEach((img) => {
const htmlImg = img as HTMLImageElement;
if (htmlImg.complete) {
handleImageLoad();
} else {
htmlImg.addEventListener("load", handleImageLoad, { once: true });
htmlImg.addEventListener("error", handleImageLoad, { once: true });
}
});
return () => {
images.forEach((img) => {
img.removeEventListener("load", handleImageLoad);
img.removeEventListener("error", handleImageLoad);
});
};
}, [onLoad, seqRef, ...dependencies]);
};
const useAnimationLoop = (
trackRef: React.RefObject<HTMLDivElement | null>,
targetVelocity: number,
seqWidth: number,
seqHeight: number,
isHovered: boolean,
hoverSpeed: number | undefined,
isVertical: boolean,
) => {
const rafRef = useRef<number | null>(null);
const lastTimestampRef = useRef<number | null>(null);
const offsetRef = useRef(0);
const velocityRef = useRef(0);
useEffect(() => {
const track = trackRef.current;
if (!track) return;
const prefersReduced =
typeof window !== "undefined" &&
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const seqSize = isVertical ? seqHeight : seqWidth;
if (seqSize > 0) {
offsetRef.current = ((offsetRef.current % seqSize) + seqSize) % seqSize;
const transformValue = isVertical
? `translate3d(0, ${-offsetRef.current}px, 0)`
: `translate3d(${-offsetRef.current}px, 0, 0)`;
track.style.transform = transformValue;
}
if (prefersReduced) {
track.style.transform = isVertical
? "translate3d(0, 0, 0)"
: "translate3d(0, 0, 0)";
return () => {
lastTimestampRef.current = null;
};
}
const animate = (timestamp: number) => {
if (lastTimestampRef.current === null) {
lastTimestampRef.current = timestamp;
}
const deltaTime =
Math.max(0, timestamp - lastTimestampRef.current) / 1000;
lastTimestampRef.current = timestamp;
const target =
isHovered && hoverSpeed !== undefined ? hoverSpeed : targetVelocity;
const easingFactor =
1 - Math.exp(-deltaTime / ANIMATION_CONFIG.SMOOTH_TAU);
velocityRef.current += (target - velocityRef.current) * easingFactor;
if (seqSize > 0) {
let nextOffset = offsetRef.current + velocityRef.current * deltaTime;
nextOffset = ((nextOffset % seqSize) + seqSize) % seqSize;
offsetRef.current = nextOffset;
const transformValue = isVertical
? `translate3d(0, ${-offsetRef.current}px, 0)`
: `translate3d(${-offsetRef.current}px, 0, 0)`;
track.style.transform = transformValue;
}
rafRef.current = requestAnimationFrame(animate);
};
rafRef.current = requestAnimationFrame(animate);
return () => {
if (rafRef.current !== null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
lastTimestampRef.current = null;
};
}, [
trackRef,
targetVelocity,
seqWidth,
seqHeight,
isHovered,
hoverSpeed,
isVertical,
]);
};
export const LogoLoop = React.memo<LogoLoopProps>(
({
logos,
speed = 120,
direction = "left",
width = "100%",
logoHeight = 28,
gap = 32,
pauseOnHover,
hoverSpeed,
fadeOut = false,
fadeOutColor,
scaleOnHover = false,
renderItem,
ariaLabel = "Partner logos",
className,
style,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const seqRef = useRef<HTMLUListElement>(null);
const [seqWidth, setSeqWidth] = useState<number>(0);
const [seqHeight, setSeqHeight] = useState<number>(0);
const [copyCount, setCopyCount] = useState<number>(
ANIMATION_CONFIG.MIN_COPIES,
);
const [isHovered, setIsHovered] = useState<boolean>(false);
const effectiveHoverSpeed = useMemo(() => {
if (hoverSpeed !== undefined) return hoverSpeed;
if (pauseOnHover === true) return 0;
if (pauseOnHover === false) return undefined;
return 0;
}, [hoverSpeed, pauseOnHover]);
const isVertical = direction === "up" || direction === "down";
const targetVelocity = useMemo(() => {
const magnitude = Math.abs(speed);
let directionMultiplier: number;
if (isVertical) {
directionMultiplier = direction === "up" ? 1 : -1;
} else {
directionMultiplier = direction === "left" ? 1 : -1;
}
const speedMultiplier = speed < 0 ? -1 : 1;
return magnitude * directionMultiplier * speedMultiplier;
}, [speed, direction, isVertical]);
const updateDimensions = useCallback(() => {
const containerWidth = containerRef.current?.clientWidth ?? 0;
const sequenceRect = seqRef.current?.getBoundingClientRect?.();
const sequenceWidth = sequenceRect?.width ?? 0;
const sequenceHeight = sequenceRect?.height ?? 0;
if (isVertical) {
const parentHeight =
containerRef.current?.parentElement?.clientHeight ?? 0;
if (containerRef.current && parentHeight > 0) {
const targetHeight = Math.ceil(parentHeight);
if (containerRef.current.style.height !== `${targetHeight}px`)
containerRef.current.style.height = `${targetHeight}px`;
}
if (sequenceHeight > 0) {
setSeqHeight(Math.ceil(sequenceHeight));
const viewport =
containerRef.current?.clientHeight ??
parentHeight ??
sequenceHeight;
const copiesNeeded =
Math.ceil(viewport / sequenceHeight) +
ANIMATION_CONFIG.COPY_HEADROOM;
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));
}
} else if (sequenceWidth > 0) {
setSeqWidth(Math.ceil(sequenceWidth));
const copiesNeeded =
Math.ceil(containerWidth / sequenceWidth) +
ANIMATION_CONFIG.COPY_HEADROOM;
setCopyCount(Math.max(ANIMATION_CONFIG.MIN_COPIES, copiesNeeded));
}
}, [isVertical]);
useResizeObserver(
updateDimensions,
[containerRef, seqRef],
[logos, gap, logoHeight, isVertical],
);
useImageLoader(seqRef, updateDimensions, [
logos,
gap,
logoHeight,
isVertical,
]);
useAnimationLoop(
trackRef,
targetVelocity,
seqWidth,
seqHeight,
isHovered,
effectiveHoverSpeed,
isVertical,
);
const cssVariables = useMemo(
() =>
({
"--logoloop-gap": `${gap}px`,
"--logoloop-logoHeight": `${logoHeight}px`,
...(fadeOutColor && { "--logoloop-fadeColor": fadeOutColor }),
}) as React.CSSProperties,
[gap, logoHeight, fadeOutColor],
);
const rootClasses = useMemo(
() =>
cx(
"relative group",
isVertical
? "overflow-hidden h-full inline-block"
: "overflow-x-hidden",
"[--logoloop-gap:32px]",
"[--logoloop-logoHeight:28px]",
"[--logoloop-fadeColorAuto:#ffffff]",
"dark:[--logoloop-fadeColorAuto:#0b0b0b]",
scaleOnHover && "py-[calc(var(--logoloop-logoHeight)*0.1)]",
className,
),
[isVertical, scaleOnHover, className],
);
const handleMouseEnter = useCallback(() => {
if (effectiveHoverSpeed !== undefined) setIsHovered(true);
}, [effectiveHoverSpeed]);
const handleMouseLeave = useCallback(() => {
if (effectiveHoverSpeed !== undefined) setIsHovered(false);
}, [effectiveHoverSpeed]);
const renderLogoItem = useCallback(
(item: LogoItem, key: React.Key) => {
if (renderItem) {
return (
<li
className={cx(
"flex-none text-[length:var(--logoloop-logoHeight)] leading-[1]",
isVertical
? "mb-[var(--logoloop-gap)]"
: "mr-[var(--logoloop-gap)]",
scaleOnHover && "overflow-visible group/item",
)}
key={key}
>
{renderItem(item, key)}
</li>
);
}
const content = isNodeItem(item) ? (
<span
className={cx(
"inline-flex items-center",
"motion-reduce:transition-none",
scaleOnHover &&
"transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/item:scale-120",
)}
aria-hidden={!!item.href && !item.ariaLabel}
>
{item.node}
</span>
) : (
<Image
className={cx(
"h-[var(--logoloop-logoHeight)] w-auto block object-contain",
"[-webkit-user-drag:none] pointer-events-none",
"[image-rendering:-webkit-optimize-contrast]",
"motion-reduce:transition-none",
scaleOnHover &&
"transition-transform duration-300 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover/item:scale-120",
)}
src={item.src}
sizes={item.sizes}
width={item.width ?? 120}
height={item.height ?? 32}
alt={item.alt ?? ""}
title={item.title}
draggable={false}
/>
);
const itemAriaLabel = isNodeItem(item)
? (item.ariaLabel ?? item.title)
: (item.alt ?? item.title);
const inner = item.href ? (
<a
className={cx(
"inline-flex items-center no-underline rounded",
"transition-opacity duration-200 ease-linear",
"hover:opacity-80",
"focus-visible:outline focus-visible:outline-current focus-visible:outline-offset-2",
)}
href={item.href}
aria-label={itemAriaLabel || "logo link"}
target="_blank"
rel="noreferrer noopener"
>
{content}
</a>
) : (
content
);
return (
<li
className={cx(
"flex-none text-[length:var(--logoloop-logoHeight)] leading-[1]",
isVertical
? "mb-[var(--logoloop-gap)]"
: "mr-[var(--logoloop-gap)]",
scaleOnHover && "overflow-visible group/item",
)}
key={key}
>
{inner}
</li>
);
},
[isVertical, scaleOnHover, renderItem],
);
const logoLists = useMemo(
() =>
Array.from({ length: copyCount }, (_, copyIndex) => (
<ul
className={cx("flex items-center", isVertical && "flex-col")}
// biome-ignore lint/suspicious/noArrayIndexKey: Static copies for animation.
key={`copy-${copyIndex}`}
aria-hidden={copyIndex > 0}
ref={copyIndex === 0 ? seqRef : undefined}
>
{logos.map((item, itemIndex) =>
renderLogoItem(item, `${copyIndex}-${itemIndex}`),
)}
</ul>
)),
[copyCount, logos, renderLogoItem, isVertical],
);
const containerStyle = useMemo(
(): React.CSSProperties => ({
width: isVertical
? toCssLength(width) === "100%"
? undefined
: toCssLength(width)
: (toCssLength(width) ?? "100%"),
...cssVariables,
...style,
}),
[width, cssVariables, style, isVertical],
);
return (
<section
ref={containerRef}
className={rootClasses}
style={containerStyle}
aria-label={ariaLabel}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{fadeOut &&
(isVertical ? (
<>
<div
aria-hidden
className={cx(
"pointer-events-none absolute inset-x-0 top-0 z-10",
"h-[clamp(24px,8%,120px)]",
"bg-[linear-gradient(to_bottom,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]",
)}
/>
<div
aria-hidden
className={cx(
"pointer-events-none absolute inset-x-0 bottom-0 z-10",
"h-[clamp(24px,8%,120px)]",
"bg-[linear-gradient(to_top,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]",
)}
/>
</>
) : (
<>
<div
aria-hidden
className={cx(
"pointer-events-none absolute inset-y-0 left-0 z-10",
"w-[clamp(24px,8%,120px)]",
"bg-[linear-gradient(to_right,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]",
)}
/>
<div
aria-hidden
className={cx(
"pointer-events-none absolute inset-y-0 right-0 z-10",
"w-[clamp(24px,8%,120px)]",
"bg-[linear-gradient(to_left,var(--logoloop-fadeColor,var(--logoloop-fadeColorAuto))_0%,rgba(0,0,0,0)_100%)]",
)}
/>
</>
))}
<div
className={cx(
"flex will-change-transform select-none relative z-0",
"motion-reduce:transform-none",
isVertical ? "flex-col h-max w-full" : "flex-row w-max",
)}
ref={trackRef}
>
{logoLists}
</div>
</section>
);
},
);
LogoLoop.displayName = "LogoLoop";
const logos: LogoItem[] = [
{ src: "/logos/chase.svg", alt: "Chase" },
{ src: "/logos/hp.svg", alt: "HP" },
{ src: "/logos/huggingface.svg", alt: "Hugging Face" },
{ src: "/logos/sandisk.svg", alt: "SanDisk" },
{ src: "/logos/tmobile.svg", alt: "T-Mobile" },
];
export default function LogoSlider() {
return (
<div className="mt-5 w-full max-w-90 sm:max-w-152">
<LogoLoop
logos={logos}
speed={40}
logoHeight={17}
gap={30}
fadeOut={false}
renderItem={(item, key) => {
if (isImageItem(item)) {
return (
<Image
key={key}
src={item.src}
alt={item.alt ?? ""}
width={item.width ?? 120}
height={item.height ?? 32}
className="h-[var(--logoloop-logoHeight)] w-auto opacity-70 brightness-0 invert"
draggable={false}
/>
);
}
return <span key={key}>{item.node}</span>;
}}
className="relative"
style={{
WebkitMaskImage:
"linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
maskImage:
"linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
}}
/>
</div>
);
}

View file

@ -0,0 +1,48 @@
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://katanemo.com";
export const siteConfig = {
name: "Katanemo",
tagline: "Forward-deployed AI infrastructure engineers",
description:
"Forward-deployed AI infrastructure engineers delivering industry-leading research and open-source technologies for agentic AI development efforts.",
url: BASE_URL,
ogImage: `${BASE_URL}/KatanemoLogo.svg`,
links: {
docs: "https://docs.katanemo.com",
github: "https://github.com/katanemo/plano",
discord: "https://discord.gg/pGZf2gcwEc",
huggingface: "https://huggingface.co/katanemo",
},
keywords: [
"Katanemo AI",
"Katanemo",
"Katanemo Labs",
"forward-deployed AI engineers",
"forward deployed AI infrastructure",
"AI infrastructure engineers",
"embedded AI engineers",
"on-site AI engineers",
"model training",
"AI model research",
"LLM model training",
"machine learning model development",
"custom AI model training",
"open source agentic AI",
"agentic AI stack",
"AI agent infrastructure",
"open source agent framework",
"agentic AI development",
"AI agent platform",
"Plano agent infrastructure",
"LLM infrastructure",
"AI infrastructure platform",
"agentic AI tools",
"AI agent orchestration",
"open source AI stack",
"enterprise AI infrastructure",
"production AI systems",
"AI deployment infrastructure",
],
authors: [{ name: "Katanemo", url: "https://github.com/katanemo/plano" }],
creator: "Katanemo",
};

View file

@ -0,0 +1,20 @@
{
"extends": "@katanemo/tsconfig/nextjs.json",
"compilerOptions": {
"jsx": "react-jsx",
"esModuleInterop": true,
"paths": {
"@/*": ["./src/*"],
"@katanemo/ui": ["../../packages/ui/src"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

View file

@ -21,18 +21,25 @@
"@portabletext/react": "^5.0.0",
"@portabletext/types": "^3.0.0",
"@sanity/client": "^7.13.0",
"@sanity/code-input": "^6.0.4",
"@sanity/image-url": "^1.2.0",
"@sanity/table": "^2.0.1",
"@vercel/analytics": "^1.5.0",
"csv-parse": "^6.1.0",
"easymde": "^2.20.0",
"framer-motion": "^12.23.24",
"jsdom": "^27.2.0",
"next": "^16.0.7",
"next": "^16.1.6",
"next-sanity": "^11.6.9",
"papaparse": "^5.5.3",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0",
"remark-gfm": "^4.0.1",
"resend": "^6.6.0",
"sanity": "^4.18.0",
"sanity-plugin-markdown": "^7.0.4",
"styled-components": "^6.1.19"
},
"devDependencies": {
@ -44,6 +51,7 @@
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-syntax-highlighter": "^15.5.13",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"

View file

@ -1,4 +1,7 @@
import { defineConfig } from "sanity";
import { codeInput } from "@sanity/code-input";
import { table } from "@sanity/table";
import { markdownSchema } from "sanity-plugin-markdown";
import { structureTool } from "sanity/structure";
import { schemaTypes } from "./schemaTypes";
@ -11,7 +14,7 @@ export default defineConfig({
basePath: "/studio",
plugins: [structureTool()],
plugins: [structureTool(), codeInput(), table(), markdownSchema()],
schema: {
types: schemaTypes,

View file

@ -35,6 +35,45 @@ export const blogType = defineType({
{
type: "block",
},
{
type: "code",
options: {
language: "typescript",
languageAlternatives: [
{ title: "TypeScript", value: "typescript" },
{ title: "JavaScript", value: "javascript" },
{ title: "HTML", value: "html" },
{ title: "CSS", value: "css" },
{ title: "Bash", value: "sh" },
{ title: "Python", value: "python" },
{ title: "Markdown", value: "markdown" },
{ title: "YAML", value: "yaml" },
{ title: "JSON", value: "json" },
{ title: "XML", value: "xml" },
{ title: "SQL", value: "sql" },
{ title: "Shell", value: "shell" },
{ title: "PowerShell", value: "powershell" },
{ title: "Batch", value: "batch" },
],
withFilename: true,
},
},
{
type: "object",
name: "markdownBlock",
title: "Markdown",
fields: [
{
name: "markdown",
title: "Markdown",
type: "markdown",
description: "Markdown content with preview and image uploads",
},
],
},
{
type: "table",
},
{
type: "image",
fields: [

View file

@ -1,10 +1,10 @@
import { Resend } from 'resend';
import { NextResponse } from 'next/server';
import { Resend } from "resend";
import { NextResponse } from "next/server";
function getResendClient() {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
throw new Error('RESEND_API_KEY environment variable is not set');
throw new Error("RESEND_API_KEY environment variable is not set");
}
return new Resend(apiKey);
}
@ -17,18 +17,24 @@ interface ContactPayload {
lookingFor: string;
}
function buildProperties(company?: string, lookingFor?: string): Record<string, string> | undefined {
function buildProperties(
company?: string,
lookingFor?: string,
): Record<string, string> | undefined {
const properties: Record<string, string> = {};
if (company) properties.company_name = company;
if (lookingFor) properties.looking_for = lookingFor;
return Object.keys(properties).length > 0 ? properties : undefined;
}
function isDuplicateError(error: { message?: string; statusCode?: number | null }): boolean {
const errorMessage = error.message?.toLowerCase() || '';
function isDuplicateError(error: {
message?: string;
statusCode?: number | null;
}): boolean {
const errorMessage = error.message?.toLowerCase() || "";
return (
errorMessage.includes('already exists') ||
errorMessage.includes('duplicate') ||
errorMessage.includes("already exists") ||
errorMessage.includes("duplicate") ||
error.statusCode === 409
);
}
@ -38,7 +44,7 @@ function createContactPayload(
firstName: string,
lastName: string,
company?: string,
lookingFor?: string
lookingFor?: string,
) {
const properties = buildProperties(company, lookingFor);
return {
@ -53,50 +59,56 @@ function createContactPayload(
export async function POST(req: Request) {
try {
const body = await req.json();
const { firstName, lastName, email, company, lookingFor }: ContactPayload = body;
const { firstName, lastName, email, company, lookingFor }: ContactPayload =
body;
if (!email || !firstName || !lastName || !lookingFor) {
return NextResponse.json(
{ error: 'Missing required fields' },
{ status: 400 }
{ error: "Missing required fields" },
{ status: 400 },
);
}
const contactPayload = createContactPayload(email, firstName, lastName, company, lookingFor);
const contactPayload = createContactPayload(
email,
firstName,
lastName,
company,
lookingFor,
);
const resend = getResendClient();
const { data, error } = await resend.contacts.create(contactPayload);
if (error) {
if (isDuplicateError(error)) {
const { data: updateData, error: updateError } = await resend.contacts.update(
contactPayload
);
const { data: updateData, error: updateError } =
await resend.contacts.update(contactPayload);
if (updateError) {
console.error('Resend update error:', updateError);
console.error("Resend update error:", updateError);
return NextResponse.json(
{ error: updateError.message || 'Failed to update contact' },
{ status: 500 }
{ error: updateError.message || "Failed to update contact" },
{ status: 500 },
);
}
return NextResponse.json({ success: true, data: updateData });
}
console.error('Resend create error:', error);
console.error("Resend create error:", error);
return NextResponse.json(
{ error: error.message || 'Failed to create contact' },
{ status: error.statusCode || 500 }
{ error: error.message || "Failed to create contact" },
{ status: error.statusCode || 500 },
);
}
return NextResponse.json({ success: true, data });
} catch (error) {
console.error('Unexpected error:', error);
console.error("Unexpected error:", error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 500 },
);
}
}

View file

@ -1,9 +1,11 @@
import { client, urlFor } from "@/lib/sanity";
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { PortableText } from "@/components/PortableText";
import { notFound } from "next/navigation";
import { UnlockPotentialSection } from "@/components/UnlockPotentialSection";
import { createBlogPostMetadata } from "@/lib/metadata";
interface BlogPost {
_id: string;
@ -67,6 +69,30 @@ export async function generateStaticParams() {
return slugs.map((slug) => ({ slug }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getBlogPost(slug);
if (!post) {
return {
title: "Post Not Found | Plano Blog",
description: "The requested blog post could not be found.",
};
}
return createBlogPostMetadata({
title: post.title,
description: post.summary,
slug: post.slug.current,
publishedAt: post.publishedAt,
author: post.author?.name,
});
}
export default async function BlogPostPage({
params,
}: {

View file

@ -5,10 +5,9 @@ import { BlogHeader } from "@/components/BlogHeader";
import { FeaturedBlogCard } from "@/components/FeaturedBlogCard";
import { BlogCard } from "@/components/BlogCard";
import { BlogSectionHeader } from "@/components/BlogSectionHeader";
export const metadata: Metadata = {
title: "Blog - Plano",
description: "Latest insights, updates, and stories from Plano",
};
import { pageMetadata } from "@/lib/metadata";
export const metadata: Metadata = pageMetadata.blog;
interface BlogPost {
_id: string;

View file

@ -0,0 +1,299 @@
"use client";
import { useState } from "react";
import { Button } from "@katanemo/ui";
import { MessageSquare, Building2, MessagesSquare } from "lucide-react";
export default function ContactPageClient() {
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
email: "",
company: "",
lookingFor: "",
message: "",
});
const [status, setStatus] = useState<
"idle" | "submitting" | "success" | "error"
>("idle");
const [errorMessage, setErrorMessage] = useState("");
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus("submitting");
setErrorMessage("");
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Something went wrong");
}
setStatus("success");
setFormData({
firstName: "",
lastName: "",
email: "",
company: "",
lookingFor: "",
message: "",
});
} catch (error) {
setStatus("error");
setErrorMessage(
error instanceof Error ? error.message : "Failed to submit form",
);
}
};
return (
<div className="flex flex-col min-h-screen">
{/* Hero / Header Section */}
<section className="pt-20 pb-16 px-4 sm:px-6 lg:px-8">
<div className="max-w-324 mx-auto text-left">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-normal leading-tight tracking-tighter text-black mb-6 text-left">
<span className="font-sans">Let's start a </span>
<span className="font-sans font-medium text-secondary">
conversation
</span>
</h1>
<p className="text-lg sm:text-xl text-black/60 max-w-2xl text-left font-sans">
Whether you're an enterprise looking for a custom solution or a
developer building cool agents, we'd love to hear from you.
</p>
</div>
</section>
{/* Main Content - Split Layout */}
<section className="pb-24 px-4 sm:px-6 lg:px-8 grow">
<div className="max-w-324 mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8 items-stretch">
{/* Left Side: Community (Discord) */}
<div className="group relative bg-white rounded-2xl p-8 sm:p-10 flex flex-col justify-between h-full overflow-hidden">
{/* Background icon */}
<div className="absolute -top-4 -right-4 w-32 h-32 opacity-[0.03] group-hover:opacity-[0.06] transition-opacity duration-300">
<MessagesSquare size={128} className="text-blue-600" />
</div>
<div className="relative z-10">
<div className="relative z-10 mb-6">
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full bg-gray-100/80 backdrop-blur-sm text-gray-700 text-xs font-mono font-bold tracking-wider uppercase mb-6 w-fit border border-gray-200/50">
<MessageSquare size={12} className="text-gray-600" />
Community
</div>
<h2 className="text-3xl sm:text-4xl font-semibold tracking-tight text-gray-900">
Join Our Discord
</h2>
</div>
<p className="text-base sm:text-lg text-gray-600 mb-8 leading-relaxed max-w-md">
Connect with other developers, ask questions, share what
you're building, and stay updated on the latest features by
joining our Discord server.
</p>
</div>
<div className="relative z-10 mt-auto">
<Button asChild>
<a
href="https://discord.gg/pGZf2gcwEc"
target="_blank"
rel="noopener noreferrer"
>
<MessageSquare size={18} />
Join Discord Server
</a>
</Button>
</div>
</div>
{/* Right Side: Enterprise Contact */}
<div className="group relative bg-white rounded-2xl p-8 sm:p-10 h-full overflow-hidden">
{/* Subtle background pattern */}
<div className="absolute inset-0 bg-[linear-gradient(to_bottom_right,transparent_0%,rgba(0,0,0,0.01)_50%,transparent_100%)] opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{/* Background icon */}
<div className="absolute -top-4 -right-4 w-32 h-32 opacity-[0.08]">
<Building2 size={128} className="text-gray-400" />
</div>
<div className="relative z-10 mb-8">
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full bg-gray-100/80 backdrop-blur-sm text-gray-700 text-xs font-mono font-bold tracking-wider uppercase mb-6 w-fit border border-gray-200/50">
<Building2 size={12} className="text-gray-600" />
Enterprise
</div>
<h2 className="text-3xl sm:text-4xl font-semibold tracking-tight mb-4 text-gray-900">
Contact Us
</h2>
</div>
<div className="relative z-10">
{status === "success" ? (
<div className="bg-gradient-to-br from-green-50 to-emerald-50/50 rounded-xl p-8 text-center border border-green-200/50 shadow-sm">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4 mx-auto">
<svg
className="w-8 h-8 text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div className="text-green-700 text-xl font-semibold mb-2">
Message Sent!
</div>
<p className="text-gray-600 mb-6 text-sm">
Thank you for reaching out. We'll be in touch shortly.
</p>
<Button
variant="outline"
onClick={() => setStatus("idle")}
className="bg-white border-gray-200 hover:bg-gray-50"
>
Send another message
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<label
htmlFor="firstName"
className="text-sm font-medium text-gray-600"
>
First Name
</label>
<input
type="text"
id="firstName"
name="firstName"
required
value={formData.firstName}
onChange={handleChange}
className="flex h-11 w-full rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm ring-offset-background placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary/20 focus-visible:border-secondary disabled:cursor-not-allowed disabled:opacity-50 transition-all shadow-sm hover:border-gray-300"
placeholder="Steve"
/>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="lastName"
className="text-sm font-medium text-gray-600"
>
Last Name
</label>
<input
type="text"
id="lastName"
name="lastName"
required
value={formData.lastName}
onChange={handleChange}
className="flex h-11 w-full rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm ring-offset-background placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary/20 focus-visible:border-secondary disabled:cursor-not-allowed disabled:opacity-50 transition-all shadow-sm hover:border-gray-300"
placeholder="Wozniak"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="email"
className="text-sm font-medium text-gray-600"
>
Work Email
</label>
<input
type="email"
id="email"
name="email"
required
value={formData.email}
onChange={handleChange}
className="flex h-11 w-full rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm ring-offset-background placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary/20 focus-visible:border-secondary disabled:cursor-not-allowed disabled:opacity-50 transition-all shadow-sm hover:border-gray-300"
placeholder="steve@apple.com"
/>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="company"
className="text-sm font-medium text-gray-600"
>
Company Name
</label>
<input
type="text"
id="company"
name="company"
value={formData.company}
onChange={handleChange}
className="flex h-11 w-full rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm ring-offset-background placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary/20 focus-visible:border-secondary disabled:cursor-not-allowed disabled:opacity-50 transition-all shadow-sm hover:border-gray-300"
placeholder="Apple Inc."
/>
</div>
<div className="flex flex-col gap-2">
<label
htmlFor="lookingFor"
className="text-sm font-medium text-gray-600"
>
How can we help?
</label>
<textarea
id="lookingFor"
name="lookingFor"
required
value={formData.lookingFor}
onChange={handleChange}
className="flex min-h-[120px] w-full rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm ring-offset-background placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary/20 focus-visible:border-secondary disabled:cursor-not-allowed disabled:opacity-50 transition-all shadow-sm hover:border-gray-300 resize-none"
placeholder="Tell us about your use case, requirements, or questions..."
/>
</div>
{errorMessage && (
<div className="text-red-700 text-sm bg-red-50 p-4 rounded-lg border border-red-200/50 shadow-sm">
<div className="font-medium mb-1">Error</div>
<div className="text-red-600">{errorMessage}</div>
</div>
)}
<div className="mt-1">
<Button
type="submit"
className="w-full"
size="lg"
disabled={status === "submitting"}
>
{status === "submitting"
? "Sending..."
: "Send Message"}
</Button>
</div>
</form>
)}
</div>
</div>
</div>
</div>
</section>
</div>
);
}

View file

@ -1,234 +1,9 @@
"use client";
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/metadata";
import ContactPageClient from "./ContactPageClient";
import { useState } from "react";
import { Button } from "@katanemo/ui";
import Link from "next/link";
import { ArrowRight, MessageSquare, Building2, MessagesSquare } from "lucide-react";
export const metadata: Metadata = pageMetadata.contact;
export default function ContactPage() {
const [formData, setFormData] = useState({
firstName: "",
lastName: "",
email: "",
company: "",
lookingFor: "",
message: "",
});
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
const [errorMessage, setErrorMessage] = useState("");
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus("submitting");
setErrorMessage("");
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Something went wrong");
}
setStatus("success");
setFormData({
firstName: "",
lastName: "",
email: "",
company: "",
lookingFor: "",
message: "",
});
} catch (error) {
setStatus("error");
setErrorMessage(error instanceof Error ? error.message : "Failed to submit form");
}
};
return (
<div className="flex flex-col min-h-screen">
{/* Hero / Header Section */}
<section className="pt-20 pb-16 px-4 sm:px-6 lg:px-8">
<div className="max-w-324 mx-auto text-left">
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-normal leading-tight tracking-tighter text-black mb-6 text-left">
<span className="font-sans">Let's start a </span>
<span className="font-sans font-medium text-secondary">
conversation
</span>
</h1>
<p className="text-lg sm:text-xl text-black/60 max-w-2xl text-left font-sans">
Whether you're an enterprise looking for a custom solution or a developer building cool agents, we'd love to hear from you.
</p>
</div>
</section>
{/* Main Content - Split Layout */}
<section className="pb-24 px-4 sm:px-6 lg:px-8 grow">
<div className="max-w-324 mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:gap-8 items-stretch">
{/* Left Side: Community (Discord) */}
<div className="group relative bg-white rounded-2xl p-8 sm:p-10 flex flex-col justify-between h-full overflow-hidden">
{/* Background icon */}
<div className="absolute -top-4 -right-4 w-32 h-32 opacity-[0.03] group-hover:opacity-[0.06] transition-opacity duration-300">
<MessagesSquare size={128} className="text-blue-600" />
</div>
<div className="relative z-10">
<div className="relative z-10 mb-6">
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full bg-gray-100/80 backdrop-blur-sm text-gray-700 text-xs font-mono font-bold tracking-wider uppercase mb-6 w-fit border border-gray-200/50">
<MessageSquare size={12} className="text-gray-600" />
Community
</div>
<h2 className="text-3xl sm:text-4xl font-semibold tracking-tight text-gray-900">Join Our Discord</h2>
</div>
<p className="text-base sm:text-lg text-gray-600 mb-8 leading-relaxed max-w-md">
Connect with other developers, ask questions, share what you're building, and stay updated on the latest features by joining our Discord server.
</p>
</div>
<div className="relative z-10 mt-auto">
<Button asChild>
<a href="https://discord.gg/pGZf2gcwEc" target="_blank" rel="noopener noreferrer">
<MessageSquare size={18} />
Join Discord Server
</a>
</Button>
</div>
</div>
{/* Right Side: Enterprise Contact */}
<div className="group relative bg-white rounded-2xl p-8 sm:p-10 h-full overflow-hidden">
{/* Subtle background pattern */}
<div className="absolute inset-0 bg-[linear-gradient(to_bottom_right,transparent_0%,rgba(0,0,0,0.01)_50%,transparent_100%)] opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{/* Background icon */}
<div className="absolute -top-4 -right-4 w-32 h-32 opacity-[0.08]">
<Building2 size={128} className="text-gray-400" />
</div>
<div className="relative z-10 mb-8">
<div className="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full bg-gray-100/80 backdrop-blur-sm text-gray-700 text-xs font-mono font-bold tracking-wider uppercase mb-6 w-fit border border-gray-200/50">
<Building2 size={12} className="text-gray-600" />
Enterprise
</div>
<h2 className="text-3xl sm:text-4xl font-semibold tracking-tight mb-4 text-gray-900">Contact Us</h2>
</div>
<div className="relative z-10">
{status === "success" ? (
<div className="bg-gradient-to-br from-green-50 to-emerald-50/50 rounded-xl p-8 text-center border border-green-200/50 shadow-sm">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-4 mx-auto">
<svg className="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="text-green-700 text-xl font-semibold mb-2">Message Sent!</div>
<p className="text-gray-600 mb-6 text-sm">Thank you for reaching out. We'll be in touch shortly.</p>
<Button variant="outline" onClick={() => setStatus("idle")} className="bg-white border-gray-200 hover:bg-gray-50">
Send another message
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<label htmlFor="firstName" className="text-sm font-medium text-gray-600">First Name</label>
<input
type="text"
id="firstName"
name="firstName"
required
value={formData.firstName}
onChange={handleChange}
className="flex h-11 w-full rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm ring-offset-background placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary/20 focus-visible:border-secondary disabled:cursor-not-allowed disabled:opacity-50 transition-all shadow-sm hover:border-gray-300"
placeholder="Steve"
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="lastName" className="text-sm font-medium text-gray-600">Last Name</label>
<input
type="text"
id="lastName"
name="lastName"
required
value={formData.lastName}
onChange={handleChange}
className="flex h-11 w-full rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm ring-offset-background placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary/20 focus-visible:border-secondary disabled:cursor-not-allowed disabled:opacity-50 transition-all shadow-sm hover:border-gray-300"
placeholder="Wozniak"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="email" className="text-sm font-medium text-gray-600">Work Email</label>
<input
type="email"
id="email"
name="email"
required
value={formData.email}
onChange={handleChange}
className="flex h-11 w-full rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm ring-offset-background placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary/20 focus-visible:border-secondary disabled:cursor-not-allowed disabled:opacity-50 transition-all shadow-sm hover:border-gray-300"
placeholder="steve@apple.com"
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="company" className="text-sm font-medium text-gray-600">Company Name</label>
<input
type="text"
id="company"
name="company"
value={formData.company}
onChange={handleChange}
className="flex h-11 w-full rounded-lg border border-gray-200 bg-white px-4 py-2.5 text-sm ring-offset-background placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary/20 focus-visible:border-secondary disabled:cursor-not-allowed disabled:opacity-50 transition-all shadow-sm hover:border-gray-300"
placeholder="Apple Inc."
/>
</div>
<div className="flex flex-col gap-2">
<label htmlFor="lookingFor" className="text-sm font-medium text-gray-600">How can we help?</label>
<textarea
id="lookingFor"
name="lookingFor"
required
value={formData.lookingFor}
onChange={handleChange}
className="flex min-h-[120px] w-full rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm ring-offset-background placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-secondary/20 focus-visible:border-secondary disabled:cursor-not-allowed disabled:opacity-50 transition-all shadow-sm hover:border-gray-300 resize-none"
placeholder="Tell us about your use case, requirements, or questions..."
/>
</div>
{errorMessage && (
<div className="text-red-700 text-sm bg-red-50 p-4 rounded-lg border border-red-200/50 shadow-sm">
<div className="font-medium mb-1">Error</div>
<div className="text-red-600">{errorMessage}</div>
</div>
)}
<div className="mt-1">
<Button type="submit" className="w-full" size="lg" disabled={status === "submitting"}>
{status === "submitting" ? "Sending..." : "Send Message"}
</Button>
</div>
</form>
)}
</div>
</div>
</div>
</div>
</section>
</div>
);
return <ContactPageClient />;
}

View file

@ -1,14 +0,0 @@
export default function DocsPage() {
return (
<section className="px-4 sm:px-6 lg:px-8 py-12 sm:py-16 lg:py-24">
<div className="max-w-[81rem] mx-auto">
<h1 className="text-4xl sm:text-5xl lg:text-7xl font-normal leading-tight tracking-tighter text-black mb-6">
<span className="font-sans">Documentation</span>
</h1>
<p className="text-lg sm:text-xl lg:text-2xl font-sans font-[400] tracking-[-1.2px] text-black/70">
Coming soon...
</p>
</div>
</section>
);
}

View file

@ -3,11 +3,15 @@ import Script from "next/script";
import "@katanemo/shared-styles/globals.css";
import { Analytics } from "@vercel/analytics/next";
import { ConditionalLayout } from "@/components/ConditionalLayout";
import { defaultMetadata } from "@/lib/metadata";
export const metadata: Metadata = {
title: "Plano - Delivery Infrastructure for Agentic Apps",
description:
"Build agents faster, and deliver them reliably to production - by offloading the critical plumbing work to Plano!",
...defaultMetadata,
manifest: "/manifest.json",
icons: {
icon: "/PlanoIcon.svg",
apple: "/Logomark.png",
},
};
export default function RootLayout({
@ -23,7 +27,7 @@ export default function RootLayout({
src="https://www.googletagmanager.com/gtag/js?id=G-ML7B1X9HY2"
strategy="afterInteractive"
/>
<Script id="google-analytics" strategy="afterInteractive">
<Script strategy="afterInteractive">
{`
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}

View file

@ -0,0 +1,26 @@
"use client";
import {
ResearchHero,
ResearchGrid,
ResearchTimeline,
ResearchCTA,
ResearchCapabilities,
ResearchBenchmarks,
} from "@/components/research";
import { UnlockPotentialSection } from "@/components/UnlockPotentialSection";
export default function ResearchPageClient() {
return (
<>
<ResearchHero />
<ResearchGrid />
<ResearchTimeline />
<ResearchCTA />
<ResearchCapabilities />
<ResearchBenchmarks />
{/* <ResearchFamily /> */}
<UnlockPotentialSection variant="transparent" />
</>
);
}

View file

@ -1,27 +1,9 @@
"use client";
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/metadata";
import ResearchPageClient from "./ResearchPageClient";
import {
ResearchHero,
ResearchGrid,
ResearchTimeline,
ResearchCTA,
ResearchCapabilities,
ResearchBenchmarks,
ResearchFamily,
} from "@/components/research";
import { UnlockPotentialSection } from "@/components/UnlockPotentialSection";
export const metadata: Metadata = pageMetadata.research;
export default function ResearchPage() {
return (
<>
<ResearchHero />
<ResearchGrid />
<ResearchTimeline />
<ResearchCTA />
<ResearchCapabilities />
<ResearchBenchmarks />
{/* <ResearchFamily /> */}
<UnlockPotentialSection variant="transparent" />
</>
);
return <ResearchPageClient />;
}

View file

@ -0,0 +1,32 @@
import { MetadataRoute } from "next";
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://planoai.dev";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: [
"/studio/", // Sanity Studio admin
"/api/", // API routes
"/_next/", // Next.js internal routes
],
},
{
// Specific rules for common crawlers
userAgent: "Googlebot",
allow: "/",
disallow: ["/studio/", "/api/"],
},
{
userAgent: "Bingbot",
allow: "/",
disallow: ["/studio/", "/api/"],
},
],
sitemap: `${BASE_URL}/sitemap.xml`,
host: BASE_URL,
};
}

View file

@ -0,0 +1,77 @@
import { MetadataRoute } from "next";
import { client } from "@/lib/sanity";
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://planoai.dev";
interface BlogPost {
slug: { current: string };
publishedAt?: string;
_updatedAt?: string;
}
async function getBlogPosts(): Promise<BlogPost[]> {
const query = `*[_type == "blog" && published == true] | order(publishedAt desc) {
slug,
publishedAt,
_updatedAt
}`;
try {
return await client.fetch(query);
} catch (error) {
console.error("Error fetching blog posts for sitemap:", error);
return [];
}
}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
// Static pages with their priorities and change frequencies
const staticPages: MetadataRoute.Sitemap = [
{
url: BASE_URL,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1.0,
},
{
url: `${BASE_URL}/research`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.9,
},
{
url: `${BASE_URL}/blog`,
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.8,
},
{
url: `${BASE_URL}/contact`,
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.6,
},
{
url: `${BASE_URL}/docs`,
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.7,
},
];
// Fetch dynamic blog posts
const blogPosts = await getBlogPosts();
const blogPages: MetadataRoute.Sitemap = blogPosts.map((post) => ({
url: `${BASE_URL}/blog/${post.slug.current}`,
lastModified: post._updatedAt
? new Date(post._updatedAt)
: post.publishedAt
? new Date(post.publishedAt)
: new Date(),
changeFrequency: "monthly" as const,
priority: 0.7,
}));
return [...staticPages, ...blogPages];
}

View file

@ -24,16 +24,18 @@ export function Hero() {
>
<div className="inline-flex flex-wrap items-center gap-1.5 sm:gap-2 px-3 sm:px-4 py-1 rounded-full bg-[rgba(185,191,255,0.4)] border border-[var(--secondary)] shadow backdrop-blur hover:bg-[rgba(185,191,255,0.6)] transition-colors cursor-pointer">
<span className="text-xs sm:text-sm font-medium text-black/65">
v0.4.3
v0.4.4
</span>
<span className="text-xs sm:text-sm font-medium text-black ">
</span>
<span className="text-xs sm:text-sm font-[600] tracking-[-0.6px]! text-black leading-tight">
<span className="hidden sm:inline">
Signals: Trace Sampling & Preference Data for Continuous Improvement
Signals: Trace Sampling for Fast Error Analysis
</span>
<span className="sm:hidden">
Signals: Trace Sampling for Fast Error Analysis
</span>
<span className="sm:hidden">Signals: Trace Sampling & Preference Data for Continuous Improvement</span>
</span>
</div>
</Link>
@ -58,12 +60,20 @@ export function Hero() {
{/* CTA Buttons */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-start gap-3 sm:gap-4">
<Button asChild className="w-full sm:w-auto">
<Link href="https://docs.planoai.dev/get_started/quickstart" target="_blank" rel="noopener noreferrer">
<Link
href="https://docs.planoai.dev/get_started/quickstart"
target="_blank"
rel="noopener noreferrer"
>
Get started
</Link>
</Button>
<Button variant="secondary" asChild className="w-full sm:w-auto">
<Link href="https://docs.planoai.dev" target="_blank" rel="noopener noreferrer">
<Link
href="https://docs.planoai.dev"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</Link>
</Button>

View file

@ -1,14 +1,273 @@
"use client";
import { PortableText as SanityPortableText } from "@portabletext/react";
import Image from "next/image";
import { urlFor } from "@/lib/sanity";
import type { PortableTextBlock } from "@portabletext/types";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism";
interface PortableTextProps {
content: PortableTextBlock[];
}
const codeTheme: any = oneLight;
function CodeBlock({
code,
language,
filename,
highlightedLines,
}: {
code: string;
language?: string;
filename?: string;
highlightedLines: Set<number>;
}) {
const [copied, setCopied] = useState(false);
const displayLanguage = language || "text";
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
} catch {
setCopied(false);
}
};
return (
<div className="my-6 lg:my-8 not-prose">
<div className="rounded-xl border border-black/10 bg-white overflow-hidden shadow-sm">
{(filename || language) && (
<div className="flex items-center justify-between gap-4 px-4 py-2 text-xs font-semibold text-black/70 bg-black/4 border-b border-black/10">
<span className="truncate text-black/80">{filename || "Code"}</span>
<div className="flex items-center gap-2">
<span className="uppercase tracking-wide text-black/60 font-mono">
{displayLanguage}
</span>
<button
type="button"
onClick={handleCopy}
className="inline-flex items-center justify-center h-6 w-6 rounded border border-black/10 text-black/60 hover:text-black hover:border-black/20 hover:bg-black/5 transition-colors"
aria-label="Copy code"
title={copied ? "Copied" : "Copy"}
>
{copied ? (
<svg
viewBox="0 0 20 20"
fill="currentColor"
className="h-3.5 w-3.5"
>
<title>Copied</title>
<path d="M16.704 5.296a1 1 0 010 1.414l-7.25 7.25a1 1 0 01-1.414 0l-3.25-3.25a1 1 0 011.414-1.414L8.25 11.343l6.543-6.547a1 1 0 011.411 0z" />
</svg>
) : (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="h-4 w-4"
>
<title>Copy</title>
<rect x="9" y="9" width="10" height="10" rx="2" />
<rect x="5" y="5" width="10" height="10" rx="2" />
</svg>
)}
</button>
</div>
</div>
)}
<SyntaxHighlighter
language={displayLanguage}
style={codeTheme}
customStyle={{
margin: 0,
background: "transparent",
padding: "1rem",
fontSize: "0.875rem",
}}
lineProps={(lineNumber: number) =>
highlightedLines.has(lineNumber)
? {
style: {
backgroundColor: "rgba(251, 191, 36, 0.2)",
},
}
: { style: {} }
}
wrapLines
codeTagProps={{ style: { fontFamily: "inherit" } }}
>
{code}
</SyntaxHighlighter>
</div>
</div>
);
}
const markdownComponents: Components = {
h1: (props) => (
<h1 className="text-2xl font-semibold text-black mb-3" {...props} />
),
h2: (props) => (
<h2 className="text-xl font-semibold text-black mt-10 mb-5" {...props} />
),
h3: (props) => (
<h3 className="text-lg font-semibold text-black mt-10 mb-5" {...props} />
),
p: (props) => (
<p className="text-base text-black/80 mb-3 leading-relaxed" {...props} />
),
ul: (props) => (
<ul className="list-disc list-inside mb-3 text-black/80" {...props} />
),
ol: (props) => (
<ol className="list-decimal list-inside mb-3 text-black/80" {...props} />
),
a: (props) => (
<a className="text-secondary hover:underline font-medium" {...props} />
),
blockquote: (props) => (
<blockquote
className="border-l-4 border-secondary pl-4 italic text-black/70"
{...props}
/>
),
table: (props) => (
<div className="my-4 overflow-x-auto">
<table
className="w-full border-collapse text-sm text-left border border-black/20"
{...props}
/>
</div>
),
thead: (props) => <thead className="bg-black/4" {...props} />,
tbody: (props) => <tbody className="divide-y divide-black/10" {...props} />,
tr: (props) => <tr className="even:bg-black/2" {...props} />,
th: (props) => (
<th className="border border-black/20 px-3 py-2 text-left font-semibold text-black" {...props} />
),
td: (props) => (
<td className="border border-black/20 px-3 py-2 text-left text-black/80" {...props} />
),
pre: (props) => (
<pre className="rounded bg-black/10 p-3 text-sm overflow-x-auto" {...props} />
),
code: ({ className, children }) => {
const match = /language-(\w+)/.exec(className || "");
if (match) {
return (
<SyntaxHighlighter
style={codeTheme}
language={match[1]}
PreTag="div"
customStyle={{
margin: 0,
background: "transparent",
fontSize: "0.875rem",
}}
codeTagProps={{ style: { fontFamily: "inherit" } }}
>
{String(children).replace(/\n$/, "")}
</SyntaxHighlighter>
);
}
return (
<code
className="rounded bg-black/10 px-1.5 py-0.5 text-[0.95em] font-mono"
>
{children}
</code>
);
},
};
const components = {
types: {
code: ({ value }: any) => {
if (!value?.code) return null;
const highlightedLines = new Set<number>(
Array.isArray(value.highlightedLines) ? value.highlightedLines : [],
);
return (
<CodeBlock
code={String(value.code)}
language={value.language}
filename={value.filename}
highlightedLines={highlightedLines}
/>
);
},
table: ({ value }: any) => {
const rows = Array.isArray(value?.rows) ? value.rows : [];
if (rows.length === 0) return null;
const headerRowIndex = rows.findIndex((row: any) => row?.isHeader);
const headerRow =
headerRowIndex >= 0 ? rows[headerRowIndex] : undefined;
const bodyRows =
headerRowIndex >= 0
? rows.filter((_: any, index: number) => index !== headerRowIndex)
: rows;
const renderCells = (cells: any[], isHeader: boolean) =>
(cells || []).map((cell: any, index: number) => {
const Tag = isHeader ? "th" : "td";
return (
<Tag
key={cell?._key || index}
className={`border border-black/20 px-3 py-2 text-left align-top ${
isHeader ? "font-semibold text-black" : "text-black/80"
}`}
>
{cell?.value || ""}
</Tag>
);
});
return (
<div className="my-6 lg:my-8 overflow-x-auto not-prose">
<div className="rounded-xl border border-black/20 overflow-hidden bg-white">
<table className="w-full border-collapse text-sm text-left">
{headerRow?.cells?.length ? (
<thead className="bg-black/4 text-black/80">
<tr>{renderCells(headerRow.cells, true)}</tr>
</thead>
) : null}
<tbody className="divide-y divide-black/10">
{bodyRows.map((row: any, rowIndex: number) => (
<tr key={row?._key || rowIndex} className="even:bg-black/2">
{renderCells(row?.cells || [], false)}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
},
markdownBlock: ({ value }: any) => {
const markdown = value?.markdown;
if (!markdown) return null;
return (
<div className="my-6 lg:my-8">
<div className="rounded-xl border border-black/10 bg-black/2 px-5 py-4">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{markdown}
</ReactMarkdown>
</div>
</div>
);
},
image: ({ value }: any) => {
if (!value?.asset) return null;
@ -55,12 +314,12 @@ const components = {
</h1>
),
h2: (props: any) => (
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-normal leading-tight tracking-tighter text-black mt-8 mb-4 first:mt-0">
<h2 className="text-3xl sm:text-4xl lg:text-5xl font-normal leading-tight tracking-tighter text-black mt-10 mb-5 first:mt-0">
<span className="font-sans">{props.children}</span>
</h2>
),
h3: (props: any) => (
<h3 className="text-2xl sm:text-3xl lg:text-4xl font-normal leading-tight tracking-tighter text-black mt-6 mb-3 first:mt-0">
<h3 className="text-2xl sm:text-3xl lg:text-4xl font-normal leading-tight tracking-tighter text-black mt-8 mb-4 first:mt-0">
<span className="font-sans">{props.children}</span>
</h3>
),

View file

@ -33,10 +33,14 @@ export function UnlockPotentialSection({
<div className="flex flex-col sm:flex-row gap-5">
<Button asChild>
<Link href="https://docs.planoai.dev/get_started/quickstart">Deploy today</Link>
<Link href="https://docs.planoai.dev/get_started/quickstart">
Deploy today
</Link>
</Button>
<Button variant="secondaryDark" asChild>
<Link href="https://docs.planoai.dev/get_started/quickstart">Documentation</Link>
<Link href="https://docs.planoai.dev/get_started/quickstart">
Documentation
</Link>
</Button>
</div>
</div>

View file

@ -0,0 +1,338 @@
import type { Metadata } from "next";
const BASE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://planoai.dev";
/**
* Site-wide metadata configuration
* Centralized SEO settings for consistent branding and search optimization
*/
export const siteConfig = {
name: "Plano",
tagline: "Delivery Infrastructure for Agentic Apps",
description:
"Build agents faster and deliver them reliably to production. Plano is an AI-native proxy and data plane for agent orchestration, LLM routing, guardrails, and observability.",
url: BASE_URL,
ogImage: `${BASE_URL}/Logomark.png`,
links: {
docs: "https://docs.planoai.dev",
github: "https://github.com/katanemo/plano",
discord: "https://discord.gg/pGZf2gcwEc",
huggingface: "https://huggingface.co/katanemo",
},
keywords: [
// High-intent comparison/alternative searches (proven search volume)
"LiteLLM alternative",
"Portkey alternative",
"Helicone alternative",
"OpenRouter alternative",
"Kong AI Gateway alternative",
// Primary keywords (high volume, validated by industry reports)
"AI gateway",
"LLM gateway",
"agentic AI",
"AI agents",
"agent orchestration",
"LLM routing",
// MCP - massive 2025 trend (97M+ SDK downloads, industry standard)
"MCP server",
"Model Context Protocol",
"MCP gateway",
"MCP observability",
"MCP security",
// Problem-aware searches (how developers search)
"LLM rate limiting",
"LLM load balancing",
"LLM failover",
"provider fallback",
"multi-provider LLM",
"LLM cost optimization",
"token usage tracking",
// Agent framework integration (trending frameworks)
"LangGraph gateway",
"LangChain infrastructure",
"CrewAI deployment",
"AutoGen orchestration",
"multi-agent orchestration",
// Production & reliability (enterprise focus)
"AI agents in production",
"production AI infrastructure",
"agent reliability",
"deploy AI agents",
"scaling AI agents",
"LLM traffic management",
// Observability & LLMOps (growing category)
"LLM observability",
"AI observability",
"agent tracing",
"LLMOps",
"AI telemetry",
"prompt versioning",
// Guardrails & safety (enterprise requirement)
"AI guardrails",
"LLM content filtering",
"prompt injection protection",
"AI safety middleware",
// Routing & optimization
"model routing",
"inference routing",
"latency based routing",
"intelligent model selection",
"semantic caching LLM",
// Emerging trends (A2A, agentic RAG)
"A2A protocol",
"agent to agent communication",
"agentic RAG",
"tool calling orchestration",
"function calling routing",
// Use cases (specific applications)
"RAG infrastructure",
"chatbot backend",
"AI customer service infrastructure",
"coding agent infrastructure",
// Infrastructure architecture
"AI data plane",
"AI control plane",
"AI proxy",
"unified LLM API",
// Open source & self-hosted (strong developer interest)
"open source AI gateway",
"open source LLM gateway",
"self hosted AI gateway",
"on premise LLM routing",
// Brand (minimal, necessary)
"Plano AI",
"Plano gateway",
"Arch gateway",
],
authors: [{ name: "Katanemo", url: "https://github.com/katanemo/plano" }],
creator: "Katanemo",
};
/**
* Generate page-specific metadata with consistent defaults
*/
export function createMetadata({
title,
description,
keywords = [],
image,
noIndex = false,
pathname = "",
}: {
title?: string;
description?: string;
keywords?: string[];
image?: string;
noIndex?: boolean;
pathname?: string;
}): Metadata {
const pageTitle = title
? `${title} | ${siteConfig.name}`
: `${siteConfig.name} - ${siteConfig.tagline}`;
const pageDescription = description || siteConfig.description;
const pageImage = image || siteConfig.ogImage;
const pageUrl = pathname ? `${BASE_URL}${pathname}` : BASE_URL;
return {
title: pageTitle,
description: pageDescription,
keywords: [...siteConfig.keywords, ...keywords],
authors: siteConfig.authors,
creator: siteConfig.creator,
metadataBase: new URL(BASE_URL),
alternates: {
canonical: pageUrl,
},
openGraph: {
type: "website",
locale: "en_US",
url: pageUrl,
title: pageTitle,
description: pageDescription,
siteName: siteConfig.name,
images: [
{
url: pageImage,
width: 1200,
height: 630,
alt: `${siteConfig.name} - ${siteConfig.tagline}`,
},
],
},
twitter: {
card: "summary_large_image",
title: pageTitle,
description: pageDescription,
images: [pageImage],
creator: "@katanemo",
},
robots: noIndex
? {
index: false,
follow: false,
}
: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
};
}
/**
* Default metadata for the root layout
*/
export const defaultMetadata: Metadata = createMetadata({});
/**
* Page-specific metadata configurations
*/
export const pageMetadata = {
home: createMetadata({
pathname: "/",
keywords: ["AI gateway", "agent orchestration", "LLM routing"],
}),
research: createMetadata({
title: "Research",
description:
"Explore Plano's applied AI research focusing on safe and efficient agent delivery. Discover our orchestrator models, benchmarks, and open-source LLMs on Hugging Face.",
pathname: "/research",
keywords: [
"AI research",
"orchestrator models",
"Plano orchestrator",
"AI benchmarks",
"open source LLM",
],
}),
blog: createMetadata({
title: "Blog",
description:
"Latest insights, tutorials, and updates from Plano. Learn about AI agents, agent orchestration, LLM routing, and building production-ready agentic applications.",
pathname: "/blog",
keywords: [
"AI blog",
"agent tutorials",
"LLM guides",
"AI engineering",
"agentic AI",
"Plano blog",
"Plano blog posts",
"Arch gateway blog",
],
}),
contact: createMetadata({
title: "Contact",
description:
"Get in touch with the Plano team. Join our Discord community or contact us for enterprise solutions for your AI agent infrastructure needs.",
pathname: "/contact",
keywords: ["contact Plano", "AI support", "enterprise AI", "AI consulting"],
}),
docs: createMetadata({
title: "Documentation",
description:
"Comprehensive documentation for Plano. Learn how to set up agent orchestration, LLM routing, guardrails, and observability for your AI applications.",
pathname: "/docs",
keywords: [
"Plano docs",
"AI gateway documentation",
"agent setup guide",
"LLM configuration",
],
}),
};
/**
* Generate metadata for blog posts
*/
export function createBlogPostMetadata({
title,
description,
slug,
publishedAt,
author,
image,
}: {
title: string;
description?: string;
slug: string;
publishedAt?: string;
author?: string;
image?: string;
}): Metadata {
const pageUrl = `${BASE_URL}/blog/${slug}`;
// Use the dynamic OG image endpoint for blog posts
const ogImage = `${BASE_URL}/api/og/${slug}`;
return {
title: `${title} | ${siteConfig.name} Blog`,
description:
description ||
`Read "${title}" on the Plano blog. Insights about AI agents, orchestration, and building production-ready agentic applications.`,
authors: author ? [{ name: author }] : siteConfig.authors,
metadataBase: new URL(BASE_URL),
alternates: {
canonical: pageUrl,
},
openGraph: {
type: "article",
locale: "en_US",
url: pageUrl,
title: title,
description: description || `Read "${title}" on the Plano blog.`,
siteName: siteConfig.name,
publishedTime: publishedAt,
authors: author ? [author] : undefined,
images: [
{
url: ogImage,
width: 1200,
height: 630,
alt: title,
},
],
},
twitter: {
card: "summary_large_image",
title: title,
description: description || `Read "${title}" on the Plano blog.`,
images: [ogImage],
creator: "@katanemo",
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
};
}

View file

@ -1 +1 @@
docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.3
docker build -f Dockerfile . -t katanemo/plano -t katanemo/plano:0.4.4

View file

@ -1,3 +1,3 @@
"""Plano CLI - Intelligent Prompt Gateway."""
__version__ = "0.4.3"
__version__ = "0.4.4"

View file

@ -187,11 +187,21 @@ def validate_and_render_schema():
model_name = model_provider.get("model")
print("Processing model_provider: ", model_provider)
if model_name in model_name_keys:
# Check if this is a wildcard model (provider/*)
is_wildcard = False
if "/" in model_name:
model_name_tokens = model_name.split("/")
if len(model_name_tokens) >= 2 and model_name_tokens[-1] == "*":
is_wildcard = True
if model_name in model_name_keys and not is_wildcard:
raise Exception(
f"Duplicate model name {model_name}, please provide unique model name for each model_provider"
)
model_name_keys.add(model_name)
if not is_wildcard:
model_name_keys.add(model_name)
if model_provider.get("name") is None:
model_provider["name"] = model_name
@ -200,9 +210,23 @@ def validate_and_render_schema():
model_name_tokens = model_name.split("/")
if len(model_name_tokens) < 2:
raise Exception(
f"Invalid model name {model_name}. Please provide model name in the format <provider>/<model_id>."
f"Invalid model name {model_name}. Please provide model name in the format <provider>/<model_id> or <provider>/* for wildcards."
)
provider = model_name_tokens[0]
provider = model_name_tokens[0].strip()
# Check if this is a wildcard (provider/*)
is_wildcard = model_name_tokens[-1].strip() == "*"
# Validate wildcard constraints
if is_wildcard:
if model_provider.get("default", False):
raise Exception(
f"Model {model_name} is configured as default but uses wildcard (*). Default models cannot be wildcards."
)
if model_provider.get("routing_preferences"):
raise Exception(
f"Model {model_name} has routing_preferences but uses wildcard (*). Models with routing preferences cannot be wildcards."
)
# Validate azure_openai and ollama provider requires base_url
if (provider in SUPPORTED_PROVIDERS_WITH_BASE_URL) and model_provider.get(
@ -213,7 +237,9 @@ def validate_and_render_schema():
)
model_id = "/".join(model_name_tokens[1:])
if provider not in SUPPORTED_PROVIDERS:
# For wildcard providers, allow any provider name
if not is_wildcard and provider not in SUPPORTED_PROVIDERS:
if (
model_provider.get("base_url", None) is None
or model_provider.get("provider_interface", None) is None
@ -222,16 +248,32 @@ def validate_and_render_schema():
f"Must provide base_url and provider_interface for unsupported provider {provider} for model {model_name}. Supported providers are: {', '.join(SUPPORTED_PROVIDERS)}"
)
provider = model_provider.get("provider_interface", None)
elif model_provider.get("provider_interface", None) is not None:
elif is_wildcard and provider not in SUPPORTED_PROVIDERS:
# Wildcard models with unsupported providers require base_url and provider_interface
if (
model_provider.get("base_url", None) is None
or model_provider.get("provider_interface", None) is None
):
raise Exception(
f"Must provide base_url and provider_interface for unsupported provider {provider} for wildcard model {model_name}. Supported providers are: {', '.join(SUPPORTED_PROVIDERS)}"
)
provider = model_provider.get("provider_interface", None)
elif (
provider in SUPPORTED_PROVIDERS
and model_provider.get("provider_interface", None) is not None
):
# For supported providers, provider_interface should not be manually set
raise Exception(
f"Please provide provider interface as part of model name {model_name} using the format <provider>/<model_id>. For example, use 'openai/gpt-3.5-turbo' instead of 'gpt-3.5-turbo' "
)
if model_id in model_name_keys:
raise Exception(
f"Duplicate model_id {model_id}, please provide unique model_id for each model_provider"
)
model_name_keys.add(model_id)
# For wildcard models, don't add model_id to the keys since it's "*"
if not is_wildcard:
if model_id in model_name_keys:
raise Exception(
f"Duplicate model_id {model_id}, please provide unique model_id for each model_provider"
)
model_name_keys.add(model_id)
for routing_preference in model_provider.get("routing_preferences", []):
if routing_preference.get("name") in model_usage_name_keys:

View file

@ -2,4 +2,4 @@ import os
SERVICE_NAME_ARCHGW = "plano"
PLANO_DOCKER_NAME = "plano"
PLANO_DOCKER_IMAGE = os.getenv("PLANO_DOCKER_IMAGE", "katanemo/plano:0.4.3")
PLANO_DOCKER_IMAGE = os.getenv("PLANO_DOCKER_IMAGE", "katanemo/plano:0.4.4")

View file

@ -145,7 +145,7 @@ def stop_docker_container(service=PLANO_DOCKER_NAME):
def start_cli_agent(arch_config_file=None, settings_json="{}"):
"""Start a CLI client connected to Arch."""
"""Start a CLI client connected to Plano."""
with open(arch_config_file, "r") as file:
arch_config = file.read()

View file

@ -74,7 +74,7 @@ def main(ctx, version):
log.info(f"Starting plano cli version: {get_version()}")
if ctx.invoked_subcommand is None:
click.echo("""Arch (The Intelligent Prompt Gateway) CLI""")
click.echo("""Plano (AI-native proxy and dataplane for agentic apps) CLI""")
click.echo(logo)
click.echo(ctx.get_help())
@ -121,16 +121,16 @@ def build():
@click.command()
@click.argument("file", required=False) # Optional file argument
@click.option(
"--path", default=".", help="Path to the directory containing arch_config.yaml"
"--path", default=".", help="Path to the directory containing config.yaml"
)
@click.option(
"--foreground",
default=False,
help="Run Arch in the foreground. Default is False",
help="Run Plano in the foreground. Default is False",
is_flag=True,
)
def up(file, path, foreground):
"""Starts Arch."""
"""Starts Plano."""
# Use the utility function to find config file
arch_config_file = find_config_file(path, file)
@ -270,7 +270,7 @@ def logs(debug, follow):
help="Additional settings as JSON string for the CLI agent.",
)
def cli_agent(type, file, path, settings):
"""Start a CLI agent connected to Arch.
"""Start a CLI agent connected to Plano.
CLI_AGENT: The type of CLI agent to start (currently only 'claude' is supported)
"""
@ -278,7 +278,7 @@ def cli_agent(type, file, path, settings):
# Check if plano docker container is running
archgw_status = docker_container_status(PLANO_DOCKER_NAME)
if archgw_status != "running":
log.error(f"archgw docker container is not running (status: {archgw_status})")
log.error(f"plano docker container is not running (status: {archgw_status})")
log.error("Please start plano using the 'planoai up' command.")
sys.exit(1)

View file

@ -128,7 +128,7 @@ def convert_legacy_listeners(
model_provider_set = False
for listener in listeners:
if listener.get("type") == "model_listener":
if listener.get("type") == "model":
if model_provider_set:
raise ValueError(
"Currently only one listener can have model_providers set"

View file

@ -1,6 +1,6 @@
[project]
name = "planoai"
version = "0.4.3"
version = "0.4.4"
description = "Python-based CLI tool to manage Plano."
authors = [{name = "Katanemo Labs, Inc."}]
readme = "README.md"

View file

@ -18,7 +18,7 @@ $ cargo test
```
## Local development
- Build docker image for arch gateway. Note this needs to be built once.
- Build docker image for Plano. Note this needs to be built once.
```
$ sh build_filter_image.sh
```
@ -27,9 +27,9 @@ $ cargo test
```
$ cargo build --target wasm32-wasip1 --release
```
- Start envoy with arch_config.yaml and test,
- Start envoy with config.yaml and test,
```
$ docker compose -f docker-compose.dev.yaml up archgw
$ docker compose -f docker-compose.dev.yaml up plano
```
- dev version of docker-compose file uses following files that are mounted inside the container. That means no docker rebuild is needed if any of these files change. Just restart the container and chagne will be picked up,
- envoy.template.yaml

View file

@ -66,6 +66,8 @@ properties:
type: string
enum:
- plano_orchestrator_v1
max_retries:
type: integer
type:
type: string
enum:

View file

@ -278,6 +278,7 @@ static_resources:
{% if listener.agents %}
# agent listeners
- name: {{ listener.name | replace(" ", "_") }}
address:
socket_address:
@ -333,7 +334,7 @@ static_resources:
auto_host_rewrite: true
prefix_rewrite: "/agents/"
cluster: bright_staff
timeout: {{ llm_gateway_listener.timeout }}
timeout: {{ listener.timeout | default('30s') }}
http_filters:
- name: envoy.filters.http.compressor
typed_config:
@ -412,7 +413,7 @@ static_resources:
"@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
path: "/var/log/access_llm.log"
format: |
[%START_TIME%] "%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL%" %RESPONSE_CODE% %RESPONSE_FLAGS% %BYTES_RECEIVED% %BYTES_SENT% %DURATION% %RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)% "%REQ(X-FORWARDED-FOR)%" "%REQ(USER-AGENT)%" "%REQ(X-REQUEST-ID)%" "%REQ(:AUTHORITY)%" "%UPSTREAM_HOST%" "%UPSTREAM_CLUSTER%"
[%START_TIME%] "%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL%" %RESPONSE_CODE% %RESPONSE_FLAGS% %BYTES_RECEIVED% %BYTES_SENT% %DURATION% %RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)% "%REQ(X-FORWARDED-FOR)%" "%REQ(USER-AGENT)%" "%REQ(X-REQUEST-ID)%" "%REQ(:AUTHORITY)%" "%UPSTREAM_HOST%" "%UPSTREAM_CLUSTER%" attempts=%UPSTREAM_REQUEST_ATTEMPT_COUNT%
route_config:
name: local_routes
virtual_hosts:
@ -533,7 +534,7 @@ static_resources:
"@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
path: "/var/log/access_llm.log"
format: |
[%START_TIME%] "%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL%" %RESPONSE_CODE% %RESPONSE_FLAGS% %BYTES_RECEIVED% %BYTES_SENT% %DURATION% %RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)% "%REQ(X-FORWARDED-FOR)%" "%REQ(USER-AGENT)%" "%REQ(X-REQUEST-ID)%" "%REQ(:AUTHORITY)%" "%UPSTREAM_HOST%" "%UPSTREAM_CLUSTER%"
[%START_TIME%] "%REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %PROTOCOL%" %RESPONSE_CODE% %RESPONSE_FLAGS% %BYTES_RECEIVED% %BYTES_SENT% %DURATION% %RESP(X-ENVOY-UPSTREAM-SERVICE-TIME)% "%REQ(X-FORWARDED-FOR)%" "%REQ(USER-AGENT)%" "%REQ(X-REQUEST-ID)%" "%REQ(:AUTHORITY)%" "%UPSTREAM_HOST%" "%UPSTREAM_CLUSTER%" attempts=%UPSTREAM_REQUEST_ATTEMPT_COUNT%
route_config:
name: local_routes
virtual_hosts:
@ -558,6 +559,16 @@ static_resources:
auto_host_rewrite: true
cluster: {{ llm_cluster_name }}
timeout: 300s
{% if llm_gateway_listener.max_retries %}
retry_policy:
retry_on: "5xx,connect-failure,refused-stream,reset,retriable-status-codes"
num_retries: {{ llm_gateway_listener.max_retries }}
per_try_timeout: 30s
retriable_status_codes: [429, 500, 502, 503, 504]
retry_back_off:
base_interval: 0.5s
max_interval: 5s
{% endif %}
{% endfor %}
- match:
prefix: "/"

View file

@ -5,7 +5,7 @@ failed_files=()
for file in $(find . -name config.yaml -o -name arch_config_full_reference.yaml); do
echo "Validating ${file}..."
touch $(pwd)/${file}_rendered
if ! docker run --rm -v "$(pwd)/${file}:/app/arch_config.yaml:ro" -v "$(pwd)/${file}_rendered:/app/arch_config_rendered.yaml:rw" --entrypoint /bin/sh katanemo/plano:0.4.3 -c "python -m planoai.config_generator" 2>&1 > /dev/null ; then
if ! docker run --rm -v "$(pwd)/${file}:/app/arch_config.yaml:ro" -v "$(pwd)/${file}_rendered:/app/arch_config_rendered.yaml:rw" --entrypoint /bin/sh katanemo/plano:0.4.4 -c "python -m planoai.config_generator" 2>&1 > /dev/null ; then
echo "Validation failed for $file"
failed_files+=("$file")
fi

95
crates/Cargo.lock generated
View file

@ -459,6 +459,35 @@ dependencies = [
"urlencoding",
]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fc4bff745c9b4c7fb1e97b25d13153da2bc7796260141df62378998d070207f"
dependencies = [
"cookie",
"document-features",
"idna",
"indexmap 2.9.0",
"log",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@ -628,6 +657,15 @@ dependencies = [
"syn 2.0.101",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "duration-string"
version = "0.3.0"
@ -993,11 +1031,14 @@ version = "0.1.0"
dependencies = [
"aws-smithy-eventstream",
"bytes",
"chrono",
"log",
"serde",
"serde_json",
"serde_with",
"serde_yaml",
"thiserror 2.0.12",
"ureq",
"uuid",
]
@ -1473,6 +1514,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "llm_gateway"
version = "0.1.0"
@ -2386,6 +2433,7 @@ version = "0.23.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
@ -3346,6 +3394,38 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a"
dependencies = [
"base64 0.22.1",
"cookie_store",
"flate2",
"log",
"percent-encoding",
"rustls 0.23.27",
"rustls-pki-types",
"serde",
"serde_json",
"ureq-proto",
"utf-8",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
dependencies = [
"base64 0.22.1",
"http 1.3.1",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.4"
@ -3363,6 +3443,12 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8_iter"
version = "1.0.4"
@ -3539,6 +3625,15 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "whoami"
version = "1.6.1"

View file

@ -1,8 +1,9 @@
use bytes::Bytes;
use common::configuration::{LlmProvider, ModelAlias};
use common::configuration::ModelAlias;
use common::consts::{
ARCH_IS_STREAMING_HEADER, ARCH_PROVIDER_HINT_HEADER, REQUEST_ID_HEADER, TRACE_PARENT_HEADER,
};
use common::llm_providers::LlmProviders;
use hermesllm::apis::openai_responses::InputParam;
use hermesllm::clients::{SupportedAPIsFromClient, SupportedUpstreamAPIs};
use hermesllm::{ProviderRequest, ProviderRequestType};
@ -49,7 +50,7 @@ pub async fn llm_chat(
router_service: Arc<RouterService>,
full_qualified_llm_provider_url: String,
model_aliases: Arc<Option<HashMap<String, ModelAlias>>>,
llm_providers: Arc<RwLock<Vec<LlmProvider>>>,
llm_providers: Arc<RwLock<LlmProviders>>,
state_storage: Option<Arc<dyn StateStorage>>,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
let request_path = request.uri().path().to_string();
@ -137,6 +138,27 @@ pub async fn llm_chat(
tracing::Span::current().record("model.requested", model_from_request.as_str());
tracing::Span::current().record("model.alias_resolved", resolved_model.as_str());
// Validate that the requested model exists in configuration
// This matches the validation in llm_gateway routing.rs
if llm_providers.read().await.get(&resolved_model).is_none() {
let err_msg = format!(
"Model '{}' not found in configured providers",
resolved_model
);
warn!("[PLANO_REQ_ID:{}] | FAILURE | {}", request_id, err_msg);
let mut bad_request = Response::new(full(err_msg));
*bad_request.status_mut() = StatusCode::BAD_REQUEST;
return Ok(bad_request);
}
// Handle provider/model slug format (e.g., "openai/gpt-4")
// Extract just the model name for upstream (providers don't understand the slug)
let model_name_only = if let Some((_, model)) = resolved_model.split_once('/') {
model.to_string()
} else {
resolved_model.clone()
};
// Extract tool names and user message preview for span attributes
let _tool_names = client_request.get_tool_names();
let _user_message_preview = client_request
@ -146,7 +168,9 @@ pub async fn llm_chat(
// Extract messages for signal analysis (clone before moving client_request)
let messages_for_signals = client_request.get_messages();
client_request.set_model(resolved_model.clone());
// Set the model to just the model name (without provider prefix)
// This ensures upstream receives "gpt-4" not "openai/gpt-4"
client_request.set_model(model_name_only.clone());
if client_request.remove_metadata_key("archgw_preference_config") {
debug!(
"[PLANO_REQ_ID:{}] Removed archgw_preference_config from metadata",
@ -253,7 +277,16 @@ pub async fn llm_chat(
}
};
let model_name = routing_result.model_name;
// Determine final model to use
// Router returns "none" as a sentinel value when it doesn't select a specific model
let router_selected_model = routing_result.model_name;
let model_name = if router_selected_model != "none" {
// Router selected a specific model via routing preferences
router_selected_model
} else {
// Router returned "none" sentinel, use validated resolved_model from request
resolved_model.clone()
};
// Record the routed model in span
tracing::Span::current().record("model.routing_resolved", model_name.as_str());
@ -263,8 +296,8 @@ pub async fn llm_chat(
});
debug!(
"[PLANO_REQ_ID:{}] | ARCH_ROUTER URL | {}, Resolved Model: {}",
request_id, full_qualified_llm_provider_url, model_name
"[PLANO_REQ_ID:{}] | ARCH_ROUTER URL | {}, Provider Hint: {}, Model for upstream: {}",
request_id, full_qualified_llm_provider_url, model_name, model_name_only
);
request_headers.insert(
@ -382,7 +415,7 @@ fn resolve_model_alias(
/// Looks up provider configuration, gets the ProviderId and base_url_path_prefix,
/// then uses target_endpoint_for_provider to calculate the correct upstream path.
async fn get_upstream_path(
llm_providers: &Arc<RwLock<Vec<LlmProvider>>>,
llm_providers: &Arc<RwLock<LlmProviders>>,
model_name: &str,
request_path: &str,
resolved_model: &str,
@ -405,25 +438,21 @@ async fn get_upstream_path(
/// Helper function to get provider info (ProviderId and base_url_path_prefix)
async fn get_provider_info(
llm_providers: &Arc<RwLock<Vec<LlmProvider>>>,
llm_providers: &Arc<RwLock<LlmProviders>>,
model_name: &str,
) -> (hermesllm::ProviderId, Option<String>) {
let providers_lock = llm_providers.read().await;
// First, try to find by model name or provider name
let provider = providers_lock.iter().find(|p| {
p.model.as_ref().map(|m| m == model_name).unwrap_or(false) || p.name == model_name
});
if let Some(provider) = provider {
// Try to find by model name or provider name using LlmProviders::get
// This handles both "gpt-4" and "openai/gpt-4" formats
if let Some(provider) = providers_lock.get(model_name) {
let provider_id = provider.provider_interface.to_provider_id();
let prefix = provider.base_url_path_prefix.clone();
return (provider_id, prefix);
}
let default_provider = providers_lock.iter().find(|p| p.default.unwrap_or(false));
if let Some(provider) = default_provider {
// Fall back to default provider
if let Some(provider) = providers_lock.default() {
let provider_id = provider.provider_interface.to_provider_id();
let prefix = provider.base_url_path_prefix.clone();
(provider_id, prefix)

View file

@ -1,19 +1,17 @@
use bytes::Bytes;
use common::configuration::{IntoModels, LlmProvider};
use hermesllm::apis::openai::Models;
use common::llm_providers::LlmProviders;
use http_body_util::{combinators::BoxBody, BodyExt, Full};
use hyper::{Response, StatusCode};
use serde_json;
use std::sync::Arc;
pub async fn list_models(
llm_providers: Arc<tokio::sync::RwLock<Vec<LlmProvider>>>,
llm_providers: Arc<tokio::sync::RwLock<LlmProviders>>,
) -> Response<BoxBody<Bytes, hyper::Error>> {
let prov = llm_providers.read().await;
let providers = prov.clone();
let openai_models: Models = providers.into_models();
let models = prov.to_models();
match serde_json::to_string(&openai_models) {
match serde_json::to_string(&models) {
Ok(json) => {
let body = Full::new(Bytes::from(json))
.map_err(|never| match never {})

View file

@ -133,15 +133,15 @@ pub async fn router_chat_get_upstream_model(
Ok(route) => match route {
Some((_, model_name)) => Ok(RoutingResult { model_name }),
None => {
// No route determined, use default model from request
// No route determined, return sentinel value "none"
// This signals to llm.rs to use the original validated request model
info!(
"[PLANO_REQ_ID: {}] | ROUTER_REQ | No route determined, using default model from request: {}",
request_id,
chat_request.model
"[PLANO_REQ_ID: {}] | ROUTER_REQ | No route determined, returning sentinel 'none'",
request_id
);
Ok(RoutingResult {
model_name: chat_request.model.clone(),
model_name: "none".to_string(),
})
}
},

View file

@ -13,6 +13,7 @@ use common::configuration::{Agent, Configuration};
use common::consts::{
CHAT_COMPLETIONS_PATH, MESSAGES_PATH, OPENAI_RESPONSES_API_PATH, PLANO_ORCHESTRATOR_MODEL_NAME,
};
use common::llm_providers::LlmProviders;
use http_body_util::{combinators::BoxBody, BodyExt, Empty};
use hyper::body::Incoming;
use hyper::server::conn::http1;
@ -75,7 +76,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
.cloned()
.collect();
let llm_providers = Arc::new(RwLock::new(arch_config.model_providers.clone()));
// Create expanded provider list for /v1/models endpoint
let llm_providers = LlmProviders::try_from(arch_config.model_providers.clone())
.expect("Failed to create LlmProviders");
let llm_providers = Arc::new(RwLock::new(llm_providers));
let combined_agents_filters_list = Arc::new(RwLock::new(Some(all_agents)));
let listeners = Arc::new(RwLock::new(arch_config.listeners.clone()));
let llm_provider_url =

View file

@ -255,7 +255,8 @@ impl LlmProviderType {
/// Get the ProviderId for this LlmProviderType
/// Used with the new function-based hermesllm API
pub fn to_provider_id(&self) -> hermesllm::ProviderId {
hermesllm::ProviderId::from(self.to_string().as_str())
hermesllm::ProviderId::try_from(self.to_string().as_str())
.expect("LlmProviderType should always map to a valid ProviderId")
}
}

View file

@ -1,24 +1,84 @@
use crate::configuration::LlmProvider;
use hermesllm::providers::ProviderId;
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::Arc;
#[derive(Debug)]
pub struct LlmProviders {
providers: HashMap<String, Rc<LlmProvider>>,
default: Option<Rc<LlmProvider>>,
providers: HashMap<String, Arc<LlmProvider>>,
default: Option<Arc<LlmProvider>>,
/// Wildcard providers: maps provider prefix to base provider config
/// e.g., "openai" -> LlmProvider for "openai/*"
wildcard_providers: HashMap<String, Arc<LlmProvider>>,
}
impl LlmProviders {
pub fn iter(&self) -> std::collections::hash_map::Iter<'_, String, Rc<LlmProvider>> {
pub fn iter(&self) -> std::collections::hash_map::Iter<'_, String, Arc<LlmProvider>> {
self.providers.iter()
}
pub fn default(&self) -> Option<Rc<LlmProvider>> {
pub fn default(&self) -> Option<Arc<LlmProvider>> {
self.default.clone()
}
/// Convert providers to OpenAI Models format for /v1/models endpoint
/// Filters out internal models and duplicate entries (backward compatibility aliases)
pub fn to_models(&self) -> hermesllm::apis::openai::Models {
use hermesllm::apis::openai::{ModelDetail, ModelObject, Models};
pub fn get(&self, name: &str) -> Option<Rc<LlmProvider>> {
self.providers.get(name).cloned()
let data: Vec<ModelDetail> = self
.providers
.iter()
.filter(|(key, provider)| {
// Exclude internal models
provider.internal != Some(true)
// Only include canonical entries (key matches provider name)
// This avoids duplicates from backward compatibility short names
&& *key == &provider.name
})
.map(|(name, provider)| ModelDetail {
id: name.clone(),
object: Some("model".to_string()),
created: 0,
owned_by: provider.to_provider_id().to_string(),
})
.collect();
Models {
object: ModelObject::List,
data,
}
}
pub fn get(&self, name: &str) -> Option<Arc<LlmProvider>> {
// First try exact match
if let Some(provider) = self.providers.get(name).cloned() {
return Some(provider);
}
// If name contains '/', it could be:
// 1. A full model ID like "openai/gpt-4" that we need to lookup
// 2. A provider/model slug that should match a wildcard provider
if let Some((provider_prefix, model_name)) = name.split_once('/') {
// Try to find the expanded model entry (e.g., "openai/gpt-4")
let full_model_id = format!("{}/{}", provider_prefix, model_name);
if let Some(provider) = self.providers.get(&full_model_id).cloned() {
return Some(provider);
}
// Try to find just the model name (for expanded wildcard entries)
if let Some(provider) = self.providers.get(model_name).cloned() {
return Some(provider);
}
// Fall back to wildcard match (e.g., "openai/*")
if let Some(wildcard_provider) = self.wildcard_providers.get(provider_prefix) {
// Create a new provider with the specific model from the slug
let mut specific_provider = (**wildcard_provider).clone();
specific_provider.model = Some(model_name.to_string());
return Some(Arc::new(specific_provider));
}
}
None
}
}
@ -43,38 +103,235 @@ impl TryFrom<Vec<LlmProvider>> for LlmProviders {
let mut llm_providers = LlmProviders {
providers: HashMap::new(),
default: None,
wildcard_providers: HashMap::new(),
};
for llm_provider in llm_providers_config {
let llm_provider: Rc<LlmProvider> = Rc::new(llm_provider);
if llm_provider.default.unwrap_or_default() {
match llm_providers.default {
Some(_) => return Err(LlmProvidersNewError::MoreThanOneDefault),
None => llm_providers.default = Some(Rc::clone(&llm_provider)),
}
}
// Track specific (non-wildcard) provider names to detect true duplicates
let mut specific_provider_names = std::collections::HashSet::new();
// Insert and check that there is no other provider with the same name.
let name = llm_provider.name.clone();
if llm_providers
.providers
.insert(name.clone(), Rc::clone(&llm_provider))
.is_some()
{
return Err(LlmProvidersNewError::DuplicateName(name));
}
// Track specific models that should be excluded from wildcard expansion
// Maps provider_prefix -> Set of model names (e.g., "anthropic" -> {"claude-sonnet-4-20250514"})
let mut specific_models_by_provider: HashMap<String, std::collections::HashSet<String>> =
HashMap::new();
// also add model_id as key for provider lookup
if let Some(model) = llm_provider.model.clone() {
if llm_providers
.providers
.insert(model, llm_provider)
.is_some()
{
return Err(LlmProvidersNewError::DuplicateName(name));
// First pass: collect all specific model configurations
for llm_provider in &llm_providers_config {
let is_wildcard = llm_provider
.model
.as_ref()
.map(|m| m == "*" || m.ends_with("/*"))
.unwrap_or(false);
if !is_wildcard {
// Check if this is a provider/model format
if let Some((provider_prefix, model_name)) = llm_provider.name.split_once('/') {
specific_models_by_provider
.entry(provider_prefix.to_string())
.or_default()
.insert(model_name.to_string());
}
}
}
for llm_provider in llm_providers_config {
let llm_provider: Arc<LlmProvider> = Arc::new(llm_provider);
if llm_provider.default.unwrap_or_default() {
match llm_providers.default {
Some(_) => return Err(LlmProvidersNewError::MoreThanOneDefault),
None => llm_providers.default = Some(Arc::clone(&llm_provider)),
}
}
let name = llm_provider.name.clone();
// Check if this is a wildcard provider (model is "*" or ends with "/*")
let is_wildcard = llm_provider
.model
.as_ref()
.map(|m| m == "*" || m.ends_with("/*"))
.unwrap_or(false);
if is_wildcard {
// Extract provider prefix from name
// e.g., "openai/*" -> "openai"
let provider_prefix = name.trim_end_matches("/*").trim_end_matches('*');
// For wildcard providers, we:
// 1. Store the base config in wildcard_providers for runtime matching
// 2. Optionally expand to all known models if available
llm_providers
.wildcard_providers
.insert(provider_prefix.to_string(), Arc::clone(&llm_provider));
// Try to expand wildcard using ProviderId models
if let Ok(provider_id) = ProviderId::try_from(provider_prefix) {
let models = provider_id.models();
// Get the set of specific models to exclude for this provider
let models_to_exclude = specific_models_by_provider
.get(provider_prefix)
.cloned()
.unwrap_or_default();
if !models.is_empty() {
let excluded_count = models_to_exclude.len();
let total_models = models.len();
log::info!(
"Expanding wildcard provider '{}' to {} models{}",
provider_prefix,
total_models - excluded_count,
if excluded_count > 0 {
format!(" (excluding {} specifically configured)", excluded_count)
} else {
String::new()
}
);
// Create a provider entry for each model (except those specifically configured)
for model_name in models {
// Skip this model if it has a specific configuration
if models_to_exclude.contains(&model_name) {
log::debug!(
"Skipping wildcard expansion for '{}/{}' - specific configuration exists",
provider_prefix,
model_name
);
continue;
}
let full_model_id = format!("{}/{}", provider_prefix, model_name);
// Create a new provider with the specific model
let mut expanded_provider = (*llm_provider).clone();
expanded_provider.model = Some(model_name.clone());
expanded_provider.name = full_model_id.clone();
let expanded_rc = Arc::new(expanded_provider);
// Insert with full model ID as key
llm_providers
.providers
.insert(full_model_id.clone(), Arc::clone(&expanded_rc));
// Also insert with just model name for backward compatibility
llm_providers.providers.insert(model_name, expanded_rc);
}
}
} else {
log::warn!(
"Wildcard provider '{}' specified but no models found in registry. \
Will match dynamically at runtime.",
provider_prefix
);
}
} else {
// Non-wildcard provider - specific configuration
// Check for duplicate specific entries (not allowed)
if specific_provider_names.contains(&name) {
return Err(LlmProvidersNewError::DuplicateName(name));
}
specific_provider_names.insert(name.clone());
// This specific configuration takes precedence over any wildcard expansion
// The wildcard expansion already excluded this model (see first pass above)
log::debug!("Processing specific provider configuration: {}", name);
// Insert with the provider name as key
llm_providers
.providers
.insert(name.clone(), Arc::clone(&llm_provider));
// Also add model_id as key for provider lookup
if let Some(model) = llm_provider.model.clone() {
llm_providers.providers.insert(model, llm_provider);
}
}
}
Ok(llm_providers)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::configuration::LlmProviderType;
fn create_test_provider(name: &str, model: Option<String>) -> LlmProvider {
LlmProvider {
name: name.to_string(),
model,
access_key: None,
endpoint: None,
cluster_name: None,
provider_interface: LlmProviderType::OpenAI,
default: None,
base_url_path_prefix: None,
port: None,
rate_limits: None,
usage: None,
routing_preferences: None,
internal: None,
stream: None,
passthrough_auth: None,
}
}
#[test]
fn test_static_provider_lookup() {
// Test 1: Statically defined provider - should be findable by model or provider name
let providers = vec![create_test_provider("my-openai", Some("gpt-4".to_string()))];
let llm_providers = LlmProviders::try_from(providers).unwrap();
// Should find by model name
let result = llm_providers.get("gpt-4");
assert!(result.is_some());
assert_eq!(result.unwrap().name, "my-openai");
// Should also find by provider name
let result = llm_providers.get("my-openai");
assert!(result.is_some());
assert_eq!(result.unwrap().name, "my-openai");
}
#[test]
fn test_wildcard_provider_with_known_model() {
// Test 2: Wildcard provider that expands to OpenAI models
let providers = vec![create_test_provider("openai/*", Some("*".to_string()))];
let llm_providers = LlmProviders::try_from(providers).unwrap();
// Should find via expanded wildcard entry
let result = llm_providers.get("openai/gpt-4");
let provider = result.unwrap();
assert_eq!(provider.name, "openai/gpt-4");
assert_eq!(provider.model.as_ref().unwrap(), "gpt-4");
// Should also be able to find by just model name (from expansion)
let result = llm_providers.get("gpt-4");
assert_eq!(result.unwrap().model.as_ref().unwrap(), "gpt-4");
}
#[test]
fn test_custom_wildcard_provider_with_full_slug() {
// Test 3: Custom wildcard provider with full slug offered
let providers = vec![create_test_provider(
"custom-provider/*",
Some("*".to_string()),
)];
let llm_providers = LlmProviders::try_from(providers).unwrap();
// Should match via wildcard fallback and extract model name from slug
let result = llm_providers.get("custom-provider/custom-model");
let provider = result.unwrap();
assert_eq!(provider.model.as_ref().unwrap(), "custom-model");
// Wildcard should be stored
assert!(llm_providers
.wildcard_providers
.contains_key("custom-provider"));
}
}

View file

@ -51,17 +51,14 @@ pub fn replace_params_in_path(
// add default values
for param in prompt_target_params.iter() {
if !vars_replaced.contains(&param.name) && param.default.is_some() {
let default_val = param.default.as_ref().unwrap();
params.insert(param.name.clone(), default_val.clone());
if query_string_replaced.contains("?") {
query_string_replaced.push_str(&format!("&{}={}", param.name, default_val));
} else {
query_string_replaced.push_str(&format!(
"?{}={}",
param.name,
param.default.as_ref().unwrap()
));
if !vars_replaced.contains(&param.name) {
if let Some(default_val) = &param.default {
params.insert(param.name.clone(), default_val.clone());
if query_string_replaced.contains("?") {
query_string_replaced.push_str(&format!("&{}={}", param.name, default_val));
} else {
query_string_replaced.push_str(&format!("?{}={}", param.name, default_val));
}
}
}
}

View file

@ -1,10 +1,9 @@
use std::rc::Rc;
use std::sync::Arc;
use crate::{configuration, llm_providers::LlmProviders};
use configuration::LlmProvider;
use rand::{seq::IteratorRandom, thread_rng};
#[derive(Debug)]
#[derive(Debug, Clone)]
pub enum ProviderHint {
Default,
Name(String),
@ -22,33 +21,14 @@ impl From<String> for ProviderHint {
pub fn get_llm_provider(
llm_providers: &LlmProviders,
provider_hint: Option<ProviderHint>,
) -> Rc<LlmProvider> {
let maybe_provider = provider_hint.and_then(|hint| match hint {
ProviderHint::Default => llm_providers.default(),
// FIXME: should a non-existent name in the hint be more explicit? i.e, return a BAD_REQUEST?
ProviderHint::Name(name) => llm_providers.get(&name),
});
if let Some(provider) = maybe_provider {
return provider;
) -> Result<Arc<LlmProvider>, String> {
match provider_hint {
Some(ProviderHint::Default) => llm_providers
.default()
.ok_or_else(|| "No default provider configured".to_string()),
Some(ProviderHint::Name(name)) => llm_providers
.get(&name)
.ok_or_else(|| format!("Model '{}' not found in configured providers", name)),
None => Err("No model specified in request".to_string()),
}
if llm_providers.default().is_some() {
return llm_providers.default().unwrap();
}
let mut rng = thread_rng();
llm_providers
.iter()
.filter(|(_, provider)| {
provider
.model
.as_ref()
.map(|m| !m.starts_with("Arch"))
.unwrap_or(true)
})
.choose(&mut rng)
.expect("There should always be at least one non-Arch llm provider")
.1
.clone()
}

View file

@ -3,12 +3,24 @@ name = "hermesllm"
version = "0.1.0"
edition = "2021"
[[bin]]
name = "fetch_models"
path = "src/bin/fetch_models.rs"
required-features = ["model-fetch"]
[dependencies]
serde = {version = "1.0.219", features = ["derive"]}
serde_json = "1.0.140"
serde_yaml = "0.9.34-deprecated"
serde_with = {version = "3.12.0", features = ["base64"]}
thiserror = "2.0.12"
aws-smithy-eventstream = "0.60"
bytes = "1.10"
uuid = { version = "1.11", features = ["v4"] }
log = "0.4"
chrono = { version = "0.4", optional = true }
ureq = { version = "3.1", features = ["json"], optional = true }
[features]
default = []
model-fetch = ["ureq", "chrono"]

View file

@ -0,0 +1,412 @@
// Fetch latest provider models from canonical provider APIs and update provider_models.yaml
// Usage:
// Optional: OPENAI_API_KEY, ANTHROPIC_API_KEY, DEEPSEEK_API_KEY, GROK_API_KEY,
// DASHSCOPE_API_KEY, MOONSHOT_API_KEY, ZHIPU_API_KEY, GOOGLE_API_KEY
// Required: AWS CLI configured for Amazon Bedrock models
// cargo run --bin fetch_models
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
fn main() {
// Default to writing in the same directory as this source file
let default_path = std::path::Path::new(file!())
.parent()
.unwrap()
.join("provider_models.yaml");
let output_path = std::env::args()
.nth(1)
.unwrap_or_else(|| default_path.to_string_lossy().to_string());
println!("Fetching latest models from provider APIs...");
match fetch_all_models() {
Ok(models) => {
let yaml = serde_yaml::to_string(&models).expect("Failed to serialize models");
std::fs::write(&output_path, yaml).expect("Failed to write provider_models.yaml");
println!(
"✓ Successfully updated {} providers ({} models) to {}",
models.metadata.total_providers, models.metadata.total_models, output_path
);
}
Err(e) => {
eprintln!("Error fetching models: {}", e);
eprintln!("\nMake sure required tools are set up:");
eprintln!(" AWS CLI configured for Bedrock (for Amazon models)");
eprintln!(" export OPENAI_API_KEY=your-key-here # Optional");
eprintln!(" export DEEPSEEK_API_KEY=your-key-here # Optional");
eprintln!(" cargo run --bin fetch_models");
std::process::exit(1);
}
}
}
// OpenAI-compatible API response (used by most providers)
#[derive(Debug, Deserialize)]
struct OpenAICompatibleModel {
id: String,
}
#[derive(Debug, Deserialize)]
struct OpenAICompatibleResponse {
data: Vec<OpenAICompatibleModel>,
}
// Google Gemini API response
#[derive(Debug, Deserialize)]
struct GoogleModel {
name: String,
#[serde(rename = "supportedGenerationMethods")]
supported_generation_methods: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct GoogleResponse {
models: Vec<GoogleModel>,
}
#[derive(Debug, Serialize)]
struct ProviderModels {
version: String,
source: String,
providers: HashMap<String, Vec<String>>,
metadata: Metadata,
}
#[derive(Debug, Serialize)]
struct Metadata {
total_providers: usize,
total_models: usize,
last_updated: String,
}
fn is_text_model(model_id: &str) -> bool {
let id_lower = model_id.to_lowercase();
// Filter out known non-text models
let non_text_patterns = [
"embedding", // Embedding models
"whisper", // Audio transcription
"-tts", // Text-to-speech (with dash to avoid matching in middle of words)
"tts-", // Text-to-speech prefix
"dall-e", // Image generation
"sora", // Video generation
"moderation", // Moderation models
"babbage", // Legacy completion models
"davinci-002", // Legacy completion models
"transcribe", // Audio transcription models
"realtime", // Realtime audio models
"audio", // Audio models (gpt-audio, gpt-audio-mini)
"-image-", // Image generation models (grok-2-image-1212)
"-ocr-", // OCR models
"ocr-", // OCR models prefix
"voxtral", // Audio/voice models
];
// Additional pattern: models that are purely for image generation usually have "image" in the name
// but we need to be careful not to filter vision models that can process images
// Models like "gpt-image-1" or "chatgpt-image-latest" are image generators
// Models like "grok-2-vision" or "gemini-vision" are vision models (text+image->text)
if non_text_patterns
.iter()
.any(|pattern| id_lower.contains(pattern))
{
return false;
}
// Filter models starting with "gpt-image" (image generators)
if id_lower.contains("/gpt-image") || id_lower.contains("/chatgpt-image") {
return false;
}
true
}
fn fetch_openai_compatible_models(
api_url: &str,
api_key: &str,
provider_prefix: &str,
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let response_body = ureq::get(api_url)
.header("Authorization", &format!("Bearer {}", api_key))
.call()?
.body_mut()
.read_to_string()?;
let response: OpenAICompatibleResponse = serde_json::from_str(&response_body)?;
Ok(response
.data
.into_iter()
.filter(|m| is_text_model(&m.id))
.map(|m| format!("{}/{}", provider_prefix, m.id))
.collect())
}
fn fetch_anthropic_models(api_key: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let response_body = ureq::get("https://api.anthropic.com/v1/models")
.header("x-api-key", api_key)
.header("anthropic-version", "2023-06-01")
.call()?
.body_mut()
.read_to_string()?;
let response: OpenAICompatibleResponse = serde_json::from_str(&response_body)?;
let dated_models: Vec<String> = response
.data
.into_iter()
.filter(|m| is_text_model(&m.id))
.map(|m| m.id)
.collect();
let mut models: Vec<String> = Vec::new();
// Add both dated versions and their aliases (without the -YYYYMMDD suffix)
for model_id in dated_models {
// Add the full dated model ID
models.push(format!("anthropic/{}", model_id));
// Generate alias by removing trailing -YYYYMMDD pattern
// Pattern: ends with -YYYYMMDD where YYYY is year, MM is month, DD is day
if let Some(date_pos) = model_id.rfind('-') {
let potential_date = &model_id[date_pos + 1..];
// Check if it's an 8-digit date (YYYYMMDD)
if potential_date.len() == 8 && potential_date.chars().all(|c| c.is_ascii_digit()) {
let alias = &model_id[..date_pos];
let alias_full = format!("anthropic/{}", alias);
// Only add if not already present
if !models.contains(&alias_full) {
models.push(alias_full);
}
}
}
}
Ok(models)
}
fn fetch_google_models(api_key: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let api_url = format!(
"https://generativelanguage.googleapis.com/v1beta/models?key={}",
api_key
);
let response_body = ureq::get(&api_url).call()?.body_mut().read_to_string()?;
let response: GoogleResponse = serde_json::from_str(&response_body)?;
// Only include models that support generateContent
Ok(response
.models
.into_iter()
.filter(|m| {
m.supported_generation_methods
.as_ref()
.is_some_and(|methods| methods.contains(&"generateContent".to_string()))
})
.map(|m| {
// Convert "models/gemini-pro" to "google/gemini-pro"
let model_id = m.name.strip_prefix("models/").unwrap_or(&m.name);
format!("google/{}", model_id)
})
.collect())
}
fn fetch_bedrock_amazon_models() -> Result<Vec<String>, Box<dyn std::error::Error>> {
// Use AWS CLI to fetch Amazon models from Bedrock
let output = std::process::Command::new("aws")
.args([
"bedrock",
"list-foundation-models",
"--by-provider",
"amazon",
"--by-output-modality",
"TEXT",
"--no-cli-pager",
"--output",
"json",
])
.output()?;
if !output.status.success() {
return Err(format!(
"AWS CLI command failed: {}",
String::from_utf8_lossy(&output.stderr)
)
.into());
}
let response_body = String::from_utf8(output.stdout)?;
#[derive(Debug, Deserialize)]
struct BedrockModelSummary {
#[serde(rename = "modelId")]
model_id: String,
}
#[derive(Debug, Deserialize)]
struct BedrockResponse {
#[serde(rename = "modelSummaries")]
model_summaries: Vec<BedrockModelSummary>,
}
let bedrock_response: BedrockResponse = serde_json::from_str(&response_body)?;
// Filter out embedding, image generation, and rerank models
let amazon_models: Vec<String> = bedrock_response
.model_summaries
.into_iter()
.filter(|model| {
let id_lower = model.model_id.to_lowercase();
!id_lower.contains("embed")
&& !id_lower.contains("image")
&& !id_lower.contains("rerank")
})
.map(|m| format!("amazon/{}", m.model_id))
.collect();
Ok(amazon_models)
}
fn fetch_all_models() -> Result<ProviderModels, Box<dyn std::error::Error>> {
let mut providers: HashMap<String, Vec<String>> = HashMap::new();
let mut errors: Vec<String> = Vec::new();
// Configuration: provider name, env var, API URL, prefix for model IDs
let provider_configs = vec![
(
"openai",
"OPENAI_API_KEY",
"https://api.openai.com/v1/models",
"openai",
),
(
"mistralai",
"MISTRAL_API_KEY",
"https://api.mistral.ai/v1/models",
"mistralai",
),
(
"deepseek",
"DEEPSEEK_API_KEY",
"https://api.deepseek.com/v1/models",
"deepseek",
),
("x-ai", "GROK_API_KEY", "https://api.x.ai/v1/models", "x-ai"),
(
"moonshotai",
"MOONSHOT_API_KEY",
"https://api.moonshot.ai/v1/models",
"moonshotai",
),
(
"qwen",
"DASHSCOPE_API_KEY",
"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models",
"qwen",
),
(
"z-ai",
"ZHIPU_API_KEY",
"https://open.bigmodel.cn/api/paas/v4/models",
"z-ai",
),
];
// Fetch from OpenAI-compatible providers
for (provider_name, env_var, api_url, prefix) in provider_configs {
if let Ok(api_key) = std::env::var(env_var) {
match fetch_openai_compatible_models(api_url, &api_key, prefix) {
Ok(models) => {
println!("{}: {} models", provider_name, models.len());
providers.insert(provider_name.to_string(), models);
}
Err(e) => {
let err_msg = format!("{}: {}", provider_name, e);
eprintln!("{}", err_msg);
errors.push(err_msg);
}
}
} else {
println!("{}: {} not set (skipped)", provider_name, env_var);
}
}
// Fetch Anthropic models (different authentication)
if let Ok(api_key) = std::env::var("ANTHROPIC_API_KEY") {
match fetch_anthropic_models(&api_key) {
Ok(models) => {
println!(" ✓ anthropic: {} models", models.len());
providers.insert("anthropic".to_string(), models);
}
Err(e) => {
let err_msg = format!(" ✗ anthropic: {}", e);
eprintln!("{}", err_msg);
errors.push(err_msg);
}
}
} else {
println!(" ⊘ anthropic: ANTHROPIC_API_KEY not set (skipped)");
}
// Fetch Google models (different API format)
if let Ok(api_key) = std::env::var("GOOGLE_API_KEY") {
match fetch_google_models(&api_key) {
Ok(models) => {
println!(" ✓ google: {} models", models.len());
providers.insert("google".to_string(), models);
}
Err(e) => {
let err_msg = format!(" ✗ google: {}", e);
eprintln!("{}", err_msg);
errors.push(err_msg);
}
}
} else {
println!(" ⊘ google: GOOGLE_API_KEY not set (skipped)");
}
// Fetch Amazon models from AWS Bedrock
match fetch_bedrock_amazon_models() {
Ok(models) => {
println!(" ✓ amazon: {} models (via AWS Bedrock)", models.len());
providers.insert("amazon".to_string(), models);
}
Err(e) => {
let err_msg = format!(" ✗ amazon: {} (AWS Bedrock required)", e);
eprintln!("{}", err_msg);
errors.push(err_msg);
}
}
if providers.is_empty() {
return Err("No models fetched from any provider. Check API keys.".into());
}
let total_providers = providers.len();
let total_models: usize = providers.values().map(|v| v.len()).sum();
println!(
"\n✅ Successfully fetched models from {} providers",
total_providers
);
if !errors.is_empty() {
println!("⚠️ {} providers failed", errors.len());
}
Ok(ProviderModels {
version: "1.0".to_string(),
source: "canonical-apis".to_string(),
providers,
metadata: Metadata {
total_providers,
total_models,
last_updated: chrono::Utc::now().to_rfc3339(),
},
})
}

View file

@ -0,0 +1,315 @@
version: '1.0'
source: canonical-apis
providers:
qwen:
- qwen/qwen3-max-2026-01-23
- qwen/qwen-plus-character
- qwen/qwen-flash-character
- qwen/qwen-flash
- qwen/qwen3-vl-plus-2025-12-19
- qwen/qwen3-omni-flash-2025-12-01
- qwen/qwen3-livetranslate-flash-2025-12-01
- qwen/qwen3-livetranslate-flash
- qwen/qwen-mt-lite
- qwen/qwen-plus-2025-12-01
- qwen/qwen-mt-flash
- qwen/ccai-pro
- qwen/tongyi-tingwu-slp
- qwen/qwen3-vl-flash
- qwen/qwen3-vl-flash-2025-10-15
- qwen/qwen3-omni-flash
- qwen/qwen3-omni-flash-2025-09-15
- qwen/qwen3-omni-30b-a3b-captioner
- qwen/qwen2.5-7b-instruct
- qwen/qwen2.5-14b-instruct
- qwen/qwen2.5-32b-instruct
- qwen/qwen2.5-72b-instruct
- qwen/qwen2.5-14b-instruct-1m
- qwen/qwen2.5-7b-instruct-1m
- qwen/qwen-max-2025-01-25
- qwen/qwen-max-latest
- qwen/qwen-turbo-2024-11-01
- qwen/qwen-turbo-latest
- qwen/qwen-plus-latest
- qwen/qwen-plus-2025-01-25
- qwen/qwq-plus-2025-03-05
- qwen/qwen-mt-turbo
- qwen/qwen-mt-plus
- qwen/qwen-coder-plus
- qwen/qwq-plus
- qwen/qwen2.5-vl-32b-instruct
- qwen/qvq-max
- qwen/qwen-omni-turbo
- qwen/qwen3-8b
- qwen/qwen3-30b-a3b
- qwen/qwen3-235b-a22b
- qwen/qwen-turbo-2025-04-28
- qwen/qwen-plus-2025-04-28
- qwen/qwen-vl-max-2025-04-08
- qwen/qwen-vl-plus-2025-01-25
- qwen/qwen-vl-plus-latest
- qwen/qwen-vl-max-latest
- qwen/qwen-vl-plus-2025-05-07
- qwen/qwen3-coder-plus
- qwen/qwen3-coder-480b-a35b-instruct
- qwen/qwen3-235b-a22b-instruct-2507
- qwen/qwen-plus-2025-07-14
- qwen/qwen3-coder-plus-2025-07-22
- qwen/qwen3-235b-a22b-thinking-2507
- qwen/qwen3-coder-flash
- qwen/qwen-vl-max
- qwen/qwen-vl-max-2025-08-13
- qwen/qwen3-max
- qwen/qwen3-max-2025-09-23
- qwen/qwen3-vl-plus
- qwen/qwen3-vl-235b-a22b-instruct
- qwen/qwen3-vl-235b-a22b-thinking
- qwen/qwen3-30b-a3b-thinking-2507
- qwen/qwen3-30b-a3b-instruct-2507
- qwen/qwen3-14b
- qwen/qwen3-32b
- qwen/qwen3-0.6b
- qwen/qwen3-4b
- qwen/qwen3-1.7b
- qwen/qwen-vl-plus
- qwen/qwen3-coder-plus-2025-09-23
- qwen/qwen3-vl-plus-2025-09-23
- qwen/qwen-plus-2025-09-11
- qwen/qwen3-next-80b-a3b-thinking
- qwen/qwen3-next-80b-a3b-instruct
- qwen/qwen3-max-preview
- qwen/qwen2-7b-instruct
- qwen/qwen-max
- qwen/qwen-plus
- qwen/qwen-turbo
openai:
- openai/gpt-4-0613
- openai/gpt-4
- openai/gpt-3.5-turbo
- openai/gpt-5.2-codex
- openai/gpt-3.5-turbo-instruct
- openai/gpt-3.5-turbo-instruct-0914
- openai/gpt-4-1106-preview
- openai/gpt-3.5-turbo-1106
- openai/gpt-4-0125-preview
- openai/gpt-4-turbo-preview
- openai/gpt-3.5-turbo-0125
- openai/gpt-4-turbo
- openai/gpt-4-turbo-2024-04-09
- openai/gpt-4o
- openai/gpt-4o-2024-05-13
- openai/gpt-4o-mini-2024-07-18
- openai/gpt-4o-mini
- openai/gpt-4o-2024-08-06
- openai/chatgpt-4o-latest
- openai/o1-2024-12-17
- openai/o1
- openai/computer-use-preview
- openai/o3-mini
- openai/o3-mini-2025-01-31
- openai/gpt-4o-2024-11-20
- openai/computer-use-preview-2025-03-11
- openai/gpt-4o-search-preview-2025-03-11
- openai/gpt-4o-search-preview
- openai/gpt-4o-mini-search-preview-2025-03-11
- openai/gpt-4o-mini-search-preview
- openai/o1-pro-2025-03-19
- openai/o1-pro
- openai/o3-2025-04-16
- openai/o4-mini-2025-04-16
- openai/o3
- openai/o4-mini
- openai/gpt-4.1-2025-04-14
- openai/gpt-4.1
- openai/gpt-4.1-mini-2025-04-14
- openai/gpt-4.1-mini
- openai/gpt-4.1-nano-2025-04-14
- openai/gpt-4.1-nano
- openai/codex-mini-latest
- openai/o3-pro
- openai/o3-pro-2025-06-10
- openai/o4-mini-deep-research
- openai/o3-deep-research
- openai/o3-deep-research-2025-06-26
- openai/o4-mini-deep-research-2025-06-26
- openai/gpt-5-chat-latest
- openai/gpt-5-2025-08-07
- openai/gpt-5
- openai/gpt-5-mini-2025-08-07
- openai/gpt-5-mini
- openai/gpt-5-nano-2025-08-07
- openai/gpt-5-nano
- openai/gpt-5-codex
- openai/gpt-5-pro-2025-10-06
- openai/gpt-5-pro
- openai/gpt-5-search-api
- openai/gpt-5-search-api-2025-10-14
- openai/gpt-5.1-chat-latest
- openai/gpt-5.1-2025-11-13
- openai/gpt-5.1
- openai/gpt-5.1-codex
- openai/gpt-5.1-codex-mini
- openai/gpt-5.1-codex-max
- openai/gpt-5.2-2025-12-11
- openai/gpt-5.2
- openai/gpt-5.2-pro-2025-12-11
- openai/gpt-5.2-pro
- openai/gpt-5.2-chat-latest
- openai/gpt-3.5-turbo-16k
- openai/ft:gpt-3.5-turbo-0613:katanemo::8CMZbm0P
google:
- google/gemini-2.5-flash
- google/gemini-2.5-pro
- google/gemini-2.0-flash-exp
- google/gemini-2.0-flash
- google/gemini-2.0-flash-001
- google/gemini-2.0-flash-exp-image-generation
- google/gemini-2.0-flash-lite-001
- google/gemini-2.0-flash-lite
- google/gemini-2.0-flash-lite-preview-02-05
- google/gemini-2.0-flash-lite-preview
- google/gemini-exp-1206
- google/gemini-2.5-flash-preview-tts
- google/gemini-2.5-pro-preview-tts
- google/gemma-3-1b-it
- google/gemma-3-4b-it
- google/gemma-3-12b-it
- google/gemma-3-27b-it
- google/gemma-3n-e4b-it
- google/gemma-3n-e2b-it
- google/gemini-flash-latest
- google/gemini-flash-lite-latest
- google/gemini-pro-latest
- google/gemini-2.5-flash-lite
- google/gemini-2.5-flash-image
- google/gemini-2.5-flash-preview-09-2025
- google/gemini-2.5-flash-lite-preview-09-2025
- google/gemini-3-pro-preview
- google/gemini-3-flash-preview
- google/gemini-3-pro-image-preview
- google/nano-banana-pro-preview
- google/gemini-robotics-er-1.5-preview
- google/gemini-2.5-computer-use-preview-10-2025
- google/deep-research-pro-preview-12-2025
mistralai:
- mistralai/mistral-medium-2505
- mistralai/mistral-medium-2508
- mistralai/mistral-medium-latest
- mistralai/mistral-medium
- mistralai/open-mistral-nemo
- mistralai/open-mistral-nemo-2407
- mistralai/mistral-tiny-2407
- mistralai/mistral-tiny-latest
- mistralai/mistral-large-2411
- mistralai/pixtral-large-2411
- mistralai/pixtral-large-latest
- mistralai/mistral-large-pixtral-2411
- mistralai/codestral-2508
- mistralai/codestral-latest
- mistralai/devstral-small-2507
- mistralai/devstral-medium-2507
- mistralai/devstral-2512
- mistralai/mistral-vibe-cli-latest
- mistralai/devstral-medium-latest
- mistralai/devstral-latest
- mistralai/labs-devstral-small-2512
- mistralai/devstral-small-latest
- mistralai/mistral-small-2506
- mistralai/mistral-small-latest
- mistralai/labs-mistral-small-creative
- mistralai/magistral-medium-2509
- mistralai/magistral-medium-latest
- mistralai/magistral-small-2509
- mistralai/magistral-small-latest
- mistralai/mistral-large-2512
- mistralai/mistral-large-latest
- mistralai/ministral-3b-2512
- mistralai/ministral-3b-latest
- mistralai/ministral-8b-2512
- mistralai/ministral-8b-latest
- mistralai/ministral-14b-2512
- mistralai/ministral-14b-latest
- mistralai/open-mistral-7b
- mistralai/mistral-tiny
- mistralai/mistral-tiny-2312
- mistralai/pixtral-12b-2409
- mistralai/pixtral-12b
- mistralai/pixtral-12b-latest
- mistralai/ministral-3b-2410
- mistralai/ministral-8b-2410
- mistralai/codestral-2501
- mistralai/codestral-2412
- mistralai/codestral-2411-rc5
- mistralai/mistral-small-2501
- mistralai/mistral-embed-2312
- mistralai/mistral-embed
- mistralai/codestral-embed
- mistralai/codestral-embed-2505
z-ai:
- z-ai/glm-4.5
- z-ai/glm-4.5-air
- z-ai/glm-4.6
- z-ai/glm-4.7
amazon:
- amazon/amazon.nova-pro-v1:0
- amazon/amazon.nova-2-lite-v1:0
- amazon/amazon.nova-2-sonic-v1:0
- amazon/amazon.titan-tg1-large
- amazon/amazon.nova-premier-v1:0:8k
- amazon/amazon.nova-premier-v1:0:20k
- amazon/amazon.nova-premier-v1:0:1000k
- amazon/amazon.nova-premier-v1:0:mm
- amazon/amazon.nova-premier-v1:0
- amazon/amazon.nova-lite-v1:0
- amazon/amazon.nova-micro-v1:0
deepseek:
- deepseek/deepseek-chat
- deepseek/deepseek-reasoner
x-ai:
- x-ai/grok-2-vision-1212
- x-ai/grok-3
- x-ai/grok-3-mini
- x-ai/grok-4-0709
- x-ai/grok-4-1-fast-non-reasoning
- x-ai/grok-4-1-fast-reasoning
- x-ai/grok-4-fast-non-reasoning
- x-ai/grok-4-fast-reasoning
- x-ai/grok-code-fast-1
moonshotai:
- moonshotai/kimi-latest
- moonshotai/kimi-k2.5
- moonshotai/moonshot-v1-8k-vision-preview
- moonshotai/kimi-k2-thinking
- moonshotai/moonshot-v1-auto
- moonshotai/kimi-k2-0711-preview
- moonshotai/moonshot-v1-32k
- moonshotai/kimi-k2-thinking-turbo
- moonshotai/kimi-k2-0905-preview
- moonshotai/moonshot-v1-128k
- moonshotai/moonshot-v1-32k-vision-preview
- moonshotai/moonshot-v1-128k-vision-preview
- moonshotai/kimi-k2-turbo-preview
- moonshotai/moonshot-v1-8k
anthropic:
- anthropic/claude-opus-4-5-20251101
- anthropic/claude-opus-4-5
- anthropic/claude-haiku-4-5-20251001
- anthropic/claude-haiku-4-5
- anthropic/claude-sonnet-4-5-20250929
- anthropic/claude-sonnet-4-5
- anthropic/claude-opus-4-1-20250805
- anthropic/claude-opus-4-1
- anthropic/claude-opus-4-20250514
- anthropic/claude-opus-4
- anthropic/claude-sonnet-4-20250514
- anthropic/claude-sonnet-4
- anthropic/claude-3-7-sonnet-20250219
- anthropic/claude-3-7-sonnet
- anthropic/claude-3-5-haiku-20241022
- anthropic/claude-3-5-haiku
- anthropic/claude-3-haiku-20240307
- anthropic/claude-3-haiku
metadata:
total_providers: 10
total_models: 298
last_updated: 2026-01-27T22:40:53.653700+00:00

View file

@ -0,0 +1,15 @@
#!/bin/bash
set -e
# Get the directory where this script is located
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# Navigate to crates directory (bin -> src -> hermesllm -> crates)
cd "$SCRIPT_DIR/../../.."
# Load environment variables silently and run fetch_models
set -a
source hermesllm/src/bin/.env
set +a
cargo run --bin fetch_models --features model-fetch

View file

@ -29,10 +29,27 @@ mod tests {
#[test]
fn test_provider_id_conversion() {
assert_eq!(ProviderId::from("openai"), ProviderId::OpenAI);
assert_eq!(ProviderId::from("mistral"), ProviderId::Mistral);
assert_eq!(ProviderId::from("groq"), ProviderId::Groq);
assert_eq!(ProviderId::from("arch"), ProviderId::Arch);
assert_eq!(ProviderId::try_from("openai").unwrap(), ProviderId::OpenAI);
assert_eq!(
ProviderId::try_from("mistral").unwrap(),
ProviderId::Mistral
);
assert_eq!(ProviderId::try_from("groq").unwrap(), ProviderId::Groq);
assert_eq!(ProviderId::try_from("arch").unwrap(), ProviderId::Arch);
// Test aliases
assert_eq!(ProviderId::try_from("google").unwrap(), ProviderId::Gemini);
assert_eq!(
ProviderId::try_from("together").unwrap(),
ProviderId::TogetherAI
);
assert_eq!(
ProviderId::try_from("amazon").unwrap(),
ProviderId::AmazonBedrock
);
// Test error case
assert!(ProviderId::try_from("unknown_provider").is_err());
}
#[test]

View file

@ -1,6 +1,28 @@
use crate::apis::{AmazonBedrockApi, AnthropicApi, OpenAIApi};
use crate::clients::endpoints::{SupportedAPIsFromClient, SupportedUpstreamAPIs};
use serde::Deserialize;
use std::collections::HashMap;
use std::fmt::Display;
use std::sync::OnceLock;
static PROVIDER_MODELS_YAML: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/src/bin/provider_models.yaml"
));
#[derive(Deserialize)]
struct ProviderModelsFile {
providers: HashMap<String, Vec<String>>,
}
fn load_provider_models() -> &'static HashMap<String, Vec<String>> {
static MODELS: OnceLock<HashMap<String, Vec<String>>> = OnceLock::new();
MODELS.get_or_init(|| {
let ProviderModelsFile { providers } = serde_yaml::from_str(PROVIDER_MODELS_YAML)
.expect("Failed to parse provider_models.yaml");
providers
})
}
/// Provider identifier enum - simple enum for identifying providers
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@ -23,31 +45,70 @@ pub enum ProviderId {
AmazonBedrock,
}
impl From<&str> for ProviderId {
fn from(value: &str) -> Self {
impl TryFrom<&str> for ProviderId {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_lowercase().as_str() {
"openai" => ProviderId::OpenAI,
"mistral" => ProviderId::Mistral,
"deepseek" => ProviderId::Deepseek,
"groq" => ProviderId::Groq,
"gemini" => ProviderId::Gemini,
"anthropic" => ProviderId::Anthropic,
"github" => ProviderId::GitHub,
"arch" => ProviderId::Arch,
"azure_openai" => ProviderId::AzureOpenAI,
"xai" => ProviderId::XAI,
"together_ai" => ProviderId::TogetherAI,
"ollama" => ProviderId::Ollama,
"moonshotai" => ProviderId::Moonshotai,
"zhipu" => ProviderId::Zhipu,
"qwen" => ProviderId::Qwen, // alias for Qwen
"amazon_bedrock" => ProviderId::AmazonBedrock,
_ => panic!("Unknown provider: {}", value),
"openai" => Ok(ProviderId::OpenAI),
"mistral" => Ok(ProviderId::Mistral),
"deepseek" => Ok(ProviderId::Deepseek),
"groq" => Ok(ProviderId::Groq),
"gemini" => Ok(ProviderId::Gemini),
"google" => Ok(ProviderId::Gemini), // alias
"anthropic" => Ok(ProviderId::Anthropic),
"github" => Ok(ProviderId::GitHub),
"arch" => Ok(ProviderId::Arch),
"azure_openai" => Ok(ProviderId::AzureOpenAI),
"xai" => Ok(ProviderId::XAI),
"together_ai" => Ok(ProviderId::TogetherAI),
"together" => Ok(ProviderId::TogetherAI), // alias
"ollama" => Ok(ProviderId::Ollama),
"moonshotai" => Ok(ProviderId::Moonshotai),
"zhipu" => Ok(ProviderId::Zhipu),
"qwen" => Ok(ProviderId::Qwen),
"amazon_bedrock" => Ok(ProviderId::AmazonBedrock),
"amazon" => Ok(ProviderId::AmazonBedrock), // alias
_ => Err(format!("Unknown provider: {}", value)),
}
}
}
impl ProviderId {
/// Get all available models for this provider
/// Returns model names without the provider prefix (e.g., "gpt-4" not "openai/gpt-4")
pub fn models(&self) -> Vec<String> {
let provider_key = match self {
ProviderId::AmazonBedrock => "amazon",
ProviderId::AzureOpenAI => "openai",
ProviderId::TogetherAI => "together",
ProviderId::Gemini => "google",
ProviderId::OpenAI => "openai",
ProviderId::Anthropic => "anthropic",
ProviderId::Mistral => "mistralai",
ProviderId::Deepseek => "deepseek",
ProviderId::Groq => "groq",
ProviderId::XAI => "x-ai",
ProviderId::Moonshotai => "moonshotai",
ProviderId::Zhipu => "z-ai",
ProviderId::Qwen => "qwen",
_ => return Vec::new(),
};
load_provider_models()
.get(provider_key)
.map(|models| {
models
.iter()
.filter_map(|model| {
// Strip provider prefix (e.g., "openai/gpt-4" -> "gpt-4")
model.split_once('/').map(|(_, name)| name.to_string())
})
.collect()
})
.unwrap_or_default()
}
/// Given a client API, return the compatible upstream API for this provider
pub fn compatible_api_for_client(
&self,
@ -169,3 +230,102 @@ impl Display for ProviderId {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_models_loaded_from_yaml() {
// Test that we can load models for each supported provider
let openai_models = ProviderId::OpenAI.models();
assert!(!openai_models.is_empty(), "OpenAI should have models");
let anthropic_models = ProviderId::Anthropic.models();
assert!(!anthropic_models.is_empty(), "Anthropic should have models");
let mistral_models = ProviderId::Mistral.models();
assert!(!mistral_models.is_empty(), "Mistral should have models");
let deepseek_models = ProviderId::Deepseek.models();
assert!(!deepseek_models.is_empty(), "Deepseek should have models");
let gemini_models = ProviderId::Gemini.models();
assert!(!gemini_models.is_empty(), "Gemini should have models");
}
#[test]
fn test_model_names_without_provider_prefix() {
// Test that model names don't include the provider/ prefix
let openai_models = ProviderId::OpenAI.models();
for model in &openai_models {
assert!(
!model.contains('/'),
"Model name '{}' should not contain provider prefix",
model
);
}
let anthropic_models = ProviderId::Anthropic.models();
for model in &anthropic_models {
assert!(
!model.contains('/'),
"Model name '{}' should not contain provider prefix",
model
);
}
}
#[test]
fn test_specific_models_exist() {
// Test that specific well-known models are present
let openai_models = ProviderId::OpenAI.models();
let has_gpt4 = openai_models.iter().any(|m| m.contains("gpt-4"));
assert!(has_gpt4, "OpenAI models should include GPT-4 variants");
let anthropic_models = ProviderId::Anthropic.models();
let has_claude = anthropic_models.iter().any(|m| m.contains("claude"));
assert!(
has_claude,
"Anthropic models should include Claude variants"
);
}
#[test]
fn test_unsupported_providers_return_empty() {
// Providers without models should return empty vec
let github_models = ProviderId::GitHub.models();
assert!(
github_models.is_empty(),
"GitHub should return empty models list"
);
let ollama_models = ProviderId::Ollama.models();
assert!(
ollama_models.is_empty(),
"Ollama should return empty models list"
);
}
#[test]
fn test_provider_name_mapping() {
// Test that provider key mappings work correctly
let xai_models = ProviderId::XAI.models();
assert!(
!xai_models.is_empty(),
"XAI should have models (mapped to x-ai)"
);
let zhipu_models = ProviderId::Zhipu.models();
assert!(
!zhipu_models.is_empty(),
"Zhipu should have models (mapped to z-ai)"
);
let amazon_models = ProviderId::AmazonBedrock.models();
assert!(
!amazon_models.is_empty(),
"AmazonBedrock should have models (mapped to amazon)"
);
}
}

View file

@ -327,8 +327,7 @@ impl TryFrom<(SseEvent, &SupportedAPIsFromClient, &SupportedUpstreamAPIs)> for S
}
// If has data, parse the data as a provider stream response (business logic layer)
if transformed_event.data.is_some() {
let data_str = transformed_event.data.as_ref().unwrap();
if let Some(data_str) = &transformed_event.data {
let data_bytes = data_str.as_bytes();
let transformed_response: ProviderStreamResponseType =
ProviderStreamResponseType::try_from((data_bytes, client_api, upstream_api))?;

View file

@ -1,11 +1,12 @@
use hermesllm::clients::endpoints::SupportedUpstreamAPIs;
use http::StatusCode;
use log::{debug, info, warn};
use log::{debug, error, info, warn};
use proxy_wasm::hostcalls::get_current_time;
use proxy_wasm::traits::*;
use proxy_wasm::types::*;
use std::num::NonZero;
use std::rc::Rc;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::metrics::Metrics;
@ -40,7 +41,7 @@ pub struct StreamContext {
/// The API that should be used for the upstream provider (after compatibility mapping)
resolved_api: Option<SupportedUpstreamAPIs>,
llm_providers: Rc<LlmProviders>,
llm_provider: Option<Rc<LlmProvider>>,
llm_provider: Option<Arc<LlmProvider>>,
request_id: Option<String>,
start_time: SystemTime,
ttft_duration: Option<Duration>,
@ -128,16 +129,40 @@ impl StreamContext {
}
}
fn select_llm_provider(&mut self) {
fn select_llm_provider(&mut self) -> Result<(), String> {
let provider_hint = self
.get_http_request_header(ARCH_PROVIDER_HINT_HEADER)
.map(|llm_name| llm_name.into());
// info!("llm_providers: {:?}", self.llm_providers);
self.llm_provider = Some(routing::get_llm_provider(
&self.llm_providers,
provider_hint,
));
// Try to get provider with hint, fallback to default if error
// This handles prompt_gateway requests which don't set ARCH_PROVIDER_HINT_HEADER
// since prompt_gateway doesn't have access to model configuration.
// brightstaff (model proxy) always validates and sets the provider hint.
let provider = match routing::get_llm_provider(&self.llm_providers, provider_hint) {
Ok(provider) => provider,
Err(err) => {
// Try default provider as fallback
match self.llm_providers.default() {
Some(default_provider) => {
info!(
"[PLANO_REQ_ID:{}] Provider selection failed, using default provider",
self.request_identifier()
);
default_provider
}
None => {
error!(
"[PLANO_REQ_ID:{}] PROVIDER_SELECTION_FAILED: Error='{}' and no default provider configured",
self.request_identifier(),
err
);
return Err(err);
}
}
}
};
self.llm_provider = Some(provider);
info!(
"[PLANO_REQ_ID:{}] PROVIDER_SELECTION: Hint='{}' -> Selected='{}'",
@ -146,6 +171,8 @@ impl StreamContext {
.unwrap_or("none".to_string()),
self.llm_provider.as_ref().unwrap().name
);
Ok(())
}
fn modify_auth_headers(&mut self) -> Result<(), ServerError> {
@ -764,7 +791,15 @@ impl HttpContext for StreamContext {
// let routing_header_value = self.get_http_request_header(ARCH_ROUTING_HEADER);
self.select_llm_provider();
if let Err(err) = self.select_llm_provider() {
self.send_http_response(
400,
vec![],
Some(format!(r#"{{"error": "{}"}}"#, err).as_bytes()),
);
return Action::Continue;
}
// Check if this is a supported API endpoint
if SupportedAPIsFromClient::from_endpoint(&request_path).is_none() {
self.send_http_response(404, vec![], Some(b"Unsupported endpoint"));

View file

@ -216,12 +216,12 @@ impl HttpContext for StreamContext {
("x-envoy-upstream-rq-timeout-ms", timeout_str.as_str()),
];
if self.request_id.is_some() {
headers.push((REQUEST_ID_HEADER, self.request_id.as_ref().unwrap()));
if let Some(request_id) = &self.request_id {
headers.push((REQUEST_ID_HEADER, request_id));
}
if self.traceparent.is_some() {
headers.push((TRACE_PARENT_HEADER, self.traceparent.as_ref().unwrap()));
if let Some(traceparent) = &self.traceparent {
headers.push((TRACE_PARENT_HEADER, traceparent));
}
let call_args = CallArgs::new(

View file

@ -183,8 +183,8 @@ impl StreamContext {
("x-envoy-upstream-rq-timeout-ms", timeout_str.as_str()),
];
if self.request_id.is_some() {
headers.push((REQUEST_ID_HEADER, self.request_id.as_ref().unwrap()));
if let Some(request_id) = &self.request_id {
headers.push((REQUEST_ID_HEADER, request_id));
}
let call_args = CallArgs::new(
@ -437,12 +437,12 @@ impl StreamContext {
.into_iter()
.collect();
if self.request_id.is_some() {
headers.insert(REQUEST_ID_HEADER, self.request_id.as_ref().unwrap());
if let Some(request_id) = &self.request_id {
headers.insert(REQUEST_ID_HEADER, request_id);
}
if self.traceparent.is_some() {
headers.insert(TRACE_PARENT_HEADER, self.traceparent.as_ref().unwrap());
if let Some(traceparent) = &self.traceparent {
headers.insert(TRACE_PARENT_HEADER, traceparent);
}
// override http headers that are set in the prompt target
@ -648,7 +648,15 @@ impl StreamContext {
}
pub fn generate_tool_call_message(&mut self) -> Message {
if self.arch_fc_response.is_none() {
if let Some(arch_fc_response) = &self.arch_fc_response {
Message {
role: ASSISTANT_ROLE.to_string(),
content: Some(ContentType::Text(arch_fc_response.clone())),
model: Some(ARCH_FC_MODEL_NAME.to_string()),
tool_calls: None,
tool_call_id: None,
}
} else {
info!("arch_fc_response is none, generating tool call message");
Message {
role: ASSISTANT_ROLE.to_string(),
@ -657,16 +665,6 @@ impl StreamContext {
tool_calls: self.tool_calls.clone(),
tool_call_id: None,
}
} else {
Message {
role: ASSISTANT_ROLE.to_string(),
content: Some(ContentType::Text(
self.arch_fc_response.as_ref().unwrap().clone(),
)),
model: Some(ARCH_FC_MODEL_NAME.to_string()),
tool_calls: None,
tool_call_id: None,
}
}
}

View file

@ -18,8 +18,8 @@ start_demo() {
echo ".env file created with OPENAI_API_KEY."
fi
# Step 3: Start Arch
echo "Starting Arch with config.yaml..."
# Step 3: Start Plano
echo "Starting Plano with config.yaml..."
planoai up config.yaml
# Step 4: Start developer services
@ -33,8 +33,8 @@ stop_demo() {
echo "Stopping Network Agent using Docker Compose..."
docker compose down
# Step 2: Stop Arch
echo "Stopping Arch..."
# Step 2: Stop Plano
echo "Stopping Plano..."
planoai down
}

View file

@ -8,7 +8,7 @@ Content-Type: application/json
"content": "convert 100 eur"
}
],
"model": "none"
"model": "gpt-4o"
}
HTTP 200
[Asserts]

View file

@ -9,7 +9,7 @@ Content-Type: application/json
}
],
"stream": true,
"model": "none"
"model": "gpt-4o"
}
HTTP 200
[Asserts]

View file

@ -18,8 +18,8 @@ start_demo() {
echo ".env file created with OPENAI_API_KEY."
fi
# Step 3: Start Arch
echo "Starting Arch with config.yaml..."
# Step 3: Start Plano
echo "Starting Plano with config.yaml..."
planoai up config.yaml
# Step 4: Start developer services
@ -33,8 +33,8 @@ stop_demo() {
echo "Stopping Network Agent using Docker Compose..."
docker compose down
# Step 2: Stop Arch
echo "Stopping Arch..."
# Step 2: Stop Plano
echo "Stopping Plano..."
planoai down
}

View file

@ -30,7 +30,7 @@ Start arch gateway,
```
$ planoai up config.yaml
# Or if installed with uv: uvx planoai up config.yaml
2024-12-05 11:24:51,288 - planoai.main - INFO - Starting plano cli version: 0.4.3
2024-12-05 11:24:51,288 - planoai.main - INFO - Starting plano cli version: 0.4.4
2024-12-05 11:24:51,825 - planoai.utils - INFO - Schema validation successful!
2024-12-05 11:24:51,825 - planoai.main - INFO - Starting arch model server and arch gateway
...
@ -67,7 +67,7 @@ print("OpenAI Response:", response.choices[0].message.content)
#### Step 3.2: Using curl command
```
$ curl --header 'Content-Type: application/json' \
--data '{"messages": [{"role": "user","content": "What is the capital of France?"}], "model": "none"}' \
--data '{"messages": [{"role": "user","content": "What is the capital of France?"}], "model": "gpt-4o"}' \
http://localhost:12000/v1/chat/completions
{
@ -92,7 +92,7 @@ You can override model selection using `x-arch-llm-provider-hint` header. For ex
```
$ curl --header 'Content-Type: application/json' \
--header 'x-arch-llm-provider-hint: ministral-3b' \
--data '{"messages": [{"role": "user","content": "What is the capital of France?"}], "model": "none"}' \
--data '{"messages": [{"role": "user","content": "What is the capital of France?"}], "model": "gpt-4o"}' \
http://localhost:12000/v1/chat/completions
{
...

View file

@ -19,7 +19,7 @@ You can also pass in a header to override model when sending prompt. Following e
$ curl --header 'Content-Type: application/json' \
--header 'x-arch-llm-provider-hint: mistral/ministral-3b' \
--data '{"messages": [{"role": "user","content": "hello"}], "model": "none"}' \
--data '{"messages": [{"role": "user","content": "hello"}], "model": "gpt-4o"}' \
http://localhost:12000/v1/chat/completions 2> /dev/null | jq .
{
"id": "xxx",

View file

@ -5,6 +5,7 @@ listeners:
name: model_1
address: 0.0.0.0
port: 12000
max_retries: 3
model_providers:

View file

@ -23,7 +23,13 @@ llm_providers:
- model: openai/gpt-4o
access_key: $OPENAI_API_KEY
# Anthropic Models
- model: openai/*
access_key: $OPENAI_API_KEY
# Anthropic - support all Claude models
- model: anthropic/*
access_key: $ANTHROPIC_API_KEY
- model: anthropic/claude-sonnet-4-20250514
access_key: $ANTHROPIC_API_KEY

View file

@ -15,9 +15,9 @@ Make sure your machine is up to date with [latest version of plano]([url](https:
```bash
(venv) $ planoai up --service plano --foreground
# Or if installed with uv: uvx planoai up --service plano --foreground
2025-05-30 18:00:09,953 - planoai.main - INFO - Starting plano cli version: 0.4.3
2025-05-30 18:00:09,953 - planoai.main - INFO - Starting plano cli version: 0.4.4
2025-05-30 18:00:09,953 - planoai.main - INFO - Validating /Users/adilhafeez/src/intelligent-prompt-gateway/demos/use_cases/preference_based_routing/config.yaml
2025-05-30 18:00:10,422 - cli.core - INFO - Starting arch gateway, image name: plano, tag: katanemo/plano:0.4.3
2025-05-30 18:00:10,422 - cli.core - INFO - Starting arch gateway, image name: plano, tag: katanemo/plano:0.4.4
2025-05-30 18:00:10,662 - cli.core - INFO - plano status: running, health status: starting
2025-05-30 18:00:11,712 - cli.core - INFO - plano status: running, health status: starting
2025-05-30 18:00:12,761 - cli.core - INFO - plano is running and is healthy!

View file

@ -5,13 +5,13 @@ Content-Type: application/json
"messages": [
{
"role": "user",
"content": "hi"
"content": "Can you explain what this Python function does?\n\ndef fibonacci(n):\n if n <= 1:\n return n\n return fibonacci(n-1) + fibonacci(n-2)"
}
],
"model": "none",
"model": "openai/gpt-4o-mini",
"stream": true
}
HTTP 200
[Asserts]
header "content-type" matches /text\/event-stream/
body matches /^data: .*?gpt-4o-mini.*?\n/
body matches /^data: .*?gpt-4o.*?\n/

View file

@ -34,7 +34,7 @@ POST http://localhost:12000/v1/chat/completions HTTP/1.1
Content-Type: application/json
{
"model": "none",
"model": "gpt-4o",
"messages": [
{
"role": "user",
@ -49,7 +49,7 @@ POST http://localhost:12000/v1/chat/completions HTTP/1.1
Content-Type: application/json
{
"model": "none",
"model": "gpt-4o",
"messages": [
{
"role": "user",

View file

@ -1,6 +1,9 @@
FROM sphinxdoc/sphinx
WORKDIR /docs
ADD requirements.txt /docs
ADD docs/requirements.txt /docs
RUN python3 -m pip install -r requirements.txt
RUN pip freeze
# Copy provider_models.yaml from the repo for documentation
COPY crates/hermesllm/src/bin/provider_models.yaml /docs/provider_models.yaml

View file

@ -1,4 +1,21 @@
docker build -f Dockerfile . -t sphinx
docker run --user $(id -u):$(id -g) --rm -v $(pwd):/docs sphinx make clean
docker run --user $(id -u):$(id -g) --rm -v $(pwd):/docs sphinx make html
chmod -R 777 build/html
docker build -f docs/Dockerfile . -t sphinx
# Clean build output locally
rm -rf docs/build
mkdir -p docs/build
chmod -R 777 docs/build
# Run make clean/html while keeping provider_models.yaml from the image
docker run --user $(id -u):$(id -g) --rm \
-v $(pwd)/docs/source:/docs/source \
-v $(pwd)/docs/Makefile:/docs/Makefile \
-v $(pwd)/docs/build:/docs/build \
sphinx make clean
docker run --user $(id -u):$(id -g) --rm \
-v $(pwd)/docs/source:/docs/source \
-v $(pwd)/docs/Makefile:/docs/Makefile \
-v $(pwd)/docs/build:/docs/build \
sphinx make html
chmod -R 777 docs/build/html

View file

@ -0,0 +1,44 @@
"""Sphinx extension to copy provider_models.yaml to build output."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
import shutil
if TYPE_CHECKING:
from sphinx.application import Sphinx
def _on_build_finished(app: Sphinx, exception: Exception | None) -> None:
"""Copy provider_models.yaml to the build output after build completes."""
if exception is not None:
return
# Only generate for HTML-like builders where app.outdir is a website root.
if getattr(app.builder, "format", None) != "html":
return
# Source path: provider_models.yaml is copied into the Docker image at /docs/provider_models.yaml
# This follows the pattern used for config templates like envoy.template.yaml and arch_config_schema.yaml
docs_root = Path(app.srcdir).parent # Goes from source/ to docs/
source_path = docs_root / "provider_models.yaml"
if not source_path.exists():
# Silently skip if source file doesn't exist
return
# Per repo convention, place generated artifacts under an `includes/` folder.
out_path = Path(app.outdir) / "includes" / "provider_models.yaml"
out_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_path, out_path)
def setup(app: Sphinx) -> dict[str, object]:
"""Register the extension with Sphinx."""
app.connect("build-finished", _on_build_finished)
return {
"version": "0.1.0",
"parallel_read_safe": True,
"parallel_write_safe": True,
}

View file

@ -20,6 +20,7 @@ Connect to any combination of providers simultaneously (see :ref:`supported_prov
- First-Class Providers: Native integrations with OpenAI, Anthropic, DeepSeek, Mistral, Groq, Google Gemini, Together AI, xAI, Azure OpenAI, and Ollama
- OpenAI-Compatible Providers: Any provider implementing the OpenAI Chat Completions API standard
- Wildcard Model Configuration: Automatically configure all models from a provider using ``provider/*`` syntax
**Intelligent Routing**
Three powerful routing approaches to optimize model selection:

View file

@ -26,7 +26,7 @@ All providers are configured in the ``llm_providers`` section of your ``plano_co
**Common Configuration Fields:**
- ``model``: Provider prefix and model name (format: ``provider/model-name``)
- ``model``: Provider prefix and model name (format: ``provider/model-name`` or ``provider/*`` for wildcard expansion)
- ``access_key``: API key for authentication (supports environment variables)
- ``default``: Mark a model as the default (optional, boolean)
- ``name``: Custom name for the provider instance (optional)
@ -108,7 +108,11 @@ OpenAI
.. code-block:: yaml
llm_providers:
# Latest models (examples - use any OpenAI chat model)
# Configure all OpenAI models with wildcard
- model: openai/*
access_key: $OPENAI_API_KEY
# Or configure specific models
- model: openai/gpt-5.2
access_key: $OPENAI_API_KEY
default: true
@ -116,7 +120,6 @@ OpenAI
- model: openai/gpt-5
access_key: $OPENAI_API_KEY
# Use any model name from OpenAI's API
- model: openai/gpt-4o
access_key: $OPENAI_API_KEY
@ -156,17 +159,29 @@ Anthropic
.. code-block:: yaml
llm_providers:
# Latest models (examples - use any Anthropic chat model)
# Configure all Anthropic models with wildcard
- model: anthropic/*
access_key: $ANTHROPIC_API_KEY
# Or configure specific models
- model: anthropic/claude-opus-4-5
access_key: $ANTHROPIC_API_KEY
- model: anthropic/claude-sonnet-4-5
access_key: $ANTHROPIC_API_KEY
# Use any model name from Anthropic's API
- model: anthropic/claude-haiku-4-5
access_key: $ANTHROPIC_API_KEY
# Override specific model with custom routing
- model: anthropic/*
access_key: $ANTHROPIC_API_KEY
- model: anthropic/claude-sonnet-4-20250514
access_key: $ANTHROPIC_PROD_API_KEY
routing_preferences:
- name: code_generation
DeepSeek
~~~~~~~~
@ -694,6 +709,93 @@ Configure multiple instances of the same provider:
access_key: $OPENAI_DEV_KEY
name: openai-dev
Wildcard Model Configuration
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Automatically configure all available models from a provider using wildcard patterns. Plano expands wildcards at configuration load time to include all known models from the provider's registry.
**Basic Wildcard Usage:**
.. code-block:: yaml
llm_providers:
# Expand to all OpenAI models
- model: openai/*
access_key: $OPENAI_API_KEY
# Expand to all Anthropic Claude models
- model: anthropic/*
access_key: $ANTHROPIC_API_KEY
# Expand to all Mistral models
- model: mistral/*
access_key: $MISTRAL_API_KEY
**How Wildcards Work:**
1. **Known Providers** (OpenAI, Anthropic, DeepSeek, Mistral, Groq, Gemini, Together AI, xAI, Moonshot, Zhipu):
- Expands at config load time to all models in Plano's provider registry
- Creates entries for both canonical (``openai/gpt-4``) and short names (``gpt-4``)
- Enables the ``/models/list`` endpoint to list all available models
- **View complete model list**: `provider_models.yaml <../../includes/provider_models.yaml>`_
2. **Unknown/Custom Providers** (e.g., ``custom-provider/*``):
- Stores as a wildcard pattern for runtime matching
- Requires ``base_url`` and ``provider_interface`` configuration
- Matches model requests dynamically (e.g., ``custom-provider/any-model-name``)
- Does not appear in ``/models/list`` endpoint
**Overriding Wildcard Models:**
You can configure specific models with custom settings even when using wildcards. Specific configurations take precedence and are excluded from wildcard expansion:
.. code-block:: yaml
llm_providers:
# Expand to all Anthropic models
- model: anthropic/*
access_key: $ANTHROPIC_API_KEY
# Override specific model with custom settings
# This model will NOT be included in the wildcard expansion above
- model: anthropic/claude-sonnet-4-20250514
access_key: $ANTHROPIC_PROD_API_KEY
routing_preferences:
- name: code_generation
priority: 1
# Another specific override
- model: anthropic/claude-3-haiku-20240307
access_key: $ANTHROPIC_DEV_API_KEY
**Custom Provider Wildcards:**
For providers not in Plano's registry, wildcards enable dynamic model routing:
.. code-block:: yaml
llm_providers:
# Custom LiteLLM deployment
- model: litellm/*
base_url: https://litellm.example.com
provider_interface: openai
passthrough_auth: true
# Custom provider with all models
- model: custom-provider/*
access_key: $CUSTOM_API_KEY
base_url: https://api.custom-provider.com
provider_interface: openai
**Benefits:**
- **Simplified Configuration**: One line instead of listing dozens of models
- **Future-Proof**: Automatically includes new models as they're released
- **Flexible Overrides**: Customize specific models while using wildcards for others
- **Selective Expansion**: Control which models get custom configurations
Default Model Configuration
~~~~~~~~~~~~~~~~~~~~~~~~~~~

View file

@ -17,7 +17,7 @@ from sphinxawesome_theme.postprocess import Icons
project = "Plano Docs"
copyright = "2025, Katanemo Labs, Inc"
author = "Katanemo Labs, Inc"
release = " v0.4.3"
release = " v0.4.4"
# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
@ -38,6 +38,7 @@ extensions = [
"sphinx_design",
# Local extensions
"llms_txt",
"provider_models",
]
# Paths that contain templates, relative to this directory.

View file

@ -37,7 +37,7 @@ Plano's CLI allows you to manage and interact with the Plano efficiently. To ins
.. code-block:: console
$ uv tool install planoai==0.4.3
$ uv tool install planoai==0.4.4
**Option 2: Install with pip (Traditional)**
@ -45,7 +45,7 @@ Plano's CLI allows you to manage and interact with the Plano efficiently. To ins
$ python -m venv venv
$ source venv/bin/activate # On Windows, use: venv\Scripts\activate
$ pip install planoai==0.4.3
$ pip install planoai==0.4.4
.. _llm_routing_quickstart:
@ -90,7 +90,7 @@ Start Plano:
$ planoai up plano_config.yaml
# Or if installed with uv tool: uvx planoai up plano_config.yaml
2024-12-05 11:24:51,288 - planoai.main - INFO - Starting plano cli version: 0.4.3
2024-12-05 11:24:51,288 - planoai.main - INFO - Starting plano cli version: 0.4.4
2024-12-05 11:24:51,825 - planoai.utils - INFO - Schema validation successful!
2024-12-05 11:24:51,825 - planoai.main - INFO - Starting plano
...
@ -105,7 +105,7 @@ Step 3.1: Using curl command
.. code-block:: bash
$ curl --header 'Content-Type: application/json' \
--data '{"messages": [{"role": "user","content": "What is the capital of France?"}], "model": "none"}' \
--data '{"messages": [{"role": "user","content": "What is the capital of France?"}], "model": "gpt-4o"}' \
http://localhost:12000/v1/chat/completions
{
@ -315,7 +315,7 @@ Here is a sample curl command you can use to interact:
.. code-block:: bash
$ curl --header 'Content-Type: application/json' \
--data '{"messages": [{"role": "user","content": "what is exchange rate for gbp"}], "model": "none"}' \
--data '{"messages": [{"role": "user","content": "what is exchange rate for gbp"}], "model": "gpt-4o"}' \
http://localhost:10000/v1/chat/completions | jq ".choices[0].message.content"
"As of the date provided in your context, December 5, 2024, the exchange rate for GBP (British Pound) from USD (United States Dollar) is 0.78558. This means that 1 USD is equivalent to 0.78558 GBP."
@ -325,7 +325,7 @@ And to get the list of supported currencies:
.. code-block:: bash
$ curl --header 'Content-Type: application/json' \
--data '{"messages": [{"role": "user","content": "show me list of currencies that are supported for conversion"}], "model": "none"}' \
--data '{"messages": [{"role": "user","content": "show me list of currencies that are supported for conversion"}], "model": "gpt-4o"}' \
http://localhost:10000/v1/chat/completions | jq ".choices[0].message.content"
"Here is a list of the currencies that are supported for conversion from USD, along with their symbols:\n\n1. AUD - Australian Dollar\n2. BGN - Bulgarian Lev\n3. BRL - Brazilian Real\n4. CAD - Canadian Dollar\n5. CHF - Swiss Franc\n6. CNY - Chinese Renminbi Yuan\n7. CZK - Czech Koruna\n8. DKK - Danish Krone\n9. EUR - Euro\n10. GBP - British Pound\n11. HKD - Hong Kong Dollar\n12. HUF - Hungarian Forint\n13. IDR - Indonesian Rupiah\n14. ILS - Israeli New Sheqel\n15. INR - Indian Rupee\n16. ISK - Icelandic Króna\n17. JPY - Japanese Yen\n18. KRW - South Korean Won\n19. MXN - Mexican Peso\n20. MYR - Malaysian Ringgit\n21. NOK - Norwegian Krone\n22. NZD - New Zealand Dollar\n23. PHP - Philippine Peso\n24. PLN - Polish Złoty\n25. RON - Romanian Leu\n26. SEK - Swedish Krona\n27. SGD - Singapore Dollar\n28. THB - Thai Baht\n29. TRY - Turkish Lira\n30. USD - United States Dollar\n31. ZAR - South African Rand\n\nIf you want to convert USD to any of these currencies, you can select the one you are interested in."

View file

@ -25,7 +25,7 @@ Create a ``docker-compose.yml`` file with the following configuration:
# docker-compose.yml
services:
plano:
image: katanemo/plano:0.4.3
image: katanemo/plano:0.4.4
container_name: plano
ports:
- "10000:10000" # ingress (client -> plano)

View file

@ -37,14 +37,6 @@ listeners:
port: 8001
router: plano_orchestrator_v1
type: agent
- address: 0.0.0.0
name: model_1
port: 12000
type: model
- address: 0.0.0.0
name: prompt_function_listener
port: 10000
type: prompt
- address: 0.0.0.0
model_providers:
- access_key: $OPENAI_API_KEY
@ -73,10 +65,13 @@ listeners:
port: 443
protocol: https
provider_interface: openai
name: egress_traffic
name: model_1
port: 12000
timeout: 30s
type: model_listener
type: model
- address: 0.0.0.0
name: prompt_function_listener
port: 10000
type: prompt
model_aliases:
fast-llm:
target: gpt-4o-mini

2541
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -22,7 +22,7 @@
},
"packageManager": "npm@10.0.0",
"dependencies": {
"next": "^16.1.1",
"next": "^16.1.6",
"react": "^19.2.3",
"resend": "^6.6.0"
}

View file

@ -34,9 +34,9 @@
"clsx": "^2.1.1",
"framer-motion": "^12.23.24",
"lucide-react": "^0.548.0",
"next": "16.0.10",
"react": "19.2.0",
"react-dom": "19.2.0",
"next": "^16.1.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
@ -47,7 +47,6 @@
"typescript": "^5"
},
"peerDependencies": {
"next": "^16.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
}

View file

@ -109,7 +109,7 @@ def test_openai_responses_api_non_streaming_with_tools_passthrough():
]
resp = client.responses.create(
model="gpt-5",
model="openai/gpt-5-mini-2025-08-07",
input="Call the echo tool",
tools=tools,
)
@ -140,7 +140,7 @@ def test_openai_responses_api_with_streaming_with_tools_passthrough():
]
stream = client.responses.create(
model="gpt-5",
model="openai/gpt-5-mini-2025-08-07",
input="Call the echo tool",
tools=tools,
stream=True,
@ -638,7 +638,7 @@ def test_openai_responses_api_mixed_content_types():
# This test mimics the request that was failing:
# One message with string content, another with array content
resp = client.responses.create(
model="arch.title.v1",
model="openai/gpt-5-mini-2025-08-07",
input=[
{
"role": "developer",

View file

@ -60,6 +60,7 @@ def test_prompt_gateway(stream):
"content": "how is the weather in seattle for next 10 days",
}
],
"model": "openai/gpt-4o",
"stream": stream,
}
response = requests.post(PROMPT_GATEWAY_ENDPOINT, json=body, stream=stream)
@ -147,6 +148,7 @@ def test_prompt_gateway_arch_direct_response(stream):
"content": "how is the weather",
}
],
"model": "openai/gpt-4o",
"stream": stream,
}
response = requests.post(PROMPT_GATEWAY_ENDPOINT, json=body, stream=stream)
@ -189,6 +191,7 @@ def test_prompt_gateway_param_gathering(stream):
"content": "how is the weather in seattle",
}
],
"model": "openai/gpt-4o",
"stream": stream,
}
response = requests.post(PROMPT_GATEWAY_ENDPOINT, json=body, stream=stream)
@ -245,6 +248,7 @@ def test_prompt_gateway_param_tool_call(stream):
"content": "for 2 days please",
},
],
"model": "openai/gpt-4o",
"stream": stream,
}
response = requests.post(PROMPT_GATEWAY_ENDPOINT, json=body, stream=stream)
@ -311,6 +315,7 @@ def test_prompt_gateway_default_target(stream):
"content": "hello",
},
],
"model": "openai/gpt-4o",
"stream": stream,
}
response = requests.post(PROMPT_GATEWAY_ENDPOINT, json=body, stream=stream)
@ -354,6 +359,7 @@ def test_prompt_gateway_prompt_guard_jailbreak(stream):
"content": "How would you respond to a question about generating harmful or unethical content",
}
],
"model": "openai/gpt-4o",
"stream": stream,
}
response = requests.post(PROMPT_GATEWAY_ENDPOINT, json=body, stream=stream)
@ -439,7 +445,7 @@ def test_anthropic_client_with_openai_model_streaming():
client = anthropic.Anthropic(api_key="test-key", base_url=base_url)
with client.messages.stream(
model="gpt-5-mini-2025-08-07", # OpenAI model via Anthropic client
model="gpt-4o-mini", # OpenAI model via Anthropic client
max_tokens=500,
messages=[
{

View file

@ -107,7 +107,7 @@ Content-Type: application/json
{
"stream": true,
"model": "None",
"model": "gpt-4o",
"messages": [
{
"role": "user",

Some files were not shown because too many files have changed in this diff Show more