17 changed files with 244 additions and 28 deletions
+4 -1
View File
@@ -2,7 +2,7 @@
TurkServis'in Hermes Agent tabanlı masaüstü geliştirme ortamı. TurkServis'in Hermes Agent tabanlı masaüstü geliştirme ortamı.
- Ürün sürümü: **1.0.0-beta.1**, ilk paket hedefi Windows x64. - Ürün sürümü: **1.0.0-beta.4**, ilk paket hedefi Windows x64.
- Varsayılan dil Türkçe; ayarlardan diğer dillere geçilebilir. - Varsayılan dil Türkçe; ayarlardan diğer dillere geçilebilir.
- İlk kurulum, sohbet, dosyalar, terminal ve temel ayarlar Türkçeleştirilmiştir. - İlk kurulum, sohbet, dosyalar, terminal ve temel ayarlar Türkçeleştirilmiştir.
Henüz çevrilmeyen gelişmiş ekranlar ve üçüncü taraf eklentiler İngilizceye döner. Henüz çevrilmeyen gelişmiş ekranlar ve üçüncü taraf eklentiler İngilizceye döner.
@@ -10,6 +10,9 @@ TurkServis'in Hermes Agent tabanlı masaüstü geliştirme ortamı.
- TurkServis bağlantısı `https://ai.turkservis.online/v1` üzerinden kullanıcının - TurkServis bağlantısı `https://ai.turkservis.online/v1` üzerinden kullanıcının
kendi API anahtarını doğrular ve hesabına açık modelleri dinamik olarak getirir. kendi API anahtarını doğrular ve hesabına açık modelleri dinamik olarak getirir.
Kaynakta veya kurulum paketinde müşteri API anahtarı bulunmaz. Kaynakta veya kurulum paketinde müşteri API anahtarı bulunmaz.
- AITURK model kataloğu arka planda otomatik yenilenir. Açık model listeleri
dakikada bir sunucuyu kontrol eder; 9Router ve OmniRoute'ta yeni yayımlanan
kayıtlar elle liste düzenlemeden görünür. Admin engeli sunucuda uygulanır.
- Bu bağlantı model sağlayıcısıdır; yerel ajan ve dosya araçları bilgisayarda - Bu bağlantı model sağlayıcısıdır; yerel ajan ve dosya araçları bilgisayarda
çalışır. Uzak Hermes gateway bağlantısı farklı bir özelliktir. çalışır. Uzak Hermes gateway bağlantısı farklı bir özelliktir.
@@ -2,13 +2,23 @@ import assert from 'node:assert/strict'
import { test } from 'vitest' import { test } from 'vitest'
import { classifyActiveRuntime, hasValidBootstrapMarker } from './active-runtime-state' import { classifyActiveRuntime, hasValidBootstrapMarker, needsPackagedRuntimeUpgrade } from './active-runtime-state'
const VALID_MARKER = { const VALID_MARKER = {
pinnedCommit: '1234567890abcdef1234567890abcdef12345678', pinnedCommit: '1234567890abcdef1234567890abcdef12345678',
schemaVersion: 1 schemaVersion: 1
} }
test('a package upgrade refreshes only a proven managed runtime, once per package', () => {
const stamp = {commit: 'b'.repeat(40), source: 'git'}
assert.equal(needsPackagedRuntimeUpgrade(true, stamp, VALID_MARKER), true)
assert.equal(needsPackagedRuntimeUpgrade(true, stamp, {...VALID_MARKER, pinnedCommit: stamp.commit}), false)
assert.equal(needsPackagedRuntimeUpgrade(true, stamp, {...VALID_MARKER, pinnedCommit: 'c'.repeat(40), packageCommit: stamp.commit}), false)
assert.equal(needsPackagedRuntimeUpgrade(false, stamp, VALID_MARKER), false)
assert.equal(needsPackagedRuntimeUpgrade(true, stamp, null), false)
assert.equal(needsPackagedRuntimeUpgrade(true, {commit: '0'.repeat(40), source: 'fallback'}, VALID_MARKER), false)
})
test('hasValidBootstrapMarker accepts the current schema with a real-looking commit', () => { test('hasValidBootstrapMarker accepts the current schema with a real-looking commit', () => {
assert.equal(hasValidBootstrapMarker(VALID_MARKER, 1), true) assert.equal(hasValidBootstrapMarker(VALID_MARKER, 1), true)
}) })
@@ -1,8 +1,21 @@
export interface BootstrapMarkerLike { export interface BootstrapMarkerLike {
pinnedCommit?: unknown pinnedCommit?: unknown
packageCommit?: unknown
schemaVersion?: unknown schemaVersion?: unknown
} }
// An AITURK package upgrade must bring its managed agent along with the UI.
// Developer checkouts and installs without proven Desktop ownership keep their
// existing launch behavior. The installer preserves edits and refuses rollback.
export function needsPackagedRuntimeUpgrade(
packaged: boolean, stamp: { commit?: string; source?: string } | null,
marker: BootstrapMarkerLike | null
): boolean {
return Boolean(packaged && stamp?.source !== 'fallback' &&
/^[0-9a-f]{40}$/i.test(stamp?.commit || '') && !/^0+$/.test(stamp?.commit || '') &&
hasValidBootstrapMarker(marker, 1) && (marker?.packageCommit || marker?.pinnedCommit) !== stamp?.commit)
}
export interface ActiveRuntimeState { export interface ActiveRuntimeState {
hasValidMarker: boolean hasValidMarker: boolean
shouldUseActiveRuntime: boolean shouldUseActiveRuntime: boolean
@@ -143,8 +143,8 @@ test('resolveMarkerPinnedCommit prefers real HEAD over fallback stamp zeros', ()
resolveMarkerPinnedCommit({ commit: 'd'.repeat(40), branch: 'main' }, '/tmp/checkout', { resolveMarkerPinnedCommit({ commit: 'd'.repeat(40), branch: 'main' }, '/tmp/checkout', {
resolveHead: () => realHead resolveHead: () => realHead
}), }),
'd'.repeat(40), realHead,
'packaged real pin wins over checkout HEAD' 'marker reports the actual installed checkout, including a preserved newer runtime'
) )
assert.equal( assert.equal(
resolveMarkerPinnedCommit({ commit: ZERO_COMMIT, branch: 'main' }, '/tmp/missing', { resolveMarkerPinnedCommit({ commit: ZERO_COMMIT, branch: 'main' }, '/tmp/missing', {
+7 -6
View File
@@ -111,16 +111,16 @@ function resolveMarkerPinnedCommit(
): string | null { ): string | null {
const resolveHead = opts.resolveHead || resolveCheckoutHead const resolveHead = opts.resolveHead || resolveCheckoutHead
if (installStamp && isPinnedCommit(installStamp.commit)) {
return installStamp.commit
}
const head = resolveHead(activeRoot) const head = resolveHead(activeRoot)
if (head) { if (head) {
return head return head
} }
if (installStamp && isPinnedCommit(installStamp.commit)) {
return installStamp.commit
}
return readExistingPinnedCommit(activeRoot) return readExistingPinnedCommit(activeRoot)
} }
@@ -918,14 +918,14 @@ async function runBootstrap(opts) {
try { try {
const existingCheckout = hasExistingGitCheckout(activeRoot) const existingCheckout = hasExistingGitCheckout(activeRoot)
const pinCommit = !existingCheckout const pinCommit = Boolean(installStamp && isPinnedCommit(installStamp.commit)) || !existingCheckout
if (existingCheckout && installStamp && installStamp.commit) { if (existingCheckout && installStamp && installStamp.commit) {
emit({ emit({
type: 'log', type: 'log',
line: line:
`[bootstrap] existing checkout detected at ${activeRoot}; ` + `[bootstrap] existing checkout detected at ${activeRoot}; ` +
`not pinning to packaged install stamp ${installStamp.commit.slice(0, 12)}` `applying packaged install stamp ${installStamp.commit.slice(0, 12)} with installer rollback protection`
}) })
} }
@@ -1002,6 +1002,7 @@ async function runBootstrap(opts) {
const markerPayload = { const markerPayload = {
pinnedCommit, pinnedCommit,
packageCommit: installStamp && isPinnedCommit(installStamp.commit) ? installStamp.commit : null,
pinnedBranch: installStamp ? installStamp.branch : null pinnedBranch: installStamp ? installStamp.branch : null
} }
+7 -1
View File
@@ -30,7 +30,7 @@ import {
systemPreferences systemPreferences
} from 'electron' } from 'electron'
import { classifyActiveRuntime } from './active-runtime-state' import { classifyActiveRuntime, needsPackagedRuntimeUpgrade } from './active-runtime-state'
import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport' import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport'
import { appIconCandidates, resolveAppIcon } from './app-icon' import { appIconCandidates, resolveAppIcon } from './app-icon'
import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child' import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child'
@@ -4597,6 +4597,7 @@ function writeBootstrapMarker(payload) {
const merged = { const merged = {
schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION, schemaVersion: BOOTSTRAP_MARKER_SCHEMA_VERSION,
pinnedCommit: payload.pinnedCommit || null, pinnedCommit: payload.pinnedCommit || null,
packageCommit: payload.packageCommit || null,
pinnedBranch: payload.pinnedBranch || null, pinnedBranch: payload.pinnedBranch || null,
completedAt: new Date().toISOString(), completedAt: new Date().toISOString(),
desktopVersion: app.getVersion() desktopVersion: app.getVersion()
@@ -4905,6 +4906,11 @@ function resolveHermesBackend(backendArgs) {
// bootstrap when the runtime itself is unusable. // bootstrap when the runtime itself is unusable.
const activeRuntime = activeRuntimeState() const activeRuntime = activeRuntimeState()
if (needsPackagedRuntimeUpgrade(IS_PACKAGED, INSTALL_STAMP, readBootstrapMarker())) {
rememberLog('[bootstrap] AITURK package changed; updating its managed agent before launch.')
return createBootstrapBackend(backendArgs)
}
if (activeRuntime.shouldUseActiveRuntime && !bootstrapRepairRequested) { if (activeRuntime.shouldUseActiveRuntime && !bootstrapRepairRequested) {
if (!activeRuntime.hasValidMarker) { if (!activeRuntime.hasValidMarker) {
rememberLog( rememberLog(
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "aiturk-ide", "name": "aiturk-ide",
"productName": "AITURK IDE", "productName": "AITURK IDE",
"private": true, "private": true,
"version": "1.0.0-beta.1", "version": "1.0.0-beta.4",
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
"author": "AITURK / TurkServis", "author": "AITURK / TurkServis",
"repository": { "repository": {
@@ -103,7 +103,7 @@ describe('the catalog owns model curation', () => {
renderMenu() renderMenu()
await screen.findByText(/Gemini 2\.5 Flash/i) await screen.findByText(/Gemini 2\.5 Flash/i)
const input = screen.getByRole('textbox', { name: 'Search models' }) const input = screen.getByRole('textbox', { name: /Search models|Model ara/ })
fireEvent.change(input, { target: { value: 'gemini-3.1' } }) fireEvent.change(input, { target: { value: 'gemini-3.1' } })
@@ -116,7 +116,7 @@ describe('the catalog owns model curation', () => {
renderMenu() renderMenu()
await screen.findByText(/Gemini 3\.1 Pro/i) await screen.findByText(/Gemini 3\.1 Pro/i)
fireEvent.click(screen.getByText('Edit models…')) fireEvent.click(screen.getByText(/Edit models…|Modelleri düzenle…/))
expect($modelVisibilityOpen.get()).toBe(true) expect($modelVisibilityOpen.get()).toBe(true)
}) })
+2 -2
View File
@@ -25,7 +25,7 @@ export function aiturkInstallStage(name: string): string | undefined {
const en = { const en = {
intro: 'Bring your code, question, or idea. Build with AITURK.', intro: 'Bring your code, question, or idea. Build with AITURK.',
pitch: 'Connect your models with your TurkServis account.', pitch: 'Connect your models and available image and audio tools with your TurkServis account.',
key: 'TurkServis API key', key: 'TurkServis API key',
getKey: 'My account and API keys', getKey: 'My account and API keys',
noModels: 'No models are available for this key.', noModels: 'No models are available for this key.',
@@ -74,7 +74,7 @@ const en = {
const tr: typeof en = { const tr: typeof en = {
intro: 'Kodunuzu, sorunuzu veya fikrinizi paylaşın. AITURK ile birlikte geliştirin.', intro: 'Kodunuzu, sorunuzu veya fikrinizi paylaşın. AITURK ile birlikte geliştirin.',
pitch: 'TurkServis hesabınızla modellerinize bağlanın.', pitch: 'TurkServis hesabınızla modellerinize, kullanılabilir görsel ve ses araçlarına bağlanın.',
key: 'TurkServis API anahtarı', key: 'TurkServis API anahtarı',
getKey: 'Hesabım ve API anahtarlarım', getKey: 'Hesabım ve API anahtarlarım',
noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.', noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.',
+25 -1
View File
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { QueryObserver, focusManager } from '@tanstack/react-query'
import { invalidateProfileScopedQueries, queryClient } from './query-client' import { invalidateProfileScopedQueries, queryClient } from './query-client'
@@ -11,6 +12,29 @@ describe('invalidateProfileScopedQueries', () => {
queryClient.clear() queryClient.clear()
}) })
it('refreshes an observed model list without reopening the picker', async () => {
vi.useFakeTimers()
focusManager.setFocused(true)
let models = ['old-model']
const observer = new QueryObserver(queryClient, {
queryKey: ['model-options', 'selected-profile'],
queryFn: async () => models
})
const unsubscribe = observer.subscribe(() => {})
try {
await observer.refetch()
expect(observer.getCurrentResult().data).toEqual(['old-model'])
models = ['new-model', 'new-combo']
await vi.advanceTimersByTimeAsync(60_000)
expect(observer.getCurrentResult().data).toEqual(models)
} finally {
unsubscribe()
queryClient.clear()
focusManager.setFocused(undefined)
vi.useRealTimers()
}
})
it('invalidates profile-scoped caches and leaves account/global caches intact', () => { it('invalidates profile-scoped caches and leaves account/global caches intact', () => {
const profileScoped = [ const profileScoped = [
['hermes-config-record'], ['hermes-config-record'],
+6
View File
@@ -12,6 +12,12 @@ export const queryClient = new QueryClient({
} }
}) })
queryClient.setQueryDefaults(['model-options'], {
staleTime: 30_000,
refetchInterval: 60_000,
refetchOnWindowFocus: true
})
// Curried, setState-shaped cache writer for optimistic write-through: keeps // Curried, setState-shaped cache writer for optimistic write-through: keeps
// mutation sites terse (`setX(next)` or `setX(prev => …)`) over one query key. // mutation sites terse (`setX(next)` or `setX(prev => …)`) over one query key.
export const writeCache = export const writeCache =
+41
View File
@@ -0,0 +1,41 @@
"""Connect the AITURK distribution's media tools to its existing member key."""
def configure_existing_media(cfg: dict) -> bool:
"""Migrate an existing member endpoint when the upgraded backend starts."""
providers = cfg.get("providers") or {}
entries = providers.values() if isinstance(providers, dict) else []
for entry in entries:
if isinstance(entry, dict) and configure_media(cfg, entry):
return True
return False
def configure_media(cfg: dict, entry: dict) -> bool:
if entry.get("base_url", "").rstrip("/") != "https://ai.turkservis.online/v1":
return False
key_env = entry.get("key_env")
if not isinstance(key_env, str) or not key_env:
return False
servers = cfg.get("mcp_servers")
if servers is None:
servers = cfg["mcp_servers"] = {}
if not isinstance(servers, dict):
return False
name = "turkservis-media"
existing = servers.get(name, {})
if not isinstance(existing, dict):
return False
if existing and existing.get("url") != "https://ai.turkservis.online/mcp":
return False
updated = {
**existing,
"url": "https://ai.turkservis.online/mcp",
"headers": {"Authorization": "Bearer ${" + key_env + "}"},
"timeout": 300,
"tools": {"include": ["list_capabilities", "generate_media"]},
}
if updated == existing:
return False
servers[name] = updated
return True
+17 -4
View File
@@ -8,6 +8,7 @@ Add, remove, or reorder entries here — both `hermes setup` and
from __future__ import annotations from __future__ import annotations
import copy import copy
import contextvars
import json import json
import http.client import http.client
import logging import logging
@@ -4809,10 +4810,11 @@ def _spawn_swr_refresh(cache_key: str, refresh_fn=None) -> None:
``PROVIDER_REGISTRY`` slug and refreshed via :func:`provider_model_ids` ``PROVIDER_REGISTRY`` slug and refreshed via :func:`provider_model_ids`
(the original behavior). (the original behavior).
""" """
refresh_identity = (str(_provider_models_cache_path()), cache_key)
with _swr_refresh_lock: with _swr_refresh_lock:
if cache_key in _swr_refresh_inflight: if refresh_identity in _swr_refresh_inflight:
return return
_swr_refresh_inflight.add(cache_key) _swr_refresh_inflight.add(refresh_identity)
def _default_refresh(): def _default_refresh():
live = provider_model_ids(cache_key, force_refresh=True) live = provider_model_ids(cache_key, force_refresh=True)
@@ -4847,10 +4849,10 @@ def _spawn_swr_refresh(cache_key: str, refresh_fn=None) -> None:
logger.debug("SWR refresh failed for %s", cache_key, exc_info=True) logger.debug("SWR refresh failed for %s", cache_key, exc_info=True)
finally: finally:
with _swr_refresh_lock: with _swr_refresh_lock:
_swr_refresh_inflight.discard(cache_key) _swr_refresh_inflight.discard(refresh_identity)
threading.Thread( threading.Thread(
target=_refresh, daemon=True, name=f"model-cache-swr-{cache_key}" target=contextvars.copy_context().run, args=(_refresh,), daemon=True, name=f"model-cache-swr-{cache_key}"
).start() ).start()
@@ -6855,6 +6857,17 @@ def cached_fetch_api_models(
entry = cache.get(cache_key) entry = cache.get(cache_key)
now = time.time() now = time.time()
if normalized_url == "https://ai.turkservis.online/v1":
ttl_seconds = min(ttl_seconds, 30)
if cache_only and (not _cache_entry_valid(entry, fp) or now - entry["at"] >= ttl_seconds):
def _refresh_aiturk():
live = fetch_api_models(api_key, base_url, timeout=timeout,
api_mode=api_mode, headers=headers)
if live is None:
return None
return {"fp": fp, "at": time.time(), "models": list(live)}
_spawn_swr_refresh(cache_key, _refresh_aiturk)
if cache_only: if cache_only:
# Same trust window as the stale-while-revalidate tier below, minus # Same trust window as the stale-while-revalidate tier below, minus
# the revalidation: an entry this side of the bound is good enough to # the revalidation: an entry this side of the bound is good enough to
+9
View File
@@ -405,6 +405,12 @@ def _eager_reconcile_own_session_db() -> None:
@asynccontextmanager @asynccontextmanager
async def _lifespan(app: "FastAPI"): async def _lifespan(app: "FastAPI"):
from hermes_cli.aiturk_media import configure_existing_media
from hermes_cli.config import read_raw_config, save_config
with _CONFIG_MUTATION_LOCK:
media_cfg = read_raw_config()
if configure_existing_media(media_cfg):
save_config(media_cfg)
app.state.event_channels = {} # dict[str, set] app.state.event_channels = {} # dict[str, set]
app.state.event_lock = asyncio.Lock() app.state.event_lock = asyncio.Lock()
app.state.pty_active_session_files = {} # dict[str, Path] app.state.pty_active_session_files = {} # dict[str, Path]
@@ -8823,6 +8829,9 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T
providers[endpoint_id] = entry providers[endpoint_id] = entry
cfg["providers"] = providers cfg["providers"] = providers
from hermes_cli.aiturk_media import configure_media
configure_media(cfg, entry)
if body.make_default: if body.make_default:
cfg["model"] = _apply_main_model_assignment( cfg["model"] = _apply_main_model_assignment(
cfg.get("model", {}), endpoint_id, model, base_url cfg.get("model", {}), endpoint_id, model, base_url
+7 -7
View File
@@ -65,7 +65,8 @@
}, },
"apps/desktop": { "apps/desktop": {
"name": "aiturk-ide", "name": "aiturk-ide",
"version": "1.0.0-beta.1", "version": "1.0.0-beta.4",
"license": "MIT",
"dependencies": { "dependencies": {
"@assistant-ui/core": "0.2.23", "@assistant-ui/core": "0.2.23",
"@assistant-ui/react": "0.14.24", "@assistant-ui/react": "0.14.24",
@@ -175,8 +176,7 @@
}, },
"optionalDependencies": { "optionalDependencies": {
"get-windows": "9.3.0" "get-windows": "9.3.0"
}, }
"license": "MIT"
}, },
"apps/desktop/node_modules/@babel/code-frame": { "apps/desktop/node_modules/@babel/code-frame": {
"version": "8.0.0", "version": "8.0.0",
@@ -7465,6 +7465,10 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/aiturk-ide": {
"resolved": "apps/desktop",
"link": true
},
"node_modules/ajv": { "node_modules/ajv": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -19873,10 +19877,6 @@
"engines": { "engines": {
"node": ">=10" "node": ">=10"
} }
},
"node_modules/aiturk-ide": {
"resolved": "apps/desktop",
"link": true
} }
} }
} }
@@ -0,0 +1,55 @@
"""An open AITURK picker discovers changes without an explicit refresh."""
import threading
import time
from hermes_cli import models
def test_cache_only_picker_refreshes_stale_aiturk_catalog(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
url = "https://ai.turkservis.online/v1"
key = "test-only"
fp = models._custom_endpoint_fingerprint(key, None, None)
models._save_provider_models_cache({"custom:" + url: {
"fp": fp, "at": time.time() - 90, "models": ["old"]}})
started = threading.Event()
release = threading.Event()
def fetch(*args, **kwargs):
started.set()
assert release.wait(5)
return ["new-model", "new-combo"]
monkeypatch.setattr(models, "fetch_api_models", fetch)
try:
assert models.cached_fetch_api_models(key, url, cache_only=True) == ["old"]
assert started.wait(5)
finally:
release.set()
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
result = models.cached_fetch_api_models(key, url, cache_only=True)
if result == ["new-model", "new-combo"]:
break
time.sleep(0.02)
assert result == ["new-model", "new-combo"]
def test_background_refresh_preserves_context_scope(tmp_path, monkeypatch):
import contextvars
scope = contextvars.ContextVar("test_catalog_scope", default="wrong")
seen = []
done = threading.Event()
monkeypatch.setattr(models, "_provider_models_cache_path", lambda: tmp_path / scope.get())
monkeypatch.setattr(models, "_load_provider_models_cache", lambda: {})
def save(cache):
seen.append((scope.get(), cache))
done.set()
monkeypatch.setattr(models, "_save_provider_models_cache", save)
token = scope.set("selected-profile")
try:
models._spawn_swr_refresh("scope-test", lambda: {"models": [scope.get()]})
finally:
scope.reset(token)
assert done.wait(5)
assert seen == [("selected-profile", {"scope-test": {"models": ["selected-profile"]}})]
+35
View File
@@ -0,0 +1,35 @@
from hermes_cli.aiturk_media import configure_media, configure_existing_media
def test_existing_member_profile_migrates_once_without_changing_provider():
entry = {"base_url": "https://ai.turkservis.online/v1", "key_env": "MEMBER_KEY"}
cfg = {"providers": {"turkservis": dict(entry)}, "model": {"default": "kept"}}
assert configure_existing_media(cfg)
assert cfg["providers"]["turkservis"] == entry
assert cfg["model"] == {"default": "kept"}
assert not configure_existing_media(cfg)
def test_member_key_is_referenced_without_copying_or_overwriting_other_servers():
cfg = {"mcp_servers": {"other": {"url": "https://example.org/mcp"}}}
entry = {"base_url": "https://ai.turkservis.online/v1", "key_env": "HERMES_CUSTOM_TURKSERVIS_API_KEY"}
assert configure_media(cfg, entry)
assert cfg["mcp_servers"]["other"] == {"url": "https://example.org/mcp"}
assert cfg["mcp_servers"]["turkservis-media"]["headers"]["Authorization"] == "Bearer ${HERMES_CUSTOM_TURKSERVIS_API_KEY}"
assert not configure_media(cfg, entry)
entry["key_env"] = "ROTATED_MEMBER_KEY"
assert configure_media(cfg, entry)
assert "ROTATED_MEMBER_KEY" in cfg["mcp_servers"]["turkservis-media"]["headers"]["Authorization"]
def test_other_provider_and_missing_key_do_not_enable_media():
for entry in ({"base_url": "https://example.com/v1", "key_env": "SECRET"}, {"base_url": "https://ai.turkservis.online/v1"}):
cfg = {}
assert not configure_media(cfg, entry)
assert cfg == {}
def test_explicitly_disabled_connection_stays_disabled():
cfg = {"mcp_servers": {"turkservis-media": {"url": "https://ai.turkservis.online/mcp", "enabled": False}}}
configure_media(cfg, {"base_url": "https://ai.turkservis.online/v1", "key_env": "KEY"})
assert cfg["mcp_servers"]["turkservis-media"]["enabled"] is False