Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
c37532b
feat(sdk): add tingly Python module + /sdk/session gateway seam
claude Jun 15, 2026
8c40291
docs(sdk): add end-to-end pencil graph to python-sdk.md
claude Jun 15, 2026
e614684
docs(sdk): document Layer 3 plugin-as-upstream with pencil graph
claude Jun 15, 2026
b1a98d0
feat(sdk): Layer 2 — tingly.Plugin AI server + manifest + tb registra…
claude Jun 15, 2026
b7cdfab
docs(sdk): add Layer 2 plugin lifecycle pencil graph
claude Jun 15, 2026
08509b6
refactor(sdk): reframe arch as rule⇄plugin hub; add Plugin.use(scenario)
claude Jun 15, 2026
f7276e4
feat(plugins): first-class plugin provider kind + one-step registration
claude Jun 15, 2026
d244ebf
fix(sdk): probe unauthenticated /info/health; add full-hub e2e example
claude Jun 15, 2026
461c472
chore(sdk): make openai/anthropic core deps (fine-grained control is …
claude Jun 15, 2026
c58d1fd
feat(plugins): dynamic ephemeral registration + active SDK configuration
claude Jun 15, 2026
b9d5730
refactor: simplify plugin SDK + gateway changes (no behavior change)
claude Jun 15, 2026
86e52fb
refactor(plugins): collapse to ephemeral-only; drop persistent path
claude Jun 15, 2026
dc75813
test(e2e): demonstrate ephemeral plugin lifecycle (lease expiry on cr…
claude Jun 16, 2026
849ce5f
fix: resolve rebase-onto-main fixups (tactic API, route group, markers)
claude Jul 15, 2026
a55febe
refactor(plugins): drop ephemeral registry, plugin = tagged provider …
claude Jul 15, 2026
5d272b4
refactor(plugins): extract into internal/server/module/plugin
claude Jul 15, 2026
2d39822
docs(sdk): flag Plugin naming collision, reprioritize follow-ups
claude Jul 23, 2026
5bb80e6
feat(sdk): narrow MVP to connect+send+plugin round-trip, Anthropic pr…
claude Jul 23, 2026
750e764
feat(sdk): add critic and fusion showcase plugins
claude Jul 23, 2026
f386ac1
feat(sdk): add Client.quota view and a quota-aware router plugin
claude Jul 23, 2026
59ba517
feat: add X-Tingly-Pin-Provider for deterministic dispatch, close rou…
claude Jul 23, 2026
e579af3
test: add fixed e2e regression script for the two connection modes; f…
claude Jul 23, 2026
9c13107
chore: regenerate openapi.json after rebase onto main
claude Jul 23, 2026
c68df68
docs(sdk): add python-sdk.pencil.md — the full request-flow pencil gr…
claude Jul 23, 2026
58a5eea
docs(sdk): simplify python-sdk.pencil.md — 4 diagrams instead of 10
claude Jul 23, 2026
f85e361
docs(sdk): record why pin_provider stays two modes, not three
claude Jul 24, 2026
e681a0b
revert: drop router_plugin.py / quota+rules views / pin_provider
claude Jul 24, 2026
c05a4b4
revert: finish dropping router_plugin.py / quota+rules / pin_provider
claude Jul 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
629 changes: 629 additions & 0 deletions .design/python-sdk.md

Large diffs are not rendered by default.

51 changes: 51 additions & 0 deletions .design/python-sdk.pencil.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Python SDK (`tingly`) — Pencil Graph

Visual companion to `python-sdk.md`. Two pictures. For exact endpoints /
field names / file:line references, that doc is the source of truth — this
page is just the shape of things.

Contents:

- The one idea
- A request, start to finish

## The one idea

```
client tingly-box real upstream
┌────────┐ model=x ┌──────────────────────┐
│ any app │────────────►│ rule x → PLUGIN CODE │
└────────┘ │ │ │
│ │ calls back:│
│ rule y ◄──┘ use(y) │
└─────┬──────────────────┘
Anthropic / OpenAI / local …
```

A plugin is just a rule whose upstream happens to be your code. It can call
*back* into any other rule to get its own answer — same gateway, same guard
rails / quota / logging, both directions.

## A request, start to finish

```
1. connect() admin token ──► mint a session ──► model token

2. tb.ask("...") model token ──► tb picks a rule ──► picks a service
real model answers

3. if that model IS a plugin:
tb calls the plugin instead of a real model (step 2, inbound)
the plugin's handler does step 2 AGAIN, on its own, to get ITS answer
the plugin's answer becomes tb's answer to the original caller
```

Steps 1 and 2 are all of Layer 1 (`Client`). Step 3 is Layer 2 (`Plugin`) —
same request, plugin just sits in the middle and calls back once.
`critic_plugin.py` (ask a different model to review), `fusion_plugin.py`
(ask several, then a judge), and `rag_plugin.py` (ask one, with retrieved
context) are all step 3 with different logic in the handler — no new
mechanism.
36 changes: 36 additions & 0 deletions ai/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ type VModelDetail struct {
LatencyProfile string `json:"latency_profile,omitempty"`
}

// PluginTag is the Provider.Tags value that marks a provider as backed by
// external plugin code. A plugin provider is otherwise an ordinary OpenAI HTTP
// upstream (APIStyle=openai, api_key / no_key) — there is NO routing change;
// reusing the existing generic Tags field means plugin identity needs no new
// persisted column. Distinct from AuthTypeVirtual (the in-process vmodel
// path): a plugin runs out-of-process and is reached over HTTP.
const PluginTag = "plugin"

// CredentialBundle holds the credential fields for multi-field auth types
// (AWS SigV4, Azure, GCP Vertex). Fields is a generic, schema-validated
// key/value map so new credential shapes can be added as data rather than new
Expand Down Expand Up @@ -253,6 +261,21 @@ func (p *Provider) IsVirtual() bool {
return p != nil && p.AuthType == AuthTypeVirtual
}

// IsPlugin reports whether this provider is backed by external plugin code
// (carries the PluginTag). Plugin providers route as ordinary OpenAI HTTP
// upstreams; this is metadata for UI grouping only.
func (p *Provider) IsPlugin() bool {
if p == nil {
return false
}
for _, tag := range p.Tags {
if tag == PluginTag {
return true
}
}
return false
}

// IsBuiltin reports whether this provider was seeded by the system and is
// therefore protected from deletion/mutation.
func (p *Provider) IsBuiltin() bool {
Expand Down Expand Up @@ -337,6 +360,16 @@ func (p *Provider) ResolveEndpoint(clientStyle APIStyle) (string, APIStyle) {
// value is never transmitted.
const VModelSentinelToken = "EMPTY"

// NoKeySentinelToken satisfies the same non-empty-APIKey check for real
// outbound HTTP providers that genuinely take no key (NoKeyRequired=true;
// e.g. a local plugin process). Unlike VModelSentinelToken this value IS
// transmitted, as an Authorization/x-api-key header the receiving side is
// expected to ignore. It exists because some client SDKs (anthropic-sdk-go)
// treat an empty API key as "look for ambient credentials" and fail loudly
// when none are found, instead of just sending an empty/absent header the
// way the OpenAI client does.
const NoKeySentinelToken = "tingly-no-key"

// GetAccessToken returns the access token based on auth type
func (p *Provider) GetAccessToken() string {
switch p.AuthType {
Expand All @@ -348,6 +381,9 @@ func (p *Provider) GetAccessToken() string {
return VModelSentinelToken
case AuthTypeAPIKey, "":
// Default to api_key for backward compatibility
if p.Token == "" && p.NoKeyRequired {
return NoKeySentinelToken
}
return p.Token
}
return ""
Expand Down
18 changes: 18 additions & 0 deletions ai/provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,24 @@ func TestProvider_GetAccessToken(t *testing.T) {
},
want: "",
},
{
name: "API key auth, no key required, empty token -> sentinel",
provider: &Provider{
AuthType: AuthTypeAPIKey,
NoKeyRequired: true,
Token: "",
},
want: NoKeySentinelToken,
},
{
name: "API key auth, no key required, but a real token is still preferred",
provider: &Provider{
AuthType: AuthTypeAPIKey,
NoKeyRequired: true,
Token: "sk-real",
},
want: "sk-real",
},
}

for _, tt := range tests {
Expand Down
19 changes: 8 additions & 11 deletions ai/quota/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,15 @@ func (m *Manager) RefreshProvider(ctx context.Context, providerUUID string) (*Pr
}

// GetQuota returns cached quota data and refreshes it when expired.
//
// A not-found store lookup returns ErrUsageNotFound UNWRAPPED — callers
// (e.g. the provider-quota HTTP handlers) compare against that sentinel
// with == to treat "no data yet" as a skip rather than an error; wrapping it
// here (as this used to do, via fmt.Errorf) silently broke that comparison
// and turned every "no quota data" provider into a hard error upstream.
func (m *Manager) GetQuota(ctx context.Context, providerUUID string) (*ProviderUsage, error) {
usage, err := m.store.Get(ctx, providerUUID)
if err != nil {
if err == ErrUsageNotFound {
return nil, fmt.Errorf("quota not found for provider: %s", providerUUID)
}
return nil, err
}

Expand All @@ -146,15 +149,9 @@ func (m *Manager) GetQuota(ctx context.Context, providerUUID string) (*ProviderU
}

// GetQuotaNoCache returns the latest quota data stored in the database.
// See GetQuota above for why ErrUsageNotFound must reach the caller unwrapped.
func (m *Manager) GetQuotaNoCache(ctx context.Context, providerUUID string) (*ProviderUsage, error) {
usage, err := m.store.Get(ctx, providerUUID)
if err != nil {
if err == ErrUsageNotFound {
return nil, fmt.Errorf("quota not found for provider: %s", providerUUID)
}
return nil, err
}
return usage, nil
return m.store.Get(ctx, providerUUID)
}

// ListQuota returns quota data for all providers.
Expand Down
19 changes: 19 additions & 0 deletions ai/quota/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,25 @@ func (f *concurrencyTestFetcher) Fetch(_ context.Context, provider *typ.Provider
return &ProviderUsage{ProviderUUID: provider.UUID}, nil
}

// TestGetQuota_NotFoundIsUnwrapped locks in that GetQuota (and
// GetQuotaNoCache) return ErrUsageNotFound identically to what the store
// returned — not a re-wrapped error. Callers such as the provider-quota
// batch handler compare with == against this sentinel to skip providers
// with no quota data instead of failing the whole request; a wrapped error
// silently breaks that comparison (this was a real bug: BatchGetQuota 500'd
// for any provider with no quota data, e.g. a vmodel/local provider, instead
// of just omitting it from the result).
func TestGetQuota_NotFoundIsUnwrapped(t *testing.T) {
manager := NewManager(DefaultConfig(), managerTestStore{}, managerTestProviderManager{}, logrus.New())

if _, err := manager.GetQuota(context.Background(), "missing"); err != ErrUsageNotFound {
t.Fatalf("GetQuota() error = %v, want ErrUsageNotFound (identity, via ==)", err)
}
if _, err := manager.GetQuotaNoCache(context.Background(), "missing"); err != ErrUsageNotFound {
t.Fatalf("GetQuotaNoCache() error = %v, want ErrUsageNotFound (identity, via ==)", err)
}
}

func TestRefreshBoundsConcurrency(t *testing.T) {
providers := make([]*typ.Provider, 20)
for i := range providers {
Expand Down
1 change: 0 additions & 1 deletion internal/server/config/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ func (c *Config) GetProviderByUUID(uuid string) (*typ.Provider, error) {
if c.providerStore == nil {
return nil, fmt.Errorf("provider store not initialized")
}

provider, err := c.providerStore.GetByUUID(uuid)
if err != nil {
return nil, fmt.Errorf("provider '%s' not found: %w", uuid, err)
Expand Down
Loading