Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/codewhale-lmm-provider.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Codewhale LMM provider
on:
pull_request:
paths:
- 'packages/codewhale-lmm-provider/**'
- 'packages/codewhale-lmm-provider'
- '.gitmodules'
- 'apps/api-go/service/oauth_server.go'
- 'apps/api-go/oauthserver/codewhale_integration_test.go'
- '.github/workflows/codewhale-lmm-provider.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
adapter:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: false
- name: Initialize adapter when extracted as a submodule
run: |
if git ls-files --stage packages/codewhale-lmm-provider | grep -q '^160000 '; then
git submodule update --init --depth 1 -- packages/codewhale-lmm-provider
fi
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm run check && npm test && npm run pack:check
working-directory: packages/codewhale-lmm-provider
registration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: apps/api-go/go.mod
cache-dependency-path: apps/api-go/go.sum
- run: go test ./oauthserver -run TestCodewhale -count=1
working-directory: apps/api-go
3 changes: 3 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@
[submodule "packages/lmm-scripts"]
path = packages/lmm-scripts
url = https://github.com/TokenNotIncluded/lmm-scripts.git
[submodule "packages/codewhale-lmm-provider"]
path = packages/codewhale-lmm-provider
url = https://github.com/TokenNotIncluded/codewhale-lmm-provider.git
72 changes: 72 additions & 0 deletions apps/api-go/oauthserver/codewhale_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package oauthserver_test

