55"""
66
77import os
8+ import uuid
89from pathlib import Path
910from typing import Any
1011
2526
2627CONFIG_FILE = CONFIG_DIR / "config.yaml"
2728CREDENTIALS_FILE = CONFIG_DIR / "credentials.yaml"
29+ MACHINE_ID_FILE = CONFIG_DIR / "machine_id"
30+ FIRST_RUN_FILE = CONFIG_DIR / ".first_run"
2831
2932# Default API endpoint
3033DEFAULT_API_URL = "https://api.annotation.garden/hedit"
@@ -82,6 +85,16 @@ class APIConfig(BaseModel):
8285 url : str = Field (default = DEFAULT_API_URL , description = "API endpoint URL" )
8386
8487
88+ class TelemetryConfig (BaseModel ):
89+ """Telemetry configuration."""
90+
91+ enabled : bool = Field (default = True , description = "Enable telemetry collection" )
92+ model_blacklist : list [str ] = Field (
93+ default_factory = lambda : [DEFAULT_MODEL ],
94+ description = "Models to exclude from telemetry" ,
95+ )
96+
97+
8598class CLIConfig (BaseModel ):
8699 """Complete CLI configuration."""
87100
@@ -90,6 +103,7 @@ class CLIConfig(BaseModel):
90103 settings : SettingsConfig = Field (default_factory = SettingsConfig )
91104 output : OutputConfig = Field (default_factory = OutputConfig )
92105 execution : ExecutionMode = Field (default_factory = ExecutionMode )
106+ telemetry : TelemetryConfig = Field (default_factory = TelemetryConfig )
93107
94108
95109def ensure_config_dir () -> None :
@@ -279,10 +293,68 @@ def clear_credentials() -> None:
279293 CREDENTIALS_FILE .unlink ()
280294
281295
296+ def get_machine_id () -> str :
297+ """Get or generate a stable machine ID for cache optimization.
298+
299+ This ID is used by OpenRouter for sticky cache routing to reduce costs.
300+ It is NOT used for telemetry and is never transmitted except to OpenRouter.
301+
302+ The ID is generated once and persists across pip updates.
303+
304+ Returns:
305+ 16-character hexadecimal machine ID
306+ """
307+ ensure_config_dir ()
308+
309+ if MACHINE_ID_FILE .exists ():
310+ try :
311+ machine_id = MACHINE_ID_FILE .read_text ().strip ()
312+ # Validate format (16 hex chars)
313+ if len (machine_id ) == 16 and all (c in "0123456789abcdef" for c in machine_id ):
314+ return machine_id
315+ except (OSError , UnicodeDecodeError ):
316+ pass # File corrupted, regenerate
317+
318+ # Generate new machine ID
319+ machine_id = uuid .uuid4 ().hex [:16 ]
320+
321+ # Save to file
322+ try :
323+ MACHINE_ID_FILE .write_text (machine_id )
324+ # Readable by user only (Unix)
325+ try :
326+ os .chmod (MACHINE_ID_FILE , 0o600 )
327+ except (OSError , AttributeError ):
328+ pass # Windows doesn't support chmod the same way
329+ except OSError :
330+ pass # If we can't write, still return the ID for this session
331+
332+ return machine_id
333+
334+
335+ def is_first_run () -> bool :
336+ """Check if this is the first time HEDit is run.
337+
338+ Returns:
339+ True if first run, False otherwise
340+ """
341+ return not FIRST_RUN_FILE .exists ()
342+
343+
344+ def mark_first_run_complete () -> None :
345+ """Mark first run as complete by creating the marker file."""
346+ ensure_config_dir ()
347+ try :
348+ FIRST_RUN_FILE .touch ()
349+ except OSError :
350+ pass # Ignore write errors
351+
352+
282353def get_config_paths () -> dict [str , Path ]:
283354 """Get paths to config files for debugging."""
284355 return {
285356 "config_dir" : CONFIG_DIR ,
286357 "config_file" : CONFIG_FILE ,
287358 "credentials_file" : CREDENTIALS_FILE ,
359+ "machine_id_file" : MACHINE_ID_FILE ,
288360 }
0 commit comments