import (
"context"
"net/url"
"strings"
"testing"
"time"

"github.com/LIghtJUNction/api.lmm.best/model"
"github.com/LIghtJUNction/api.lmm.best/oauthserver"
"github.com/LIghtJUNction/api.lmm.best/service"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func TestCodewhaleClientLifecycleAndIsolation(t *testing.T) {
oauthserver.ForTestDatabases(t, func(t *testing.T, db, _ *gorm.DB) {
s := productionPolicyFixture(t, db, false)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
query, err := url.ParseQuery(productionPolicyQuery(s))
require.NoError(t, err)
query.Set("client_id", service.OAuthCodewhaleClientID)
query.Set("scope", strings.Join([]string{service.OAuthCatalogScope, service.OAuthBalanceScope, service.OAuthUsageScope, service.OAuthInvokeScope}, " "))
user := &model.User{Group: "default"}
expanded, groups, err := s.ConsentQuery(query.Encode(), user)
require.NoError(t, err)
require.Equal(t, []string{"default"}, groups)
pending, err := s.Core.BeginAuthorization(ctx, expanded, policyBrowser)
require.NoError(t, err)
consent, err := s.Core.TrustedPrepareConsent(ctx, pending.Transaction, policyBrowser, 42)
require.NoError(t, err)
approved, err := s.Core.TrustedApprove(ctx, consent.Transaction, policyBrowser, consent.Secret)
require.NoError(t, err)
redirect, err := url.Parse(approved.RedirectURI)
require.NoError(t, err)
form := url.Values{"grant_type": {"authorization_code"}, "client_id": {service.OAuthCodewhaleClientID}, "code": {redirect.Query().Get("code")}, "redirect_uri": {policyRedirect}, "code_verifier": {policyVerifier}, "resource": {s.Resource}}
tokens, err := s.Core.Exchange(ctx, form.Encode(), oauthserver.SenderBinding{})
require.NoError(t, err)
grant, err := s.Core.ValidateAccess(ctx, oauthserver.AccessRequest{Token: tokens.AccessToken, Resource: s.Resource, RequiredScopes: []string{service.OAuthInvokeScope}})
require.NoError(t, err)
require.Equal(t, service.OAuthCodewhaleClientID, grant.ClientID)
for _, scope := range []string{service.OAuthMCPBountiesScope, service.OAuthMCPDrawingScope, service.OAuthMarketDiscoverScope, service.OAuthMarketManageScope} {
_, err = s.Core.ValidateAccess(ctx, oauthserver.AccessRequest{Token: tokens.AccessToken, Resource: s.Resource, RequiredScopes: []string{scope}})
require.Error(t, err)
}
refresh := url.Values{"grant_type": {"refresh_token"}, "client_id": {service.OAuthCodewhaleClientID}, "refresh_token": {tokens.RefreshToken}, "resource": {s.Resource}}
rotated, err := s.Core.Exchange(ctx, refresh.Encode(), oauthserver.SenderBinding{})
require.NoError(t, err)
require.NotEqual(t, tokens.RefreshToken, rotated.RefreshToken)
grant, err = s.Core.ValidateAccess(ctx, oauthserver.AccessRequest{Token: rotated.AccessToken, Resource: s.Resource, RequiredScopes: []string{service.OAuthUsageScope}})
require.NoError(t, err)
require.Equal(t, service.OAuthCodewhaleClientID, grant.ClientID)
refresh.Set("client_id", service.OAuthPiClientID)
refresh.Set("refresh_token", rotated.RefreshToken)
_, err = s.Core.Exchange(ctx, refresh.Encode(), oauthserver.SenderBinding{})
require.Error(t, err, "Pi must not refresh Codewhale credentials")
})
}

func TestCodewhaleConsentRejectsUnsupportedScopes(t *testing.T) {
oauthserver.ForTestDatabases(t, func(t *testing.T, db, _ *gorm.DB) {
s := productionPolicyFixture(t, db, false)
base := "catalog:read balance:read usage:read models:invoke"
for _, scope := range []string{base + " mcp:bounties mcp:drawing", base + " market:discover", "catalog:read balance:read", base + " group:" + service.OAuthGroupID("default")} {
query := url.Values{"client_id": {service.OAuthCodewhaleClientID}, "scope": {scope}}
_, _, err := s.ConsentQuery(query.Encode(), &model.User{Group: "default"})
require.Error(t, err)
}
})
}
12 changes: 11 additions & 1 deletion apps/api-go/service/oauth_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ const (
OAuthPiClientName = "LMM for Pi"
OAuthDshClientID = "lmm-dsh"
OAuthDshClientName = "LMM for DSH"
OAuthCodewhaleClientID = "lmm-codewhale"
OAuthCodewhaleClientName = "LMM for Codewhale"
OAuthCLIClientID = "lmm"
OAuthCLIClientName = "LMM CLI"
OAuthNativeRedirect = "http://127.0.0.1/oauth/lmm/callback"
Expand Down Expand Up @@ -125,6 +127,12 @@ func NewOAuthIntegration(db *gorm.DB, cfg OAuthServerConfig) (*OAuthIntegration,
{ID: OAuthPiClientID, Name: OAuthPiClientName, RedirectURIs: []string{OAuthNativeRedirect}, Resources: []string{integration.Resource}, Scopes: scopes},
{ID: OAuthDshClientID, Name: OAuthDshClientName, RedirectURIs: []string{OAuthNativeRedirect}, Resources: []string{integration.Resource}, Scopes: scopes},
}
// Codewhale's companion adapter has no MCP or marketplace integration.
codewhaleScopes := []string{OAuthCatalogScope, OAuthBalanceScope, OAuthUsageScope, OAuthInvokeScope}
for _, group := range groups {
codewhaleScopes = append(codewhaleScopes, OAuthGroupScope(group))
}
clients = append(clients, oauthserver.NativeClient{ID: OAuthCodewhaleClientID, Name: OAuthCodewhaleClientName, RedirectURIs: []string{OAuthNativeRedirect}, Resources: []string{integration.Resource}, Scopes: codewhaleScopes})
// CLI discovery does not authorize relay, MCP or account administration.
cliScopes := []string{OAuthCatalogScope, OAuthBalanceScope}
for _, group := range groups {
Expand Down Expand Up @@ -216,7 +224,7 @@ func (s *OAuthIntegration) GrantedGroups(user *model.User, grant oauthserver.Gra
// validation. Database/cache failures never imply access. It does not mutate
// OAuth tables or acquire a second pool connection while core owns a transaction.
func (s *OAuthIntegration) Authorize(ctx context.Context, tx *gorm.DB, grant oauthserver.Grant) error {
if (grant.ClientID != OAuthPiClientID && grant.ClientID != OAuthDshClientID && grant.ClientID != OAuthCLIClientID) || grant.Resource != s.Resource {
if (grant.ClientID != OAuthPiClientID && grant.ClientID != OAuthDshClientID && grant.ClientID != OAuthCLIClientID && grant.ClientID != OAuthCodewhaleClientID) || grant.Resource != s.Resource {
return ErrOAuthDenied
}
if tx == nil {
Expand Down Expand Up @@ -277,6 +285,8 @@ func (s *OAuthIntegration) ConsentQuery(raw string, user *model.User) (string, [
}
if query.Get("client_id") == OAuthCLIClientID {
profiles = [][]string{{OAuthCatalogScope, OAuthBalanceScope}}
} else if query.Get("client_id") == OAuthCodewhaleClientID {
profiles = [][]string{currentBase}
}
slices.Sort(requested)
validProfile := false
Expand Down
36 changes: 36 additions & 0 deletions docs/codewhale-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Codewhale LMM OAuth adapter

Source: `packages/codewhale-lmm-provider`, pinned as a Git submodule to
`TokenNotIncluded/codewhale-lmm-provider`. Adapter source, tests, CI and package
metadata live in the independent repository; this parent repository owns the
server-side OAuth client registration and the pinned submodule revision.

The backend registers public native client `lmm-codewhale` / `LMM for Codewhale`.
It reuses the existing PKCE S256, state/issuer-bound loopback callback, resource,
refresh rotation and revocation contract. Its initial profile is exactly
`catalog:read balance:read usage:read models:invoke`, plus only the groups added
by explicit consent. MCP and marketplace permissions are not registered or added.
Pi/DSH/CLI grants and existing OAuth deployment switches are unchanged.

Codewhale's current plugin bundle API has no executable provider/auth adapter.
This package therefore uses a companion CLI and the documented named
`openai-compatible` provider configuration. Its native bundle contains a help
skill, not a fictitious OAuth provider entry. Only models advertising
`openai-completions` are admitted. Responses/Messages-only models, Pi-specific
model-picker integration and MCP/marketplace features are not claimed.

The CLI holds LMM credentials; the host only receives a random per-run loopback
capability through `api_key_env`. It must be launched with `codewhale-lmm run`.
The temporary provider config does not overwrite the existing Codewhale config.
The bridge rechecks catalog/grant on each request, replaces the exact synthetic
model ID with the upstream model, and sets `X-LMM-Group` without fallback.
Streaming bytes are passed through, disconnects cancel the upstream, and the
adapter does not retry inference POSTs. The host may have its own retry policy.

Local verification: Linux, Node.js 22.16.0, 36 passing tests and syntax/package
checks, including mock OAuth HTTP, separate-process refresh locking, and a mock
host process. This is not live Codewhale or production/billing acceptance.
Backend tests: `cd apps/api-go && go test ./oauthserver -run TestCodewhale -count=1`.
The registration tests passed in the initial GitHub CI run 35638700004; they
were not run in the dependency-unavailable local review environment. Production rollout remains gated by real interoperability
and billing tests; registering a client does not enable or deploy OAuth.
1 change: 1 addition & 0 deletions packages/codewhale-lmm-provider
Submodule codewhale-lmm-provider added at 8c78be
Loading